Forms
Server-rendered forms — contact pages, quote requests, newsletter signups,
anything that posts to an inbox. Themes write the layout with the
form_* helpers; the controller validates, protects, and mails.
This page is the canonical reference. The form-helper section in twig-helpers.md is a quick API list that links back here.
What forms are NOT for. Customer registration, login, password reset — those have their own controllers + endpoints, shipped by the Customer module. Don't reach for
form_*helpers there; see operators/modules/customer.md for the dedicatedcms_register_form()/cms_login_form()/cms_password_forgot_form()helpers.
End-to-end at a glance
page from the cache → visitor starts on the form
→ form module: POST /_cms/form/{websiteCode}/{formId}/schema
(shared schema in, per-visitor schema out)
visitor submits → POST /_cms/form/{websiteCode}/{formId}
(rate-limit) (signed schema) (time-gate)
(CSRF) (honeypot) (validate) (single use)
(spam score) (mail) (redirect)
→ email arrives at the form's "recipient"
→ visitor lands on success_url (or back with a flash)
Pages with a form are cached like any other page; the per-visitor part of the form arrives separately. See Forms on cached pages.
The controller is App\Controller\FormSubmitController in cms-public.
Every protection above is always-on — there is no "off" toggle.
A complete contact form
A working pages/contact.html.twig from a theme:
{% extends '@website/base.html.twig' %}
{% block body %}
<section class="container py-16">
<h1 class="font-display text-4xl mb-2">{{ field('contact_title', 'text') }}</h1>
<p class="text-ink-soft mb-8">{{ field('contact_intro', 'html') }}</p>
{# Status banner — null if no flash. #}
{% set status = form_status() %}
{% if status == 'success' %}
<div class="rounded bg-green-50 border border-green-200 px-4 py-3 mb-6 text-green-800">
Bedankt — we nemen binnen 1 werkdag contact op.
</div>
{% endif %}
{{ form_start('contact') }}
{{ form_protection() }}
{# Form-level errors (CSRF mismatch, expired schema, rate limit,
etc.) — emitted inside the open form. #}
{{ form_errors() }}
<div class="grid gap-4 md:grid-cols-2">
<div>
{{ form_label('name', 'Naam') }}
{{ form_input('name', 'text', {required: true, max: 80}) }}
{{ form_error('name') }}
</div>
<div>
{{ form_label('email', 'E-mailadres') }}
{{ form_input('email', 'email', {required: true, placeholder: 'jij@example.nl'}) }}
{{ form_error('email') }}
</div>
</div>
<div class="mt-4">
{{ form_label('subject', 'Onderwerp') }}
{{ form_select('subject', [
{value: 'general', label: 'Algemene vraag'},
{value: 'booking', label: 'Afspraak boeken'},
{value: 'complaint', label: 'Klacht of probleem'},
], {required: true}) }}
{{ form_error('subject') }}
</div>
<div class="mt-4">
{{ form_label('message', 'Bericht') }}
{{ form_textarea('message', {rows: 6, required: true, min: 10, max: 2000}) }}
{{ form_error('message') }}
</div>
<div class="mt-4">
{{ form_checkbox('consent', '1', {required: true}) }}
{{ form_label('consent', 'Ik ga akkoord met het privacybeleid.') }}
{{ form_error('consent') }}
</div>
<div class="mt-6">
{{ form_submit('Versturen', {class: 'btn btn--primary'}) }}
</div>
{{ form_end() }}
</section>
{% endblock %}
That's a working form. No PHP, no JS. Submissions are authenticated, validated, throttled, and mailed.
The helpers
Only form_start takes the form id. It opens the form; every other
helper binds to that open form automatically (the field set, CSRF token,
signed schema, and the flashed errors/status all hang off the form
form_start opened). So field helpers take the field name first —
not a repeated form id — and form_end / form_protection /
form_errors / form_status take no id at all.
Why no id on the others? The helpers share per-request state seeded by
form_start. Passing an id again would be redundant and would shift every following argument by one slot — e.g.form_errors('id')lands'id'in theattrs(array) parameter and throwsArgument #2 ($attrs) must be of type array, string given. One open form at a time; close it withform_end()before opening another.
| Helper | What it emits |
|---|---|
form_start('id', {method, enctype, class, …}) |
<form action method novalidate data-cms-form="id" …> |
form_end() |
Hidden _schema (signed field list, see Forms on cached pages) then </form> |
form_protection(extras = null) |
Hidden _csrf + invisible _hp honeypot |
form_label('name', 'Text', attrs = {}) |
<label for="…">Text</label> |
form_input('name', type = 'text', attrs = {}) |
<input type id name value …> — value redisplayed from old POST |
form_textarea('name', attrs = {}) |
<textarea …>old value</textarea> |
form_select('name', options, attrs = {}) |
<select>…</select> with selected on the old value |
form_checkbox('name', value = '1', attrs = {}) |
<input type=checkbox value …> — checked from old POST |
form_radio('name', value, attrs = {}) |
One radio. Loop in the template for option groups. |
form_file('name', attrs = {}) |
<input type=file accept … [multiple]> — see File uploads |
form_submit('Text', attrs = {}) |
<button type=submit> |
form_error('name', attrs = {}) |
<div role="alert" id="…-error">message</div> for one field |
form_errors(attrs = {}) |
<ul role="alert"> of _global errors (form-level) |
form_status() |
Returns 'success' / 'error' / null |
Style the error output with the attrs map (e.g.
form_error('email', {class: 'form-error'})); there is no built-in
.form-error / .form-errors class on the emitted markup — the helpers
only set role="alert".
All attrs maps support arbitrary HTML attributes (passed through with
escaping). Recognised validation rules in attrs:
required: true— schema-side required checkmin: 10,max: 2000— character length boundspattern: '\d{4}\s?[A-Z]{2}'— regex (PCRE), wrapped with^…$server-sideoptions: [{value, label}, …]— forselect/radio; the schema enforces that the submitted value is in this listmultiple: true— for<select multiple>
A label may be anything that is text, including the output of a macro,
which is what a multi-language option list looks like:
{{ form_select('subject', [
{value: 'general', label: i18n.t('subject_general', loc)},
{value: 'booking', label: i18n.t('subject_booking', loc)},
]) }}
Labels are escaped, so a macro that returns markup renders as text, not
as HTML. (Older cms-core dropped a macro-built label and rendered the
option's own value as its label, which reads as a list of lowercase
slugs in every language and is easy to miss. Themes that worked around
it with |trim on each label can drop that.)
The schema is HMAC-signed at render time. A POST whose field set doesn't
match what was rendered is rejected. Translation: a bot that scrapes
the form and posts an extra password field gets the whole submission
rejected, no whitelist hand-maintained.
Choosing the form id
The form id appears in three places:
- The argument to
form_start('contact')(the only helper that takes it — the rest bind to the open form automatically). - The submit URL —
POST /_cms/form/<websiteCode>/contact. - The admin's per-form config row in Forms tab.
It must match [a-z0-9_-]+ and be stable — renaming the id breaks
the saved per-form config (recipient, subject, sender override). Treat
form ids as identifiers, not labels.
For multiple forms on one site, give each a different id:
contact, quote, newsletter, booking, …
Server-side protections
Six layers, in this order. None are opt-in — all run on every submission. Layers 1–5 stop bots and forged posts; layer 6 (spam scoring) filters human-submitted junk that clears the bot gates.
1. Rate limit
Two buckets in AND. Both must pass; either trips → fail-closed with a "Too many submissions, please wait a few minutes" message.
| Bucket | Key | Limit |
|---|---|---|
cms_form_ip |
client IP | 10 / 5 minutes |
cms_form_per_form |
<websiteCode>:<formId> |
30 / 5 minutes |
A third bucket, cms_form_schema (client IP, 30 / 5 minutes), guards
the endpoint the form module fetches a per-visitor schema from. Over it
nothing is refused: the form keeps its shared schema and the visitor
goes through the confirm step (see
Forms on cached pages).
Configured in cms-public/config/packages/framework.yaml. Backed by
cache.rate_limiter — filesystem in dev (per-pod), Redis in prod
(cluster-wide).
2. Signed schema
form_end() emits a hidden _schema field carrying the form's field
list, the form id, the website's code and issued_at (unix timestamp),
all base64url-encoded and HMAC-SHA256 signed with the kernel secret.
The submit controller decodes + verifies; tampering breaks the HMAC,
and a missing or wrong form id or website rejects with "This form has
expired. Please reload."
There are two kinds. A per-visitor schema also carries a nonce:
one accepted submission uses it up, so a schema scraped from a page is
good for one message. A shared schema, the one a cacheable page
carries, has no nonce and is marked shared; a submission made with it
is never delivered. How a visitor gets from one to the other is in
Forms on cached pages.
This is also what enforces field-set integrity: if a bot posts an unexpected field, it's silently dropped (the validator only walks the signed schema, not the raw POST).
3. Time-gate (3 seconds)
The signed issued_at of the per-visitor schema lets the controller
measure how long ago this visitor got the form: on a cached page that is
when the form module fetched their schema (their first focus on the
form), on a page rendered for them it is the render. If less than
3 seconds elapsed, the submission is treated as a bot and silently
redirected to "success": the user isn't told (bots get no signal).
Logged at WARNING level for observability. The form module holds a
quicker submit until the 3 seconds have passed rather than let it be
dropped.
3 seconds is the floor. A real human typing even a 2-field form takes much longer. If the form has a single one-click action (e.g. a "yes, subscribe" newsletter form prefilled from a magic link), keep this in mind.
4. CSRF
One token id, cms_form, for every form, and it is stateless:
form_protection() emits the same constant _csrf value on every page,
and the controller checks the request's origin (Sec-Fetch-Site,
Origin or Referer), which a cross-site POST cannot fake. Rendering a
form therefore starts no session, which is what lets the page be
cached. Failure: "Security token mismatch. Please check your details
and send it again.", with the input handed back.
5. Honeypot
form_protection() emits an invisible _hp input
(position: absolute; left: -9999px; aria-hidden). Bots fill every
field they see — humans don't. A non-empty _hp is silently treated
as success (no signal to bots).
6. Spam scoring (content heuristic)
The first five layers stop bots and forged posts. A real person pasting
an SEO pitch or a Cyrillic spam wall clears all of them — so the last
layer scores the validated field values with a content heuristic
(FormSpamScorer). It runs after field validation, just before the
mail would be sent.
It adds up weighted signals:
| Signal | Weight |
|---|---|
| Each URL / bare domain in the text | 3 |
Link markup (<a href> or [url]) |
4 |
| Mostly non-Latin script (Cyrillic, CJK, Arabic, Hebrew, Greek — ≥50% of letters) | 4 |
| Each spam keyword (seo service, backlink, crypto, casino, payday loan, viagra, … — capped at 3) | 2 each |
| Duplicate of a submission to the same form in the last 60 minutes | 5 |
A score of 5 or more is treated as spam. (So one link plus one keyword trips it; a single link by itself doesn't.) The threshold, the weights, and the keyword list are fixed in cms-core — there is no per-website spam setting today.
On a spam verdict the submission is quarantined, not bounced: it's
recorded in the submission log with status quarantined (plus the
score and the human-readable reasons), no email is sent, and the
sender is shown the normal success page. That keeps a false positive
from tipping off a spammer, while letting the operator review held
submissions in the admin. A clean submission (score < 5) proceeds to
the mailer as before.
Submission log
Every protected submission that reaches scoring is written to
form_submission_log (per website) — the validated fields (minus the
_csrf / _hp / _schema protection fields and any password input),
recipient, subject, submitter IP, the spam score + reasons, and a
status:
| Status | Meaning |
|---|---|
sent |
Delivered to the mailer successfully. |
failed |
The mailer threw — delivery failed. |
quarantined |
Scored ≥ 5; held for operator review, not emailed. |
spam |
A quarantined item the operator rejected (kept for audit). |
The log is retained indefinitely (no auto-pruning) and is what backs the admin's submission/quarantine review.
What got dropped
Earlier versions had a 'turnstile' protection that injected the
Cloudflare widget. That was removed in favour of the rate-limit +
time-gate combo, which is collectively stronger for the contact-form
threat model and removes a third-party dependency. If you see
form_protection('contact', 'turnstile') in old templates, drop the
arguments entirely — form_protection() (no id, no extras) is the only
form. (form_protection() still accepts an optional extra-protections
argument, but 'turnstile' is no longer a valid value and there are no
others today.)
Forms on cached pages
A page with a form is served from the page cache (HttpCache,
s-maxage 5 minutes) like every other public page, so the HTML in it is
the same for every visitor and holds nothing that belongs to one:
form_end()emits the shared schema: field list, form id and website, signed, with no nonce and markedshared.- The CSRF token is a constant (stateless, checked by origin) and the honeypot is empty.
What is per visitor arrives through the form module
(cms-public/public/assets/form.js), which form_end() registers on
every page with a form:
- When the visitor first focuses, types in or changes anything in the
form, the module POSTs the shared schema to
/_cms/form/{websiteCode}/{formId}/schema. - The answer is a per-visitor schema: the same fields, a nonce of
its own (good for one accepted message) and an
issued_atof now, which the time-gate counts from. It replaces the_schemavalue. - A submit that comes before that, or within 3 seconds of it, is held
(the form gets
aria-busy="true") and goes out as soon as it can. Nothing is dropped for being quick.
The endpoint answers private, no-store, only for the website the
request's host belongs to and a form configured on it, only trades a
shared schema of that same form that is at most 4 hours old, and is
rate-limited per IP (cms_form_schema, 30 / 5 minutes). Loading the
page used to hand out a fresh schema on every request with no limit at
all, so it gives a bot nothing it did not have.
Without JavaScript
When the module did not run (script off or blocked, the request failed,
the rate limit was hit), the shared schema is posted as it is. The
submit controller never delivers a message on a shared schema. It
still applies the rate limit, CSRF, the honeypot and field validation
(errors come back as usual), and then sends the visitor back to the page
with their input filled in and a form-level error from form_errors():
Please check your details and press send again to confirm.
That page is rendered for them alone, is never cached, and carries a per-visitor schema, so the second press meets every check like any other submission: time-gate, single-use nonce, spam scoring. Files have to be attached again (a browser cannot refill a file input), and each file field says so.
Why not accept the shared schema once per visitor: its issued_at is
when the cached copy was rendered and it has no nonce, so neither the
time-gate nor the replay check could judge it. Posts without JavaScript
are mostly bots, and this way a bot has to keep a session cookie, follow
the redirect, read the new schema and wait out the time-gate before
anything is delivered.
What is never cached
The page is made private, no-store (FormPageCacheListener) when it:
- shows a submission's flash: old input, errors or a status, as
rendered by the field helpers,
form_errors(),form_error()orform_status(); - carries a per-visitor schema;
- is the URL the submit controller redirected to. That redirect adds
?_cms_form=<random>to the page it sends the visitor back to, so the request misses the page cache (one per pod) and the browser's cache, either of which would otherwise answer with a copy showing an empty form and no message.
Every other page with a form keeps its public cache headers. Its ETag includes the current hour, so a browser's copy, and the shared schema in it, is never more than about an hour old.
For theme authors
- Render forms with the helpers only. Never write the
_schemainput yourself or copy a schema value into a template or a script. - Keep
{{ parent() }}in any{% block javascripts %}override: the module is emitted there. Without it every visitor goes through the confirm step. - Render
form_errors()inside the form. It carries the confirm message for visitors without JavaScript. - Submitting from your own script: use
form.requestSubmit(), notform.submit(), so the module sees the submit and can swap the schema first. If you post withfetchinstead, wait until the_schemainput'sdata-cms-schemaattribute readsvisitor; a post with the shared schema is answered with the confirm redirect, never delivered.
For operators
- Nothing to configure. The limiter sits next to the other form
limiters in cms-public's
config/packages/framework.yaml. Form submission on a shared schema sent back to confirm.(INFO, cms-public) is the confirm step. A handful a day is visitors without JavaScript and bots. A steady stream on one site from real visitors means the module does not load there: a theme override ofjavascriptswithoutparent(), or a script error before it.Form schema requests rate-limited.(WARNING) is the endpoint's per-IP limit.
Per-form admin configuration
Each form id corresponds to one row in the website's Forms tab in the admin SPA. Operators set:
| Field | What it controls |
|---|---|
| Recipient | Where the submission email is sent. Comma-separate for multiple. |
| Subject | Subject line, may name fields as {field:<name>} (below). Defaults to <websiteName> form: <formId> if blank. |
| Success URL | Path to redirect to on success (e.g. /bedankt). Blank → back to source. |
| Sender email override | Overrides the website's default mail-from for this form only. Optional. |
| Sender name override | Display name for this form's From: header. Optional. |
Field values in the subject
The subject can carry what the visitor filled in: {field:<name>} is
replaced with the submitted value of the field with that name (the name
given to form_input(), form_select() and the rest).
Offerte aanvraag {field:service} → Offerte aanvraag Dakreiniging
Vraag van {field:naam} ({field:plaats}) → Vraag van Jan (Utrecht)
- The value is what was submitted, the same text the mail body shows. For a select whose options have a separate value and label, that is the value, so give options readable values if they will appear in a subject.
- Several values (checkboxes, a multiple select) are joined with a comma.
- Each value is put on one line and capped at 80 characters, the whole subject at 191.
- A field that does not exist or was left empty leaves nothing behind, and
a separator it would leave dangling at the start or end (
-,:,|,,,/) goes with it. Password and file fields are never filled in. - A submission held for review is logged with the filled-in subject and sent with it when it is approved.
The submit controller refuses to send when recipient is blank ("This form is misconfigured (no recipient)") or no mail-from resolves ("This form is misconfigured (no sender address)"). Both are user- visible errors flashed back to the form.
The Forms tab lists every form id detected in the active theme's
templates (via static scan of form_start('<id>') calls), so an editor
sees what's been wired up vs. what's still pending. Designers don't
register forms anywhere — calling form_start('quote') in a template
is the only registration step.
Mail-from resolution chain
For each submission, the controller walks:
- Per-form override (
from_email/from_nameon the form's row). - Website default (
mailFromEmail/mailFromNameon the General tab — see operators/index.md). - If neither resolves: refuse + log + tell the user the form is misconfigured.
There is no platform-wide fallback — sending under a generic relay address would mis-attribute mail for SPF / DKIM / reputation. The "refuse rather than relay" policy is intentional. See operators/security.md.
The email that arrives
An HTML part (a table of label and value) and a plain-text part, one field per line, label-cased name + value:
Naam: Jane Visitor
Email: jane@example.com
Subject: booking
Message:
Graag een afspraak inplannen voor de combi-brow behandeling.
Lukt volgende week donderdag of vrijdag?
Multi-line values are indented under their label. Empty fields are omitted. The fixed wording (the attachments block) is in the website's default language: the shop reads this mail, not the visitor. Headers are set as follows:
- From: per-form override → website default. Display name falls
back to the website's
nameif no name is set. - To: the form's
recipient. - Subject: the form's
subjectwith its{field:<name>}placeholders filled in, falling back to<websiteName> form: <formId>. - Reply-To: if the submission has a field named
email,reply_to,e-mail, orsenderand its value is a valid email address, that's set as the Reply-To header. Replies in the recipient's mail client go straight to the visitor.
Hidden fields (_csrf, _hp, _schema) are stripped before the body
is composed.
Overriding the email in a theme
The body is a pair of Twig templates the theme can replace, for one form or for every form. The first that exists wins:
templates/form/email/<form id>.html.twig one form, e.g. form/email/quote.html.twig
templates/form/email/submission.html.twig every form
@GsidCmsCore/form/email/submission.html.twig the shipped default
The .txt.twig part resolves the same way, on its own: override only the
HTML and the default plain-text part is still sent. Start from the shipped
pair in cms-core (core/templates/form/email/). The templates get:
| Variable | What it is |
|---|---|
website |
The website. |
locale |
The website's default language, for cms_t(key, {}, locale). |
form_id |
The form's id. |
subject |
The subject line the mail is sent with. |
fields |
The non-empty fields in submitted order: { name, label, value, lines, multiline }. value is a string (several values joined with , ), lines is value split on newlines. |
values |
The submitted values by field name, e.g. values.email. Multi-value fields are lists. |
attachments |
null, or { count, files: [{ name, size, size_label }], url, expires_at }. url is the one signed link to the submission page. |
Subject, recipient and Reply-To are not the template's to set; they come from the form's admin configuration and the rules above.
Error redisplay
On validation failure the controller flashes old values + per-field
errors and redirects back to the page that hosted the form (the
Referer, sanity-checked to same-host). Helpers redisplay automatically:
form_input/form_textarea/form_select/form_checkbox/form_radiocarry the old POST value through.form_error('name')renders the per-field error.form_errors()renders form-level errors (_globalslot — CSRF mismatch, expired schema, rate-limit hit, mail-from missing). Call it inside the open form (afterform_start); it reads the open form's flashed error bag.
{# Pattern for a row with field + inline error: #}
<div>
{{ form_label('email', 'E-mailadres') }}
{{ form_input('email', 'email', {required: true}) }}
{{ form_error('email') }}
</div>
The emitted error markup carries only role="alert" (no built-in class);
add your own via the attrs map, e.g.
form_error('email', {class: 'form-error'}).
Custom validation rules quick reference
All inside an open form_start('quote') … form_end() block:
{# Required + length bounds. #}
{{ form_input('company', 'text', {required: true, min: 2, max: 120}) }}
{# Email + required. #}
{{ form_input('email', 'email', {required: true}) }}
{# Dutch postcode regex. The schema wraps it with ^…$ — no need to anchor. #}
{{ form_input('zip', 'text', {required: true, pattern: '\\d{4}\\s?[A-Z]{2}'}) }}
{# Number — schema rejects non-numeric. min/max on numbers gate length, not value. #}
{{ form_input('guests', 'number', {required: true}) }}
{# Allowlisted select — server enforces the option set. #}
{{ form_select('service', [
{value: 'wedding', label: 'Bruiloft'},
{value: 'corporate', label: 'Bedrijfsfeest'},
{value: 'private', label: 'Privé-event'},
], {required: true}) }}
{# Multi-select. #}
{{ form_select('extras', [
{value: 'photography', label: 'Fotograaf'},
{value: 'flowers', label: 'Bloemen'},
{value: 'catering', label: 'Catering'},
], {multiple: true}) }}
{# Required checkbox (consent). #}
{{ form_checkbox('consent', '1', {required: true}) }}
Anything else (cross-field rules, async validation, file uploads) is out of scope for the v1 forms surface. Shape it as a separate page controller if you need it.
File uploads (attachments)
A form can accept files. Two template changes:
{{ form_start('quote', {files: true}) }}
{{ form_protection() }}
{{ form_label('photos', 'Foto’s van de schade') }}
{{ form_file('photos', {required: true}) }}
{{ form_error('photos', {class: 'form-error'}) }}
{{ form_submit('Versturen') }}
{{ form_end() }}
{files: true} on form_start is what emits
enctype="multipart/form-data". It cannot be inferred from a later
form_file() call — the <form> tag is already written by then — so
form_file() throws a LogicException when the form wasn't opened with
it. Without the enctype the browser posts only the filename and the
upload fails silently, which is why this is a hard error rather than a
warning.
The theme does not set the limits. form_file() reads them from the
per-form admin config (FormAttachmentPolicy) and writes them into the
signed schema, so a tenant can widen or narrow uploads without a theme
change and the submit controller validates against exactly what was
rendered. Defaults and ceilings:
| Limit | Default | Ceiling |
|---|---|---|
| Files per submission | 5 | 20 |
| Bytes per file | 10 MB | 25 MB |
| Accepted types | JPG, PNG, PDF | that set — an override can only narrow it |
| Retention | 90 days | 3650 days |
The helper renders accept="…", adds multiple when the limit is
above one, and writes the resolved limits as data-max-files and
data-max-bytes (bytes per file) for theme JS that checks a selection
before posting. A template can't override those two. All of it is browser
convenience only. Everything is re-checked server-side, including the real
MIME from the file's magic bytes, so a renamed .exe is rejected
regardless of what the input accepted.
Two details themes have to handle:
multiplechanges the field name. Withmax_files > 1the input posts asname[](PHP keeps only the last file otherwise). The helper does this for you; don't hardcode the brackets.- A file input cannot be repopulated after a failed submit — browsers
forbid it. Tell the visitor to re-attach;
form_error('photos')carries the message.
What happens to the file
- Stored on the NFS share by content hash
(
CMS_NFS_FORM_ATTACHMENTS_ROOT,/data/cms/form-attachments). Identical files from different submissions share one blob. - Recorded on the
form_submission_logrow (name, size, mime, hash). - The notification email lists the files by name with one signed, expiring link to the submission page, which previews them and hosts the per-file and ZIP downloads, plus the date the link stops working. Bytes are never attached to the mail.
- Link TTL is the shorter of 30 days and whatever is left of the
retention window, so a forwarded link can't outlive the file it points
at. An expired link answers
410 This download link has expired., not a 404 — support can tell "expired" from "broken". - When retention closes, the bytes are deleted and the row is stamped
pruned. The sweep is a nightly CronJob (
cms-cron-forms-prune-attachments, 04:15 UTC) that dispatchesforms:prune-attachments; cms-worker does the deleting, 500 rows per (website, form) pair per tick. The submission keeps the file's metadata and admin renders it greyed out, so a file never silently vanishes from the record.
One file failing validation fails the whole submission — a visitor who attached four photos and got three through would have no way to tell which one was dropped.
Multiple forms on one page
Each form must have a unique id, and each must be in its own
form_start / form_end block:
<section class="quote">
{{ form_start('quote') }}
{{ form_protection() }}
… fields …
{{ form_end() }}
</section>
<section class="newsletter">
{{ form_start('newsletter') }}
{{ form_protection() }}
… fields …
{{ form_end() }}
</section>
Each gets its own CSRF token + signed schema + admin config row + rate-
limit bucket (per-form). The helpers track one open form at a time —
always form_end() before the next form_start(...); don't nest or
interleave two forms' helpers.
Things that don't work
- No inline file attachments in the notification email. Uploads are supported (see File uploads), but the mail carries signed download links, never the bytes.
- No JavaScript validation hook. Helpers emit
novalidateon the form by design — server validation is the source of truth, the same-day visitor and the bot get the same answer. If you want client-side hints, add them with vanilla JS in your theme; don't rely on browserrequired/patternfor security. - No multi-step / multi-page wizard. One page = one POST. If you
need multi-step, that's a custom page controller, not a
form_*helper composition. - Cookies aren't required to send a form. CSRF is checked by origin and rendering a form starts no session. A session (and its cookie) starts only when a submission has something to hand back: errors, the confirm step or the success message. With cookies off, the message still goes out; the visitor just doesn't see it confirmed. The one exception is cookies and JavaScript off: the confirm step needs the session to hand the input back, so such a visitor cannot send a form.
Troubleshooting
| Symptom | Cause |
|---|---|
| "This form has expired. Please reload." | Page cached too long, schema's issued_at rolled past the kernel-secret rotation, or the form id in the URL doesn't match the rendered form. Reload. |
| "Security token mismatch." | Visitor's session expired between render and submit (1 h idle). Reload. |
| "Please check your details and press send again to confirm." | The form module did not swap in a per-visitor schema: JavaScript off or blocked, a theme dropped {{ parent() }} from javascripts, or the schema request failed or hit its limit. Pressing send again delivers it. See Without JavaScript. |
| "Too many submissions. Please wait a few minutes." | Rate limit hit. Either real spam or a developer hammering submit during testing. Wait 5 min or restart the cache pool in dev. |
| "This form is misconfigured (no recipient)." | The form id has no admin config — open the Forms tab and add a recipient. |
| "This form is misconfigured (no sender address)." | Neither the form-level override nor the website default is set. Open the General tab → Mail sender card. |
| Submit silently redirects to "success" but no mail arrives | Honeypot or time-gate tripped. Check the cms-public logs for the WARNING line — likely the form is being autofilled by a browser extension that fills _hp. |
form_file("x") needs the form opened with uploads enabled |
form_start is missing {files: true}. Add it — the enctype can't be back-patched. |
| "Only JPG, PNG and PDF files are accepted." | Server-side MIME sniff rejected the file. The extension is irrelevant; the magic bytes decide. |
| "This form cannot accept files." | A file was posted to a form whose rendered schema has no file field. Usually a stale cached page. Reload. |
| "This download link has expired." (410) | Link older than 30 days, or the retention window closed and the bytes are gone. Fetch the file from the submission in admin instead — if it's not pruned there. |
| Mail arrives from the wrong From: address | Check the form's per-form override; clear it to fall back to the website default. |
| Mail arrives but the recipient says "this looks like spam" | The relay's SPF/DKIM doesn't authorise the From: domain. That's an infra issue — operators set the From: address; ops sets the relay. |
Reference
- Submit controller:
cms-public/src/Controller/FormSubmitController.php - Per-visitor schema endpoint:
cms-public/src/Controller/FormSchemaController.php(POST /_cms/form/{websiteCode}/{formId}/schema) - The
formmodule:cms-public/public/assets/form.js - Cache rules for form pages:
cms-public/src/EventListener/FormPageCacheListener.php - Schema signer:
cms-core/src/Form/FormSchemaCollector.php - Twig helpers:
cms-core/src/Twig/FormExtension.php - Field validator: in the controller (
validate()private method). - Per-form admin endpoint:
PATCH /api/admin/websites/{id}/forms/{formId} - Static form-id detection:
cms-core/src/Website/FormDiscovery.php - Attachment limits/retention policy:
cms-core/src/Form/FormAttachmentPolicy.php - Attachment storage + MIME whitelist:
cms-core/src/Form/FormAttachmentStorage.php - Download-link signer:
cms-core/src/Form/FormAttachmentSigner.php - Download endpoint:
cms-public/src/Controller/FormAttachmentController.php(GET /_cms/form-attachment/{token}) - Storage root env var:
CMS_NFS_FORM_ATTACHMENTS_ROOT(cluster: theenv-configmapincms-k8s/configmaps.kdef)