Forms
This document explains how to build a form in the admin panel, place it on a page with the Form block, and read the resulting submissions in the panel. It also covers multi-step forms, spam protection, notification and auto-reply email, the submission webhook, file uploads, retention, styling, accessibility, configuration and replacing the captcha. It is written for the developer who sets up forms and for the editor who builds them.
A form is rendered in the page flow as a Livewire component. Forms have no public API; see What this does not do.
Building a form
Creating the form
- In the admin panel, open Forms in the navigation and click New form.
- Fill in the six tabs described in the table below, then save.
| Tab | Contents |
|---|---|
| Form | Name, slug, description, and the Active switch |
| Steps | The fields, grouped into one or more steps. Steps and fields are reordered by dragging |
| After submit | Button text, the success message, or a redirect URL in Redirect instead |
| Notifications | Notify these addresses, and the optional auto-reply |
| Spam | Honeypot field, Cloudflare Turnstile, and the retention period |
| Advanced | The webhook, and Custom CSS |
Field types
A form field has one of 13 types. The labels below are the ones the panel shows in the field type select:
- Single line text
- Phone
- Number
- URL
- Multi-line text
- Dropdown (single)
- Dropdown (multiple)
- Checkbox
- Checkboxes (multiple)
- Radio buttons
- Date
- File upload
Three of these types record which options a visitor selected, and they differ as follows:
- Dropdown (multiple) and Checkboxes (multiple) store the same value, a list of the chosen values, and differ only in the control the visitor uses. A dropdown is compact and hides its options until it is opened, which suits a long list of options. Checkboxes show every option at once and are easier to operate on a phone, which suits a short list.
- Checkbox is a single box that stores a boolean. It is for one yes-or-no question, such as agreeing to terms. If it is used for a set of options, only the last answer is kept.
Field labels and names
Each field has a label, which is the text the visitor reads, and a Field name, which is the key the answer is stored under and the input's name attribute. The field name is generated from the label and can be edited. Field names use lower case letters, numbers and underscores.
Set the field name before the form goes live. If it is changed later, new submissions store the answer under the new key and existing submissions keep the old key.
Six field names are reserved because the form itself uses them, and the panel refuses them: website, form_id, cms_form, cms_ts, _token and _method.
Extra validation rules accepts Laravel validation rule strings, which are applied in addition to the rules of the field type. Examples: max:255, min:10, regex:/^[A-Z]{2}\d{6}$/.
Steps
Every form has at least one step. A new form opens with one step already present. A form with one step behaves as a single-page form: it shows no Next button and no progress indicator, and the submit button is on the only screen.
Splitting a form across several screens
- In the admin panel, open Forms, open the form, and select the Steps tab.
- On the step that is already present, fill in Step title. The title is shown above that step's fields and in the progress indicator. A single-step form can leave it empty.
- Inside that step, add the fields that belong on the first screen with Add a field.
- At the bottom of the Steps tab, click Add a step.
- Enter a Step title for the new step and add its fields.
- Save the form.
Steps are reordered by dragging, and so are the fields inside each step. Deleting a step deletes the fields in it, so the delete action asks for confirmation.
The review screen
The Review step switch, on the Steps tab, adds a final screen with the heading "Check your answers", which lists every answer before the visitor submits. A choice field is shown by the label the editor configured rather than by the stored value, so the visitor reads "Next-day delivery" rather than express.
Back on the review screen returns to the last step with its answers intact.
The progress indicator
The Show progress switch lists the step titles above the form and marks the current step. A completed step is a link back to that step. A step not yet reached is not a link. The visitor moves forward only with Next, so the validation on each step cannot be skipped by clicking ahead.
Review step and Show progress apply only to a form with more than one step.
Partially completed answers
Answers are held in the session for the session's lifetime, which config/session.php sets to 120 minutes by default. A visitor who reloads the page returns to the step they were on, with the answers they had entered. Nothing is written to the database until the form is submitted, so an abandoned form leaves no row in the submissions list.
Two details follow from this:
- An answer typed but not yet carried forward with Next or Back is not held, because the form records a step's answers when the visitor leaves that step.
- A file chosen on an early step is staged immediately and attached to the submission when the form is submitted.
Validation across steps
Next validates only the fields on the current step. Submitting validates every field on every step again, with the rules rebuilt from the database instead of reusing the earlier checks, because an editor can add a required field while a visitor is partway through the form. If the final check fails, the visitor is returned to the step that holds the first failing field, so the error is shown next to a field the visitor can see.
Extra validation rules that refer to another field, such as same:email or required_with:phone, work across steps.
Placing a form on a page
- In the admin panel, open the page's edit screen and add the Form block.
- Choose the form in the block's form select. The select lists only active forms. An inactive form stops accepting submissions and is removed from the select; its existing submissions are kept.
- Optionally, enter a heading for the block.
The heading is the only setting on the block. The description, the button text and the success message come from the form itself, so one form placed on three pages behaves identically on all three.
Pages with a form are not cached
A page that contains a Form block is never stored in the page cache. A cached page would also cache the form's Livewire snapshot and its encrypted submission timestamp, both of which are generated per request. Served from the cache to a second visitor, they would cause submission failures that resemble a framework bug rather than a caching decision. The form block's isCacheable() returns false, so CachesCmsPages marks the response BYPASS and stores nothing.
A form embedded in a snippet has the same effect on every page that carries the snippet. A footer inquiry form therefore makes every page on the site uncacheable. Decide this before launch, because otherwise it is discovered when traffic arrives and no page is served from the cache.
Spam protection
Forms have four layers of spam protection. The first three require no action from the visitor.
- Honeypot (on by default, per form). A decoy field, placed off-screen rather than hidden with
display: none, because some bots skip fields that CSS hides outright. - Timing (default 2 seconds). A submission that arrives less than
cms.forms.min_secondsafter the form was rendered is treated as automated. - Rate limiting (default 5 per minute and 20 per hour). Counted per IP address, per form.
- Turnstile (off by default, per form). Cloudflare's captcha.
Honeypot and timing
The rendered timestamp is encrypted with the application key, so it cannot be back-dated to pass the minimum. It expires after 86,400 seconds (one day), so a scraped form cannot be reused indefinitely. A missing or unreadable timestamp counts as a failure.
A submission that fails the honeypot or the timing check receives the success screen. Telling a bot which check it failed would tell it what to change on the next attempt.
Rate limiting
Set cms.forms.throttle.per_minute or cms.forms.throttle.per_hour to 0 to switch that limit off. A visitor over a limit sees an error message instead of the success screen. The attempt is counted before the final validation of all fields, so a script that calls the submit action directly with invalid input is also throttled.
Turnstile
- In the site's
.envfile, set both Turnstile keys:
CMS_TURNSTILE_SITE_KEY=0x4AAA…
CMS_TURNSTILE_SECRET_KEY=0x4AAA…
- In the admin panel, open the form, select the Spam tab and turn on Cloudflare Turnstile.
Reading submissions
- In the admin panel, open Forms and open the form.
- Scroll to the Submissions table below the form. Unread rows are shown in bold.
- Open a row to read it. Opening a row marks it read.
Each submission has a Status (New, In progress or Closed) and an Internal notes field that the submitter never sees.
Export CSV exports the rows that the current filters show. A cell whose text begins with =, +, -, @, a tab or a carriage return is prefixed with an apostrophe, so a spreadsheet does not evaluate it as a formula. Text such as =cmd|'/c calc'!A1 typed into a name field is a real attack against the person who opens the exported file.
When a field is deleted from the form, the answers already collected under it remain visible on existing submissions, with the note "This form no longer has a field with this name."
Notifications
Notification email
Notify these addresses, on the Notifications tab, takes one email address per entry. Each accepted submission sends one queued email to those addresses. The visitor's address is set as Reply-To, never as From. Sending as the visitor's address would fail SPF checks at most receiving mail servers and cause the site's mail to be filed as spam.
Auto-reply
The Send an auto-reply switch sends a queued acknowledgment to the person who filled in the form. The auto-reply needs an address to send to, and it takes the first of these fields that the submission contains: email, email_address or your_email. If the form has none of these fields, or the value is not a valid email address, no auto-reply is sent. The package does not infer a destination from a name or a phone number, because a guessed destination sends the acknowledgment to a stranger.
Plain text or HTML
The auto-reply body has a Format select with two options:
- Plain text is edited in a text area, where blank lines separate paragraphs. The message is wrapped in Laravel's standard mail layout. Markup typed into the body is escaped and appears as literal angle brackets.
- HTML is edited in a syntax-highlighted code editor and sent exactly as written, inside a minimal wrapper that sets a white card and a readable font and nothing else. HTML mode is for a message that supplies its own design.
Both formats are stored in the same column, so switching between them keeps the text already written.
Use inline styles in HTML mode. Most mail clients discard a <style> block or a linked stylesheet, and several rewrite the markup around it.
HTML bodies do not pass through Laravel's markdown mailer. Laravel renders markdown mail through CommonMark with html_input set to escape, so an editor's markup would otherwise arrive as visible angle brackets.
The HTML template and the substituted answers are treated differently. The template is written by the editor and rendered as written. Every {{ placeholder }} value substituted into it is escaped first, so a visitor who types <img src=x onerror=…> into the name field receives it back as text. Anything the editor types into the template is sent verbatim, so treat the field like any other raw-HTML field.
Placeholders
The auto-reply subject and body both accept {{ token }} placeholders, in either format. Every field name on the form is a token, and three tokens are built in:
| Token | Value |
|---|---|
| {{ your_field_name }} | That field's answer. Multi-value answers are joined with commas; checkboxes read Yes or No |
| {{ form_name }} | The form's name |
| {{ submission_id }} | The submission's id |
| {{ submitted_at }} | The time the submission arrived |
Subject: Thanks {{ name }}
Hi {{ name }},
We have your message and will reply to {{ email }} within a day.
The team
The Available placeholders list under the auto-reply fields shows the exact tokens for the form being edited.
Substitution follows these rules:
- Whitespace inside the braces is ignored.
- An unknown token is replaced with nothing.
{{ nonsense }}leaves a gap in the message rather than sending literal braces to a customer. A field the visitor left blank is also replaced with nothing. - Substitution is a single pass. A visitor who types
{{ email }}into the name field receives that text back verbatim. A visitor's input is never expanded, because expansion would allow a visitor to read a field into the message that the form never showed them. - Newlines are removed from the subject line, because a mail header that contains a line break is rejected.
- Answers are escaped in both formats, so a visitor cannot insert markup into an auto-reply.
Delivery failures
The submission row is stored before any email is sent. The notification email, the auto-reply, the FormSubmitted event and the webhook dispatch each run inside their own try/catch in FormSubmissionObserver, which writes an exception to the log (for example "Form submission notify failed.") and continues. A mail server that refuses the connection therefore never prevents the submission from being saved. Because both emails are queued, a failure on a queue connection other than sync occurs later in the queue worker and is recorded as a failed job.
Webhook
When an endpoint is set under Advanced, every accepted submission is sent to it as a JSON POST by the queued job SendFormSubmissionWebhook. The job makes up to 3 attempts, with a 15-second request timeout. It waits 10 seconds before the second attempt and 30 seconds before the third. A response outside the 2xx range is logged as a warning and counts as a failed attempt.
{
"form": "contact",
"form_name": "Contact us",
"submission_id": 41,
"submitted_at": "2026-08-03T09:12:44+00:00",
"data": { "name": "Ada Lovelace", "email": "ada@example.com" },
"files": ["3fJk2LmN8pQrS1tUvW4xYz6aBcDeFgHiJkLmNoPq.pdf"],
"ip_address": "203.0.113.9",
"user_agent": "Mozilla/5.0 …"
}
The request carries these headers:
X-Cms-Form: the form's slug.X-Cms-Submission: the submission's id.Authorization: Bearer …: sent only when a Bearer token is set. The token is stored encrypted.
files carries the stored file names only, not the file contents. The files are the visitor's personal data, and the webhook endpoint is a third party the visitor never agreed to share them with. A stored file name is the random name described in Uploads, not the name of the file the visitor chose.
Uploads
File answers are stored on the disk named by cms.forms.upload_disk (default local), under cms/form-uploads/YYYY/MM. Each file is given a random 40-character name with the original extension, generated by Laravel's hashName().
cms.forms.max_upload_kb (default 10240) limits the size of each file. PHP's upload_max_filesize and post_max_size also limit it, and the smallest of the three applies.
Deleting a submission deletes its files. Deleting a form deletes its files as well.
Retention
Submissions hold names, email addresses, phone numbers and any other data the form asks for. By default they are kept forever. Set a retention period to delete them on a schedule.
Setting a retention period
- In
config/cms.php, setretention_daysin theformsgroup:
'retention_days' => 365,
- Optionally, in the admin panel, open a form, select the Spam tab, and set Delete submissions after (days) under Retention to override the site-wide value for that form. Leave it empty to use the site-wide value.
When cms.forms.retention_days is set, the service provider schedules cms:prune-submissions to run daily. When it is null, the command is not scheduled at all, so a per-form value takes effect only when the command is run by hand.
Running the command by hand
In a terminal at the site's root:
php artisan cms:prune-submissions --dry-run # report what would be deleted
php artisan cms:prune-submissions --days=30 # ignore every retention setting and use 30 days
In an interactive terminal the command asks for confirmation before deleting. Pass --force to skip the confirmation.
Rows are deleted one at a time rather than with a single bulk query, because the model's deleting hook removes the uploaded files. A bulk delete would skip the hook and leave a visitor's CV on disk after the record that names it was deleted.
Styling
The form markup uses BEM-style classes under .cms-form, styled in the package's cms.css. Override the classes in the site's own stylesheet, or publish the views and edit the markup:
php artisan vendor:publish --tag=cms-block-views
The form renders through a Livewire component rather than through resources/views/blocks, so the command publishes the form's markup to resources/views/vendor/cms/livewire/multi-step-form.blade.php. The markup of an individual field is in the cms::form-field component. To change it, copy it to resources/views/vendor/cms/components/form-field.blade.php.
Custom CSS, on the Advanced tab, is rendered inline with the form and scoped to that form. The characters < and > are removed from it, so a value such as </style><script> cannot close the style tag. Use it for adjustments to one form, and put reusable rules in the site's stylesheet.
Forms have no custom-scripts field. Such a field would put arbitrary JavaScript on a public page, editable by every user who can edit a form.
Accessibility
The rendered form markup includes the following without configuration:
- Every input has a
<label>bound to it byforandid. - Radio button and checkbox groups are wrapped in a
<fieldset>with a<legend>. A single label pointing at one of several inputs would name the wrong element and leave the group's question unannounced. - The individual boxes of a required checkbox group carry no
requiredattribute. A browser readsrequiredon a checkbox as "this particular box must be ticked", so marking every box would demand every option rather than at least one. The requirement is enforced on the server instead. - Help text and error messages are linked to their field with
aria-describedby. - A field that fails validation carries
aria-invalid="true". - Error messages have
role="alert". The success message hasrole="status". - The honeypot field has
aria-hiddenandtabindex="-1", so assistive technology skips it as well as sighted visitors. - The form requires JavaScript. It renders as a Livewire component, so a visitor with scripting disabled cannot submit it. This applies to a one-field contact form as much as to a multi-step form.
Configuration
The forms group in config/cms.php:
'forms' => [
'enabled' => true,
'upload_disk' => 'local',
'upload_directory' => 'cms/form-uploads',
'max_upload_kb' => 10240,
'turnstile' => [
'site_key' => env('CMS_TURNSTILE_SITE_KEY'),
'secret_key' => env('CMS_TURNSTILE_SECRET_KEY'),
'skip_locally' => true,
],
'throttle' => ['per_minute' => 5, 'per_hour' => 20],
'min_seconds' => 2,
'retention_days' => null,
'mail_from' => null,
],
mail_from sets the From address of notification and auto-reply email. When it is null, the application's configured from address is used.
Setting 'enabled' => false switches the whole feature off and removes the Forms resource and the Form block. To keep the tables and hide only the panel section, disable forms on the plugin in the panel provider:
FilamentCmsPlugin::make()->forms(false)
Replacing the captcha
Mcg\FilamentCms\Contracts\CaptchaVerifier is bound to TurnstileVerifier. To use hCaptcha or another service, bind a different implementation in a service provider:
$this->app->singleton(CaptchaVerifier::class, HCaptchaVerifier::class);
An implementation must follow two rules:
verify()must returnfalseon a network error, a timeout or a missing secret.isConfigured()must returnfalsewhen the keys are absent. The form uses this to refuse to render, which prevents a form from losing its protection without anyone noticing.
What this does not do
- A public API. There is no schema endpoint and no JSON submission endpoint. One would be added if a decoupled front end needed it.
- Conditional fields and calculated fields. A form can be split into steps, but a step cannot be shown or skipped based on an earlier answer.
- Payment. Forms cannot take payments.
- Per-field encryption at rest. Answers are stored in a plain JSON column.
- MCP tools. The MCP server cannot read submissions, because submissions contain personal data.