frootbox / forms
Forms for the Frootbox CMS: build forms from field types, send entries by mail through the queue, keep them in the backend, spam protection with captchas of your choice and a spam folder
Requires
- php: >=8.5
- frootbox/core: ^0.1
- frootbox/database: ^0.1
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
Requires (Dev)
None
Suggests
- frootbox/backend: Screens for the forms and their entries
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 22:33:27 UTC
README
Forms for the Frootbox CMS: build forms from field types in the
backend, show them with the block Form (frootbox/forms/Form) on
any page, keep the entries and mail them to recipients through the
queue. Spam protection comes in layers: invisible checks, a captcha
of your choice, rules for the content and a spam folder.
Field types
text, email, phone, number, date, textarea, select,
radio, checkboxes, checkbox, consent (always required) and
files. Each field has a key, a label, a hint and, for choices, options
by language.
A consent may link to a page, mostly the privacy policy:
form.field-add and form.field-update take link, a target such as
page:01J… or an address. The text in [square brackets] of the label
becomes the link; without brackets the theme puts a link "Privacy
policy" after the label. Mails, entries and the export show the label
without the brackets.
Files
A field files takes up to 5 files of at most 10 MB each, 20 MB
together: PDF, DOCX, ODT, plain text, JPEG, PNG and WebP, checked by
their content. They are kept in storage/private/forms/<entry>/, never
mailed, and downloaded only in the backend at
/cms/forms/entries/{entry}/files/{file} with the permission
frootbox/forms/entry.list. A day after an entry is deleted the
scheduler removes its files, so undoing the deletion keeps them.
What an entry is about
On the address of an object, e.g. a job at frootbox/jobs/job:<id>, the
block sends that target as _about. The entry keeps it with the
published title of the object (about, aboutTitle); the mail subject
and the CSV export name it.
Another block can show a form with
render_block('frootbox/forms/Form', {form: id}).
Actions and queries
Everything goes through actions, also from the REST API and the AI:
frootbox/forms/form.create, .update, .field-add, .field-update,
.field-move, .field-remove, .publish, .discard, .delete, the
queries frootbox/forms/form.list, .show and .choices, and for the
entries frootbox/forms/entry.list, .show, .delete and .prune.
The role "editor" gets frootbox/forms/form.* on installation; the
entries stay with the admins.
Visitors send a form with the public action frootbox/forms/form.submit.
The website posts to /_forms/submit; with JavaScript it answers JSON,
without it leads back to the page with ?fbForm=<id> or
?fbFormError=<id>.
Spam protection
Every form has three invisible checks; what fails them gets the answer of a success but is neither kept nor sent:
- a hidden field that people leave empty,
- the time the form was shown, signed with
APP_SECRET: sent within 3 seconds counts as a bot, - at most 5 entries per address and form in 10 minutes, counted with a keyed hash of the address.
Then the captcha of the form and the rules for the content check the
entry. A captcha may find it wrong (the visitor sees an error, e.g.
a wrong sum) or suspicious; a suspicious entry and one a rule
finds go to the spam folder, without a mail. In the backend it moves
back to the inbox with frootbox/forms/entry.release, which mails it
then. Spam is deleted after 30 days.
Two captchas come with the module:
frootbox/forms/proof-of-work, the default: invisible. When the visitor enters the form, the browser fetches a task from/_forms/proof/<id>and searches the number up to 200,000 whose SHA-256 with the salt gives the hash of the task. A task is signed, valid for an hour and counts once. Without JavaScript the entry goes to the spam folder.frootbox/forms/math: a sum to solve, also without JavaScript.
Each form chooses one in the tab Spam protection, or none, or the
standard of the website (setting captcha).
A captcha of your own
An extension registers a captcha with a provider from the container:
$registry->add(ExternalService::class, new ExternalService( 'acme/captcha/turnstile', 'Cloudflare Turnstile', 'Cloudflare, Inc.', 'https://www.cloudflare.com/privacypolicy/', )); $registry->add(Captcha::class, new Captcha( 'acme/captcha/turnstile', 'Cloudflare Turnstile', TurnstileCaptcha::class, service: 'acme/captcha/turnstile', description: 'A check by Cloudflare, loaded after consent.', ));
TurnstileCaptcha implements Frootbox\Forms\Captcha\CaptchaProvider:
widget() returns the markup inside the form, verify() checks what
came back in fields named captcha[…] and returns
CaptchaResult::passed(), ::wrong($message) or
::suspicious($reason). With a service the markup sits behind
external() of the core and loads only after consent; without
consent the form cannot be sent, so verify() returns wrong() with
a hint.
A captcha that needs keys implements ConfigurableCaptcha with
ready(): until it is ready, frootbox/forms/captcha.list says so, the
backend greys it out, form.update refuses it and a form that has it
falls back to the proof of work.
In the browser the script of the forms fires two events at the form.
frootbox:forms-sending comes before sending; a captcha pushes a
promise to event.detail.wait, and sending waits for it, e.g. to fetch
a token. frootbox:forms-failed comes after a failed try, with the
errors by pointer in event.detail.errors, e.g. /captcha.
frootbox/captcha-recaptcha is a complete example.
Rules for the content
Two rules come with the module: more links than the setting maxLinks
(default 3) and a word of the setting spamWords (separated by commas).
An extension adds one with
$registry->add(SpamChecks::class, new SpamChecks(AcmeCheck::class));
AcmeCheck implements Frootbox\Forms\Spam\SpamCheck and returns the
reason in English, or null.
Settings
keepDays(default 180, 0 keeps entries until someone deletes them): older entries are deleted when the scheduler runs.captcha(defaultfrootbox/forms/proof-of-work): the captcha of forms that choose none, a name ornone.maxLinks(default 3) andspamWords(empty): rules for the content.
Set them in the backend or in config/extensions/frootbox/forms.yaml.
Each recipient gets a plain text mail through the job mail.send of
the core; answering it goes to the first email field. The transport
comes from MAILER_DSN in .env.
Source and issues
This package is developed in the Frootbox monorepo and mirrored here read-only. Questions, bugs and ideas go there, see the documentation.
License
MIT