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 dedicated cms_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 the attrs (array) parameter and throws Argument #2 ($attrs) must be of type array, string given. One open form at a time; close it with form_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 check
  • min: 10, max: 2000 — character length bounds
  • pattern: '\d{4}\s?[A-Z]{2}' — regex (PCRE), wrapped with ^…$ server-side
  • options: [{value, label}, …] — for select / radio; the schema enforces that the submitted value is in this list
  • multiple: 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:

  1. The argument to form_start('contact') (the only helper that takes it — the rest bind to the open form automatically).
  2. The submit URL — POST /_cms/form/<websiteCode>/contact.
  3. 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 marked shared.
  • 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:

  1. 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.
  2. The answer is a per-visitor schema: the same fields, a nonce of its own (good for one accepted message) and an issued_at of now, which the time-gate counts from. It replaces the _schema value.
  3. 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() or form_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 _schema input 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(), not form.submit(), so the module sees the submit and can swap the schema first. If you post with fetch instead, wait until the _schema input's data-cms-schema attribute reads visitor; 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 of javascripts without parent(), 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:

  1. Per-form override (from_email/from_name on the form's row).
  2. Website default (mailFromEmail/mailFromName on the General tab — see operators/index.md).
  3. 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 name if no name is set.
  • To: the form's recipient.
  • Subject: the form's subject with 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, or sender and 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_radio carry the old POST value through.
  • form_error('name') renders the per-field error.
  • form_errors() renders form-level errors (_global slot — CSRF mismatch, expired schema, rate-limit hit, mail-from missing). Call it inside the open form (after form_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:

  • multiple changes the field name. With max_files > 1 the input posts as name[] (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

  1. 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.
  2. Recorded on the form_submission_log row (name, size, mime, hash).
  3. 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.
  4. 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".
  5. 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 dispatches forms: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 novalidate on 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 browser required / pattern for 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 form module: 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: the env-configmap in cms-k8s/configmaps.kdef)