Skip to content
MCG Filament CMS Demo

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 the field types and their settings, button labels, conditional visibility, 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

  1. In the admin panel, open Forms in the navigation and click New form.
  2. 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, and the three button labels: Button text, Next button label and Back button label. Each field is one row; its settings open in a slide-over from the row's Edit field button (see Editing a field). Steps and fields are reordered by dragging
After submit 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

Editing a field

On the Steps tab, each field inside a step is one row. The row shows the field's label, its field name in parentheses, its type and, for a required field, Required, for example Monthly income (monthly_income) · Number · Required. A field without a label shows Untitled field. A Content (text only) field has no field name until its row is saved. On that save, FormField::booted() sets the name to content_ followed by eight random lowercase letters and digits, for example content_k3x9q2ab. The save can be the form's first save or any later one, so a Content field added to an existing form shows no name in its row until the next Save changes.

Each row has a drag handle and three icon buttons: Edit field (a pencil icon), Clone and Delete. Hovering over a button shows its name; Edit field's name includes the field's own label, for example "Edit field: Monthly income" (cms::cms.panel.forms.edit_field). Delete removes the row without asking for confirmation, and the field is deleted from the database when the form is saved.

To change a field:

  1. On the form's edit screen (/admin/forms/{id}/edit), select the Steps tab.
  2. On the field's row, click Edit field. A slide-over opens from the right, headed by the field's label (or Untitled field), with the field's settings: Label, Field name, Type, the settings of that type, and the Visibility fieldset.
  3. In the slide-over, change the settings and click Apply. The slide-over closes and the row shows the new label, name, type and Required.
  4. At the bottom of the edit screen, click Save changes.

To add a field:

  1. On the Steps tab, inside the step that should hold the field, click Add a field. The slide-over opens, headed Add a field, with empty settings and Type set to Single line text.
  2. In the slide-over, enter a Label and press Tab. When Field name is empty, it fills with the label converted by str($label)->slug('_'): letters are lower-cased, spaces and hyphens become underscores, @ becomes _at_, and other punctuation is removed. For example, Monthly Income (USD) becomes monthly_income_usd, and Name/Email becomes nameemail.
  3. In the slide-over, fill in the other settings and click Apply. The field is added as the last row of the step.
  4. At the bottom of the edit screen, click Save changes, or Create on a new form.

Apply checks the slide-over's settings:

  1. Label must be filled in.
  2. Field name must follow the rules in Field labels and names: it starts with a lowercase letter and contains only lowercase letters, numbers and underscores, is at most 64 characters, is not one of the six reserved names, does not contain two underscores in a row, and is not already used by another field of the form. A duplicate shows the message "The field name 'email' is already used by the field "Email"." under Field name.
  3. A choice field (Dropdown (single), Dropdown (multiple), Checkboxes (multiple) or Radio buttons) must have at least one choice, each with a Value and a Label.
  4. Opt-in checkbox must have Agreement text filled in.
  5. Minimum and Maximum must match the format the type requires (see Minimum and Maximum).
  6. Each rule in the Visibility fieldset must name a field that comes earlier in the form.

A failing setting shows its error under the input, and the slide-over stays open.

Apply changes only the edit screen. Nothing is written to the database until Save changes is clicked, and reloading the page before saving discards every applied change. Cancel, the slide-over's close button and the Escape key close the slide-over without changing the field.

Clone inserts a copy of the field directly below it. The copy's field name is the original's followed by _copy, or by _copy_2, _copy_3 and so on when that name is used by another field of the form, capped at 64 characters in total: the original is shortened as needed to leave room for the suffix, and any underscores at the end of the original, after shortening, are removed so that the copy has no double underscore. The copy's label is the original's followed by " (copy)", or Untitled field (copy) when the original has no label. The step's own Clone button copies the whole step below it, keeps the step's own Step title, and renames every field in the copy the same way. A visibility rule in the copied step that names another field of the same step is changed to name that field's copy, so the copied step's fields depend on the copied answers. A rule that names a field in an earlier step is copied unchanged.

Field types

A form field has one of 20 types, chosen in the field's Type select. The first column below is the label the panel shows in that select, and the second is the value stored in the type column of cms_form_fields (Mcg\FilamentCms\Enums\FormFieldType). The validation column lists the rule the type itself contributes; Required, Minimum and Maximum, and Extra validation rules add to it.

Type Stored as Renders Validation A submission stores
Single line text text <input type="text"> string A string
Email email <input type="email"> email A string
Phone tel <input type="tel"> string A string
Number number <input type="number"> numeric The number as typed, as a string
URL url <input type="url"> url A string
Multi-line text textarea A five-row <textarea> string A string
Dropdown (single) select A <select> with a "Choose…" first option string, in: the choice values The chosen value
Dropdown (multiple) multiselect A <select multiple> array; each element in: the choice values A list of the chosen values
Checkbox checkbox One <input type="checkbox"> boolean true or false
Checkboxes (multiple) checkboxgroup One checkbox per choice, in a <fieldset> array; each element in: the choice values A list of the chosen values
Radio buttons radio One radio button per choice, in a <fieldset> string, in: the choice values The chosen value
Date date <input type="date"> date A string such as "2026-10-19"
File upload file <input type="file"> file, max: cms.forms.maxuploadkb The stored file, listed under the submission's files
Opt-in checkbox optin One checkbox whose label is the Agreement text boolean; accepted when Required is ticked true or false, plus the agreement text as plain text under {name}text
Content (text only) content A heading and a rich-text body, with no input None Nothing
Hidden value hidden Nothing; the value is not rendered as an input string, max:255 The value read from its Value source
Time time <input type="time"> dateformat:H:i A string such as "14:30"
Date and time datetime <input type="datetime-local"> date A string such as "2026-10-19T14:30"
Rating or scale rating One radio button per whole number from From to To, shown as stars or numbered boxes integer, between: From and To An integer
Yes / No yesno Two radio buttons, in a <fieldset> boolean true or false

Three of these types record which options a visitor selected, and they differ as follows:

  1. 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.
  2. Checkbox is a single box that stores a boolean. It is for one yes-or-no question. If it is used for a set of options, only the last answer is kept. For consent to a policy, use Opt-in checkbox instead, because it also stores the text the visitor agreed to. For a question that needs an explicit "No" rather than an unticked box, use Yes / No.

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.

A Content (text only) field collects no answer, so the builder hides its Field name. When the field is saved, FormField generates a name of the form content_ followed by eight random lower case letters and digits, because cms_form_fields has a unique index on the form and the name.

A field can be renamed after the form has received submissions. New submissions store the answer under the new name, and existing submissions keep it under the old name. Existing submissions still show the answer, under the label the field had when each one was submitted, because every submission stores a snapshot of its fields (see Field snapshots). The CSV export lists the old name and the new name as two separate columns, each holding the answers stored under it.

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.

A field name cannot contain two consecutive underscores (__). An opt-in field stores its agreement text under its own name followed by __text, so a name such as consent__text could collide with that key. The panel refuses such a name with "A field name cannot contain two underscores in a row (_). An opt-in field stores its agreement text under its name followed by _text."

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}$/. The panel accepts any rule string here. The MCP form tools accept only the 26 rules on an allowlist and check that each one compiles (see "Extra validation rules over MCP" in The MCP server).

Reusing a field name in the same save

Save changes writes the fields one row at a time, step by step and, within each step, field by field in the order of the Steps tab. Each row's new name must be free in cms_form_fields at the moment that row is written, because the table has a unique index on (form_id, name). Two kinds of change made in one save pass the Apply check and the save check, which compare the names on the edit screen, and then fail at that index:

  1. Two fields exchange names, for example email becomes phone and phone becomes email. The first row written takes a name that the second row still holds in the database.
  2. A field in a later step is deleted, and a field in an earlier step is given the deleted field's name. The earlier step's fields are written before the later step's deleted field is removed from the database.

The save then fails with a database error (Illuminate\Database\UniqueConstraintViolationException). EditForm runs the save in a database transaction, so the transaction rolls back and nothing in the form, its steps or its fields is written, and cms_forms.revision is unchanged.

To exchange two field names, save twice. The temporary name must go on the field that Save changes writes first: the field in the earlier step, or, when both fields are in the same step, the field higher in that step's list. This guide calls it the upper field and the other one the lower field.

  1. On the form's edit screen, on the Steps tab, click Edit field on the upper field, change its Field name to a temporary name that no other field uses, for example email_old, and click Apply.
  2. On the Steps tab, click Edit field on the lower field, change its Field name to the upper field's former name, and click Apply.
  3. At the bottom of the edit screen, click Save changes.
  4. On the Steps tab, click Edit field on the upper field, change its Field name from the temporary name to the lower field's former name, and click Apply.
  5. At the bottom of the edit screen, click Save changes again.

If the temporary name is given to the lower field instead, the first save writes the upper field's new name while the lower field's row still holds that name, and the save fails with the same database error.

To reuse the name of a field that is being deleted from a later step, delete the field and click Save changes first, then give its name to the other field and click Save changes again.

Type-specific settings

The seven field types below show their own settings in the field's Edit field slide-over once the type is chosen in Type. The settings are stored in the field's settings JSON column under the key given in parentheses.

Opt-in checkbox

Opt-in checkbox is a single checkbox for consent, such as agreement to a privacy policy or to marketing email.

Setting Behavior
Agreement text (settings.agreementtext) Required. A rich-text editor with bold, italic and link buttons. The text is rendered as the checkbox's <label>, so a link in it, such as a link to the privacy policy, is clickable on the public form
Required When ticked, the field is validated with accepted, so the visitor must tick the box to submit. When not ticked, the field is optional and validated as boolean
Checked by default (settings.default) Ticks the box when the form first renders. The panel shows the warning "Pre-ticked consent is not valid consent under GDPR. Leave this off for marketing consent." under this toggle

The field's Label is not displayed on the public form, because the agreement text takes its place. The label is still required; it names the answer on the review screen, in the notification email, in the CSV export and in the validation error message.

A submission stores true or false under the field name, and the agreement text under {name}__text. The stored agreement text is plain text, with each link's address written in parentheses after the link's words, for example I agree to the privacy policy (https://example.com/privacy). The record therefore shows which policy was linked at the time of submission, even if the agreement text is edited later. In the panel's submission view, the stored agreement text is shown under the opt-in answer. Export CSV writes it in its own column, directly after the opt-in answer's column.

Content (text only)

Content (text only) places a heading and a paragraph between fields, for example an explanation at the start of a step. It collects no answer.

Setting Behavior
Heading (settings.heading) Optional, up to 255 characters. Rendered as an <h4> with the class cms-formcontent-heading
Body (settings.body) A rich-text editor with bold, italic, link, bulleted list and numbered list buttons. Rendered as sanitized HTML inside .cms-formcontent

The builder hides Field name, Placeholder, Help text, Extra validation rules, Required and Width for this type. A content field always spans the full width of the form. It is excluded from validation, from storage, from the review screen, from both emails, from the CSV export, from the webhook and from the auto-reply placeholders.

Hidden value

Hidden value records a value the visitor never sees, such as the campaign a visitor arrived from or the page the form was submitted on. The value is not rendered as an input, so it does not appear in the form's HTML fields. It is, however, part of the Livewire component snapshot that the page carries between requests, and a crafted request can change a value held there.

Setting Behavior
Value source (settings.source) Fixed value, URL parameter or Page URL. Defaults to Fixed value
Value (settings.value) Shown for Fixed value. The text stored with every submission
URL parameter name, for example utmsource (settings.value) Shown for URL parameter in place of Value. The name of the query-string parameter to read, without the ? or =

Page URL stores the full URL of the page, including its query string, and shows no further setting.

The value is read once, when the form first renders. Later Livewire requests are POST requests to Livewire's update endpoint and carry neither the page's query string nor its URL, so the value cannot be read again after the first render. A value already restored from the visitor's session is kept. The value is cut to 255 characters before it is stored.

When the submission is stored, a Fixed value is read again from the field's settings, so the stored value is always the one configured in the builder, whatever the request contained. A URL parameter or Page URL value cannot be read again, because the request it came from has ended, so it is stored as the request submitted it. Treat those two sources as information the visitor supplied, not as verified data.

A Hidden value field is not listed on the review screen.

Leave Required unticked on a Hidden value field whose source is URL parameter. A visitor who arrives without the parameter would fail validation on that field, and because the field has no visible input, no error message would be displayed next to anything the visitor can change.

Hidden value replaces the Hidden (legacy) checkbox, which appears on every field. Hidden (legacy) renders the field with the HTML hidden attribute, so the visitor can neither see nor fill it in, and the panel's helper text under it reads "Renders the field with the HTML hidden attribute, so a visitor cannot fill it in. Use the Hidden value field type instead." Existing fields keep the checkbox's stored value (is_hidden). Use Hidden value for new forms.

Time

Time renders <input type="time">, which browsers display as a time picker. It is validated with date_format:H:i, and a submission stores a 24-hour string such as "14:30". It has no settings of its own; Minimum and Maximum set the earliest and latest accepted time (see Settings on every field).

Date and time

Date and time renders <input type="datetime-local">, which browsers display as a combined date and time picker in the visitor's local time. It is validated with date, and a submission stores a string such as "2026-10-19T14:30". No time zone is recorded. It has no settings of its own; Minimum and Maximum set the earliest and latest accepted moment.

Rating or scale

Rating or scale renders a row of whole numbers from From to To as radio buttons inside a <fieldset>, with the field's label as the <legend>.

Setting Behavior
Style (settings.style) Stars shows a star character beside each number. Numbers shows numbered boxes. Defaults to Stars
From (settings.from) The lowest choice, a whole number. Defaults to 1
To (settings.to) The highest choice, a whole number. Defaults to 5. If From is greater than To, the two are swapped rather than rejected, so a typing mistake cannot produce a scale with no choices
Low label (settings.lowlabel) Optional, up to 64 characters. Shown under the lowest end of the row
High label (settings.highlabel) Optional, up to 64 characters. Shown under the highest end of the row

The field is validated with integer and between: From and To, and a submission stores an integer. A scale of 0 to 10 with Numbers produces 11 choices, which is the layout of a Net Promoter Score question (see Building an NPS survey).

Yes / No

Yes / No renders two radio buttons inside a <fieldset>. The first posts 1 and the second posts 0, and a submission stores true or false.

Setting Behavior
Yes label (settings.yeslabel) Optional, up to 64 characters. The text of the first button. Left blank, it reads "Yes"
No label (settings.nolabel) Optional, up to 64 characters. The text of the second button. Left blank, it reads "No"

When Required is ticked, the field is validated with required, which either answer satisfies, so "No" is a valid answer to a required Yes / No question. A required Checkbox cannot record "No" in the same way, because an unticked box fails required.

Settings on every field

Three settings apply across field types. Each is shown only for the types it applies to.

Width

Width (settings.width) has two options: Full and Half (two fields side by side on wider screens). It defaults to Full.

On a screen 640px wide or wider, the public form lays out its fields in a two-column grid (.cms-form__fields with grid-template-columns: 1fr 1fr). A Full field spans both columns. A Half field, which carries the class cms-form__field--half, takes one column and shares its row with the next Half field. Below 640px every field spans the full width. The setting is hidden for Content (text only) and Hidden value, which always render at full width.

Default value

Default value (settings.default) is the answer a field starts with when the form first renders. Its input in the builder depends on the field type:

Field types Input in the builder
Single line text, Email, Phone, URL, Multi-line text, Number, Date, Time, Date and time, Rating or scale A text input labeled Default value. For Rating or scale, enter a whole number between From and To; a value outside that range matches none of the choices and fails validation on submission
Dropdown (single), Radio buttons A select labeled Default value, listing the field's choices
Dropdown (multiple), Checkboxes (multiple) A multiple select labeled Default value, listing the field's choices
Checkbox, Opt-in checkbox A toggle labeled Checked by default
Yes / No A select labeled Default value, with the options "Yes" and "No"
Content (text only), Hidden value, File upload No default value setting

A visitor who started the form earlier and returns within the session lifetime sees the answers they entered, not the defaults. The restored session answer takes priority over Default value.

Minimum and Maximum

Minimum and Maximum (settings.min, settings.max) limit an answer. What they limit, and their labels in the builder, depend on the field type:

Field types Builder labels What is limited Browser attribute Server rule
Single line text, Email, Phone, URL, Multi-line text Minimum characters, Maximum characters Number of characters minlength, maxlength min:, max:
Number Minimum value, Maximum value The numeric value min, max min:, max:
Date, Time, Date and time Earliest (YYYY-MM-DD, HH:MM, or YYYY-MM-DDTHH:MM), Latest (YYYY-MM-DD, HH:MM, or YYYY-MM-DDTHH:MM) The earliest and latest accepted value. Enter it in the format the field posts: 2026-10-19 for Date, 09:00 for Time, 2026-10-19T09:00 for Date and time min, max afterorequal:, beforeorequal:
Dropdown (multiple), Checkboxes (multiple) Minimum choices, Maximum choices Number of choices selected None min:, max: on the list
File upload Maximum file size (KB) only File size in kilobytes None max:, capped at cms.forms.maxuploadkb

The other types (Dropdown (single), Checkbox, Radio buttons, Opt-in checkbox, Content (text only), Hidden value, Rating or scale and Yes / No) show neither setting. A Rating's range is set by From and To instead.

The builder checks the value entered in Minimum and Maximum against the limit: a whole number for characters, choices and file size, a number for a numeric value, and YYYY-MM-DD for Date, HH:MM for Time or YYYY-MM-DDTHH:MM for Date and time. Apply in the field's slide-over is refused when the value does not match, and the error is shown under the input.

The browser attribute stops most visitors before they submit. The server rule is applied on every submission regardless, because a request does not have to come from the rendered markup. A File upload maximum above cms.forms.max_upload_kb has no effect beyond that configured limit.

Button labels

The Steps tab holds three button labels, in a row below the steps:

Field Behavior
Button text (submitlabel) Required, up to 64 characters, defaults to "Submit". Shown on the button that submits the form: on the last step, or on the review screen when Review step is on
Next button label (nextlabel) Optional, up to 64 characters. Shown on the button that moves to the next step. Left blank, the button reads "Next"
Back button label (backlabel) Optional, up to 64 characters. Shown on the button that returns to the previous step and on the review screen's back button. Left blank, the button reads "Back"

The blank-label defaults "Next" and "Back" come from the package's translation file (cms::cms.blocks.form.next and cms::cms.blocks.form.back), so a site that publishes and translates the language file changes them there. Next button label and Back button label have no effect on a form with one step, which shows neither button.

In versions before 0.81.0, Button text was on the After submit tab. It is now on the Steps tab. The stored value in submit_label is unchanged, so existing forms keep their label.

The Form block has its own Submit button label field, with the helper text "Replaces the form's own submit label on this page only. Leave blank to use the form's label." A value entered there replaces Button text for that one placement of the form. Every other page that embeds the same form keeps showing the form's own Button text. The block has no override for the Next and Back labels.

Conditional visibility

A field can be shown or hidden based on the answers to earlier fields in the same form. The rules are set on the field that is shown or hidden, in the Visibility fieldset at the bottom of that field's Edit field slide-over, and are stored in the field's conditions JSON column.

Setting Behavior
Show only when… (conditions.enabled) A toggle. When it is off, the field is always shown and the remaining settings are hidden. Rules entered before the toggle was switched off are kept and are ignored until it is switched on again
Match (conditions.match) All rules shows the field only when every rule matches. Any rule shows it when at least one rule matches. Defaults to All rules
Rules (conditions.rules) A list of rules, added with Add a rule. Each rule has a Field, an Operator and a Value

The Field select lists the fields that come before this field in the form, across every step, each shown as its label followed by its field name in parentheses, for example How likely are you to recommend us? (score). A File upload field and a Content (text only) field are never listed, because neither produces a value a rule can compare.

Operators

The Operator select lists the operators that apply to the type of the field chosen in Field:

Source field type Operators offered
Checkbox, Opt-in checkbox, Yes / No "is checked / yes", "is unchecked / no"
Dropdown (multiple), Checkboxes (multiple) "contains / includes", "does not include", "is empty"
Dropdown (single), Radio buttons "is", "is not", "is empty"
Number, Rating or scale, Date, Time, Date and time "is", "is not", "greater than", "less than", "is empty", "is not empty"
Single line text, Email, Phone, URL, Multi-line text, Hidden value "is", "is not", "contains / includes", "is empty", "is not empty"

The Value input changes with the source field and the operator:

  1. For "is empty", "is not empty", "is checked / yes" and "is unchecked / no", and for every boolean source, no Value input is shown.
  2. For a source with choices (Dropdown (single), Dropdown (multiple), Radio buttons and Checkboxes (multiple)), Value is a select listing that field's choices by label.
  3. For every other source, Value is a text input.

The operators compare as follows:

  1. "is" and "is not" compare the answer with Value as exact text.
  2. "contains / includes" on a text answer matches when Value appears anywhere in the answer, ignoring case. On a multi-choice answer it matches when Value is one of the selected choices. "does not include" is its opposite.
  3. "greater than" and "less than" compare numerically when both the answer and Value are numbers. Otherwise they compare the two as text, which orders dates, times and dates with times chronologically when Value is written in the same format the field posts (for example 2026-10-19). Neither operator ever matches an empty answer.
  4. "is empty" matches an answer that is missing, an empty string or an empty list.

Fields hidden by a rule

The server decides which fields are visible, in FieldConditions, and that decision governs everything the form does with the field:

  1. A hidden field is not validated, so a Required field that its rules hide does not block the submission.
  2. A hidden field's answer is discarded before the submission is stored, and so is any file the visitor staged for it. This applies even when the visitor answered the field before changing an earlier answer that hid it.
  3. A hidden field is left off the review screen and out of the notification and auto-reply emails.
  4. A rule whose source field is itself hidden treats that source's answer as empty.
  5. A rule whose source field no longer exists on the form counts as matched, so the dependent field is shown rather than staying hidden behind a rule with nothing left to evaluate.

Visibility updates while the visitor fills in the form, without a page reload. A field that another field's rule reads binds with wire:model.live, so each change re-renders the form. For Single line text, Email, Phone, URL, Multi-line text and Number sources the update waits until 400 milliseconds after the visitor stops typing.

Step skipping

A step whose fields are all hidden is skipped. Next moves to the next step that has at least one visible field, and Back moves to the previous step that has at least one visible field. When no later step has a visible field, the current step is treated as the last step, and its button shows Button text (or moves to the review screen when Review step is on).

The save error

A rule may refer only to a field that comes earlier in the form. If a rule names a field that has been deleted, renamed, or dragged below the field the rule belongs to, Save changes (or Create) is refused. Nothing is saved, and a red notification stays on screen until it is closed:

The notification names the step by its position and its Step title, the field by its label and field name, and the rule by its position in the field's Rules list, counting from 1. The quoted name in the message is the field name the rule refers to. Each problem is one line.

The save check refuses four kinds of visibility rule error. The first is the error shown above. The other three apply to a rule whose Field is an earlier field and whose Operator or Value does not match that field's current type or choices. Each kind is listed below with an example of its line in the notification:

  1. The rule's Field is not an earlier field in the form, because the field was deleted, renamed or dragged below the field the rule belongs to:
  1. The rule's Operator is not offered for the source field's type (see Operators), for example "greater than" after the source field's Type was changed from Number to Single line text:
  1. The source field has choices (Dropdown (single), Dropdown (multiple), Radio buttons or Checkboxes (multiple)), and the rule's Value is not the value of one of its choices, for example after that choice was removed from the source field, or for a rule saved without a value. The message lists the values of the source field's current choices:
  1. The source field is a Number or Rating or scale field, the operator is "greater than" or "less than", and the rule's Value is not a number, for example an empty value:

A form saved before this check existed can already hold a rule of the third or fourth kind. The Value text input of earlier versions of the panel had no numeric rule, so it saved rules such as one on a Number field score with "less than" and an empty Value, and removing a choice from a source field does not change the rules that use that choice. Every save of such a form is refused, including a save that changes only the form's name, until the rule is corrected in the slide-over of the field the rule belongs to, with the steps under "To fix a refused save" below.

The same notification lists a field name that breaks one of the field name rules: an invalid pattern, more than 64 characters, a reserved name, a double underscore, or a name already used by another field of the form. A duplicate name reads, for example:

This wording differs from the Apply message, which reads The field name 'email' is already used by the field "Email". without naming a step. Apply checks only the field being edited, while the save check walks the whole form and names both fields by their step.

To fix a refused save:

  1. On the Steps tab, find the field the notification names.
  2. On the Steps tab, for a rule whose Field is not an earlier field, either drag the source field's row above that field's row, or click Edit field on that field. For the other three kinds, click Edit field on that field. In the slide-over, correct the Field, Operator or Value of the rule the notification numbers, or delete the rule, and click Apply.
  3. At the bottom of the edit screen, click Save changes again.

The check runs on every save, because a drag, a deletion or a rename changes the order or the names that another field's rule depends on without opening that field's slide-over. It uses FormDefinitionValidator, the validator the MCP form tools use, and refuses only problems with a field name or a visibility rule. A field whose other settings no longer pass the slide-over's checks, for example a Minimum saved by an older version of the panel, is saved as it is; Apply reports the problem when that field is next opened and applied.

Previewing a form

  1. In the admin panel, open Forms (/admin/forms) and open the form.
  2. On the form's edit screen, in the header next to Delete, click Preview.
  3. Step through the form in the slide-over, and submit it to check validation.

Preview renders the form as it was last saved, through the front end's own stylesheet and the site's active skin, framed inside the slide-over at a signed, temporary URL. Save the form first to preview changes made since the last save. An inactive form can be previewed the same as an active one: the preview route does not check Active.

Submitting inside the preview runs the same validation a visitor's answers face — required fields, each field's type-specific rules, conditional visibility, and every field's default value and labels — so an editor can confirm the form behaves correctly before switching it on. It does not do anything else a real submission does: it writes no row to the form's Submissions table, sends neither the notification email nor the auto-reply, does not POST the webhook, does not count toward the limits in Spam protection, and does not show Turnstile even when the form requires it. A form that requires Turnstile renders in the preview even when the Turnstile keys are not configured, although the live form does not render until they are. A banner reading "Preview. Submitting checks the answers and stores nothing." shows above the fields throughout, and a successful submission replaces the form with "Preview — nothing was submitted." instead of the form's own success message.

Progress through a multi-step form's steps while previewing is not saved to the session. Every preview starts on the first step with each field's current Default value, so a default changed and saved in the builder shows the next time Preview is opened. A preview never reads or overwrites a visitor's real, in-progress submission on the same form.

Two people editing one form

The panel refuses to save a form that was changed after the edit screen was opened. Every form has a revision counter, cms_forms.revision, which each save increments. The edit screen stores the revision it loaded in EditForm::$loadedRevision, a Livewire property marked #[Locked] so that a request from the browser cannot change it, and when Save changes is clicked, the save checks that the form is still at that revision and increments it in the same database statement.

When another save has happened in between, from a second browser window, a second editor, or an agent using the MCP form tools, the save is stopped, nothing in the form, its steps or its fields is written, and a red notification stays on screen until it is closed:

To recover the edits:

  1. On the form's edit screen, copy the text of any change that should be kept.
  2. In the browser, reload the page. The screen shows the version that was saved by the other editor.
  3. On the form's edit screen, make the changes again, and click **Save changes**.

After a successful save, the edit screen holds the new revision, so the same editor can save again without reloading. The check covers the whole form: two editors changing different fields of the same form still conflict, because the edit screen saves the form, its steps and its fields together. EditForm runs the save in a database transaction, so a save that fails part way also leaves the revision unchanged.

The revision is compared before the fields are checked for save errors. When the form was saved elsewhere and the edit screen's fields also have a save error, the save is refused with "This form was changed after you opened it" and not with "This form was not saved", so the editor does not correct the fields only to have the save refused for the revision afterward.

Fields applied in a slide-over are part of the edit screen's unsaved changes. After a refused save, reloading the page discards them with every other change, so copy the settings of any applied field before reloading.

The MCP form tools use the same counter. An agent's write that names an older revision is refused, and a panel save that follows an agent's write is refused if the edit screen was opened before the agent's write. See "Revisions and concurrent edits" in The MCP server.

This procedure adds a required marketing-consent checkbox with a link to the privacy policy to an existing form.

  1. In the admin panel, open Forms in the navigation (/admin/forms), and open the form, or click New form to create one (/admin/forms/create).
  2. On the form's edit screen, select the Steps tab.
  3. On the Steps tab, inside the step that should hold the consent checkbox, click Add a field. The slide-over opens.
  4. In the slide-over, enter Marketing consent in Label and press Tab. Field name fills with marketing_consent.
  5. In the slide-over, choose Opt-in checkbox in Type. The **Agreement text** editor appears.
  6. In Agreement text, type `I agree to receive email updates and accept the privacy policy., select the words privacy policy`, click the link button in the editor's toolbar, and enter the policy's address, for example https://example.com/privacy.
  7. In the slide-over, tick Required.
  8. In the slide-over, leave Checked by default switched off.
  9. In the slide-over, click Apply. The slide-over closes and the step shows the row Marketing consent (marketing_consent) · Opt-in checkbox · Required.
  10. At the bottom of the edit screen, click Save changes (or Create on a new form).
  11. On a page that embeds the form, submit it once without ticking the box. The form shows a validation error on the consent field and does not submit.
  12. On the same page, tick the box and submit. In the admin panel, on the form's edit screen, open the new row in the Submissions table. The Marketing consent answer is shown with the stored agreement text under it: `I agree to receive email updates and accept the privacy policy. (https://example.com/privacy)`.

Checked by default stays off because consent recorded from a box the visitor did not tick is not valid consent under GDPR. Required makes the field use the accepted rule, which fails unless the box is ticked.

Building an NPS survey

This procedure builds a Net Promoter Score question on a 0 to 10 scale, followed by a free-text question that is shown only to visitors who score below 7.

  1. In the admin panel, open Forms in the navigation (/admin/forms) and click New form (/admin/forms/create).
  2. On the Form tab, enter Customer feedback in Name.
  3. On the Steps tab, inside the step that is already present, click **Add a field**. The slide-over opens.
  4. In the slide-over, enter How likely are you to recommend us? in Label, press Tab, and replace the generated Field name with score.
  5. In the slide-over, choose Rating or scale in Type.
  6. In the slide-over, choose Numbers in Style, enter 0 in From and 10 in To.
  7. In the slide-over, enter Not likely in Low label and Very likely in High label, tick Required, and click Apply.
  8. On the Steps tab, below the first field's row, click Add a field again.
  9. In the slide-over, enter What could we do better? in Label, press Tab, keep the generated Field name what_could_we_do_better, and choose Multi-line text in Type.
  10. In the slide-over's Visibility fieldset, switch on **Show only when…. Match and Rules appear, with Match set to All rules. If Rules shows no rule row, click Add a rule**.
  11. In the rule row, choose How likely are you to recommend us? (score) in Field, choose "less than" in Operator, enter 7 in Value, and click Apply.
  12. At the bottom of the screen, click Create.
  13. In the admin panel, on the edit screen of the page that should carry the survey, add the Form block, choose Customer feedback in Form, and save the page.
  14. On the public page, choose 9 on the scale. The follow-up question stays hidden. Choose 6. The follow-up question appears below the scale without a page reload.

The follow-up field must come after the rating field, because a rule's Field select lists only earlier fields. A visitor who scores 7 or higher never sees the follow-up question, and nothing is stored under what_could_we_do_better for that submission, even if the visitor typed an answer before raising the score.

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

  1. In the admin panel, open Forms, open the form, and select the Steps tab.
  2. 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.
  3. Inside that step, click Add a field, fill in the slide-over and click Apply, once for each field that belongs on the first screen.
  4. At the bottom of the Steps tab, click Add a step.
  5. In the new step, enter a Step title and add its fields.
  6. At the bottom of the edit screen, click Save changes.

Steps are reordered by dragging, and so are the fields inside each step, by the drag handle at the start of each field's row. A drag that moves a field below another field whose visibility rule refers to it is refused when the form is saved (see The save error). 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. Hidden value fields, Content fields and fields that a visibility rule hides are not listed. 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 shows a numbered stepper above the form: one circle per step, numbered from 1, joined by a line, with the step's title under each circle. The current step's circle is outlined in the skin's accent color, a completed step's circle is filled with it, and the line after a completed step is drawn in it as well. The component is resources/views/components/form-progress.blade.php, and its classes begin with cms-form__progress in resources/css/cms.css.

Every step except the current one is a button:

  1. A step before the current one opens that step, with its answers intact.
  2. A step after the current one runs MultiStepForm::goTo(), which validates each visible step from the current one up to the chosen step, in order, the same way Next does. The first step that fails becomes the current step and shows its errors, so clicking ahead cannot skip a required field. The check runs on the server, so it applies whatever the markup offers.
  3. On the review screen, the last step is a button as well and leaves the review.

Below a viewport width of 40rem, only the current step's title is shown; the other titles remain in the markup for screen readers, so four or more titles do not wrap into a column of single words.

Review step and Show progress apply only to a form with more than one step.

Width

A form on a block whose Width is Normal is 58ch wide (--cms-measure-tight), centered in the block. Narrow, Wide and Full bleed set --cms-form-max to none, so the form, the stepper, the review screen and the button row fill the width the block resolves to: 42rem, 72rem, or the full window with --cms-gutter on each side.

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:

  1. 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.
  2. A file chosen on an early step is staged immediately and attached to the submission when the form is submitted.

A form opened from a draft link keeps its answers on the draft rather than in the session, so the recipient can continue in another browser. See Pre-filled drafts.

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

  1. In the admin panel, open the page's edit screen and add the Form block.
  2. 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.
  3. Optionally, enter a heading for the block.
  4. Optionally, enter Submit button label to replace the form's own Button text on this page only.

The heading and Submit button label are the only settings on the block. The description, the Next and Back labels and the success message come from the form itself, and so does the submit label when the block leaves Submit button label blank. One form placed on three pages therefore behaves identically on all three unless a block sets its own submit label (see Button labels).

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.

Layer Default Behavior
Honeypot On, per form A decoy field, placed off-screen rather than hidden with display: none, because some bots skip fields that CSS hides outright
Timing 2 seconds A submission that arrives less than cms.forms.minseconds after the form was rendered is treated as automated
Rate limiting 5 per minute, 20 per hour Counted per IP address, per form
Turnstile Off, 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

  1. In the site's .env file, set both Turnstile keys:

Environment file

CMS_TURNSTILE_SITE_KEY=0x4AAA…
CMS_TURNSTILE_SECRET_KEY=0x4AAA…
  1. In the admin panel, open the form, select the Spam tab and turn on Cloudflare Turnstile.

Turnstile fails closed. If a form requires Turnstile and either key is missing, the form refuses to render. Signed-in users see an explanation that names the two environment variables; other visitors see nothing. Rendering the form without its challenge would leave the site owner believing the form had spam protection that it does not have.

Local development skips verification when cms.forms.turnstile.skip_locally is true, which is the default. This is a config setting rather than an app()->isLocal() check, because a customer's staging server may legitimately run with the local environment.

Reading submissions

  1. In the admin panel, open Forms and open the form.
  2. Scroll to the Submissions table below the form. Unread rows are shown in bold.
  3. 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.

Field snapshots

Each submission stores a snapshot of the form's answer fields as they were when it was submitted, in the JSON column cms_form_submissions.fields. The snapshot is a list in form order with one entry per field that collects an answer, including a field that a visibility rule hid on that submission. Each entry holds the field's name, label and type, its choices as a map of stored value to label when the field has options, and visible, which is true when the field was shown on that submission. The snapshot holds no answers; the answers stay in cms_form_submissions.data. An opt-in field's agreement text is not repeated in the snapshot, because it is already stored in data under the field's name followed by __text.

FormSubmission::answerColumns() reads the snapshot, and these readers use it:

Reader What it shows
The submission view, opened from the Submissions table Every snapshot entry, labeled with the label the visitor saw. A choice is shown as its label followed by the stored value in parentheses, for example "Morning (9:00) (am)"
Export CSV One column per field name across the exported submissions: the form's current fields first, in form order, then names that appear only in older submissions. A column's heading is the label from the newest submission that has the field. A choice cell holds the label. An opt-in column is followed by its agreement text column when the current field is an opt-in or when any exported submission's snapshot records the field as one, so the accepted agreement text stays in the export after the field's type changes. Every cell, including each heading, keeps the formula escaping described above
The notification email Every snapshot entry that was visible on the submission, labeled from the snapshot. A choice is shown as its label
The auto-reply placeholders {{ fieldname }} resolves from the snapshot. A choice resolves to its label

Because each submission carries its own labels and choices, renaming a field, relabeling it, removing it, or changing its choices does not change how an existing submission reads. A queued email sent after such an edit uses the definition the visitor saw.

Submissions made before the snapshot column existed have no snapshot. For those, answerColumns() uses the form's current fields, followed by every key in data that no current field names. Each such leftover key is labeled with the key itself, and the submission view adds the note "This form no longer has a field with this name." The CSV export includes those keys as columns. The auto-reply placeholders do not offer them. A submission with a snapshot also gets this treatment for any key in data that its snapshot does not name.

The webhook payload is unchanged. It posts data as stored, with choice values rather than labels, and carries no snapshot.

Pre-filled drafts

A draft is a copy of a form that an editor has started for one person, with the answers that are already known filled in. The editor copies a private link and sends it to that person, who checks the answers, completes the rest and submits. Until the person submits, every change they make is saved to the draft, so they can close the tab and continue later from the same link on any device. Use a draft when you already hold some of the information a form asks for, such as a client's company name, address or account number, and want the client to confirm it and supply the rest.

A submitted draft becomes an ordinary submission. The notification email, the auto-reply and the webhook are sent at that moment and not before.

Creating a draft

A draft opens on a published page that holds the form, so place the form on a page and publish the page before you start (see Placing a form on a page).

  1. In the admin panel, open Forms (/admin/forms) and open the form.
  2. Select the Drafts tab below the form.
  3. Press New draft. A slide-over opens.
  4. In Label, type a note naming the recipient, for example "Acme Corp, Jo Smith". The recipient never sees the label.
  5. In Page, choose the page the link opens. The list holds every published page whose published version contains a Form block for this form, directly, inside a container block or inside a snippet. When no page holds the form, the field reads "No published page holds this form. Add a Form block for it to a page and publish the page first."
  6. In Expires after (days), set how long the link works. The default is 30 days and the largest value is 365.
  7. Fill in the answers you know. The slide-over shows one section per step of the form, titled with the step's title. Leave a field empty to let the recipient answer it; an empty field also shows the field's own **Default value**.
  8. Press Create. The Draft link modal opens with the link.
  9. Press the copy button at the end of the Link field, and send the link to the recipient by email or any other means. The CMS does not send it.

The link has the form https://your-site.test/apply?draft=8f3k…, where /apply is the page you chose.

What the recipient sees

  1. The recipient opens the link. The page renders as usual, with the editor's answers in the form and the line "Some answers have been filled in for you. Check them before you submit." above it.
  2. The recipient changes any answer and completes the rest. Every answer can be changed, including the ones the editor filled in.
  3. Each answer is saved to the draft when the recipient leaves the field, and the current step is saved when the recipient moves between steps. Reloading the page, or opening the link in another browser, returns to the same step with the same answers.
  4. A file chosen for a File upload field is checked against the field's rules and kept with the draft. Under the field, the form shows "Uploaded: {file name}. Choose a file to replace it."
  5. The recipient submits. The usual spam checks, rate limit and validation apply, and the usual success message or redirect follows.

Opening the link again after that shows "This form has already been submitted." A link that has expired, has been revoked or does not match any draft shows "This link is no longer valid. Contact the person who sent it for a new one." Both messages replace the form and are followed by a Start a new form link to the same page without the draft.

Managing drafts

The Drafts tab lists every draft of the form, newest first, with its Label, Page, Status, Expires date, Last opened time and Created by user. The Status filter narrows the list.

Status Meaning
Open The link works
Submitted The recipient submitted the draft; it is now a submission
Revoked An editor pressed Revoke
Expired The Expires date has passed

Each row offers these actions:

Action Shown when What it does
Copy link The draft is Open Opens the Draft link modal again
Edit The draft is Open Opens the slide-over to change the label, page, expiry or answers. Once the recipient has opened the link, the slide-over warns: "The recipient has opened this link. Saving replaces their answers with the ones below."
Revoke The draft is Open Stops the link working immediately, after a confirmation. The draft stays in the list as Revoked
View submission The draft is Submitted Opens the submission the draft became
Delete Always Deletes the draft and any files uploaded to it

When the chosen page is later deleted or unpublished, the link answers with the site's 404 page and the Page column shows "Unavailable" under the path. Use Edit to choose another page; the link changes only in its path, so copy it again with Copy link and send the new one.

Fields a draft can fill in

Field type Can be filled in Reason when it cannot
Single line text, Email, Phone, Number, URL, Paragraph Yes
Dropdown (single), Radio buttons, Rating or scale Yes
Dropdown (multiple), Checkboxes (multiple) Yes
Checkbox, Yes / No Yes
Date, Time, Date and time Yes
Opt-in checkbox No It records the recipient's own consent
File upload No It is the recipient's own file
Content (text only) No It collects no answer
Hidden value No Its value is set when the form renders on the page

Only the type of each pre-filled value is checked, for example that an email address is well formed or that a choice is one of the field's choices. A field's Required setting, its extra validation rules and its visibility rules are applied when the recipient submits.

A form edited after a draft was created is matched to the draft's answers by field name. An answer for a removed field is discarded, a renamed field starts empty, and a removed step returns the recipient to the first step.

Configuration and cleanup

The settings live under forms.drafts in config/cms.php:

Key Default What it sets
enabled true With false, the Drafts tab, the three MCP draft tools, draft links and the scheduled cleanup are all switched off
defaultexpirydays 30 The starting value of Expires after (days)
maxexpirydays 365 The largest expiry an editor or an agent can set
pruneafterdays 30 How long a submitted, revoked or expired draft is kept
uploaddirectory cms/form-drafts Where a draft's uploaded files are kept on the forms upload disk until the draft is submitted

A published config/cms.php written before 0.86.0 has no forms.drafts block; the defaults above apply until you add it.

php artisan cms:prune-form-drafts deletes drafts that were submitted, revoked or expired more than prune_after_days days ago, together with their uploaded files. It runs daily on Laravel's scheduler. It takes --days=N to override the setting (the value must be 1 or more), --dry-run to print the count without deleting, and --force to skip the confirmation.

Privacy

  1. Anyone who has the link can open the draft, read its answers and submit it, until it expires or is revoked. Send it only to the recipient.
  2. The link's token is stored as a SHA-256 hash, used to find the draft, and encrypted with the application key, so Copy link can show it again.
  3. A page opened from a draft link is sent with Referrer-Policy: no-referrer, so the browser does not pass the link to another site, and with X-Robots-Tag: noindex. It is never stored in the page cache, even when draft is added to cms.cache.allowed_query_params.
  4. A draft holds the recipient's answers in the database, unencrypted, like a submission.
  5. Drafts have their own permissions, separate from the submissions inbox: cms.form-drafts.view, cms.form-drafts.create, cms.form-drafts.update and cms.form-drafts.delete. See docs/permissions.md.

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:

Format Editing Sending
Plain text A text area. Blank lines separate paragraphs Wrapped in Laravel's standard mail layout. Markup typed into the body is escaped and appears as literal angle brackets
HTML A syntax-highlighted code editor 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
{{ yourfieldname }} That field's answer. Multi-value answers are joined with commas; checkboxes read Yes or No
{{ formname }} The form's name
{{ submissionid }} The submission's id
{{ submittedat }} The time the submission arrived

Plain text

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:

  1. Whitespace inside the braces is ignored.
  2. 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.
  3. 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.
  4. Newlines are removed from the subject line, because a mail header that contains a line break is rejected.
  5. 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.

JSON

{
  "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:

Header Value
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().

The upload disk must be a private disk. Submissions carry CVs, invoices and photographs of identity documents, and an attachment reachable by guessing a URL is a data breach. Files are streamed back through an authenticated panel route and are never linked directly. Uploads never enter the media library either, so an editor's media picker does not fill with visitors' documents.

In a submission's view in the panel, each file answer is a link to that route: GET {panel path}/form-submissions/{submission}/files/{field}, named cms.submissions.download inside the panel's route group (full name filament.{panel id}.cms.submissions.download). Opening it requires a signed-in user who can access the panel and, when permissions are enabled, the cms.submissions.view ability. A signed-out visitor is redirected to the panel's sign-in page, and a user without the ability is refused with 403. A field name the submission has no file under, and a file that is no longer on the upload disk, both answer 404. The file downloads under its stored name.

To keep uploads in a private S3-compatible bucket, and to set Livewire's temporary upload disk on a host with several containers, follow the steps in Storage.

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

  1. In config/cms.php, set retention_days in the forms group:

PHP

'retention_days' => 365,
  1. 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:

Shell

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.

Answers are stored in a plain JSON column and are not encrypted at rest. A site that collects sensitive data should account for this.

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:

Shell

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 by for and id.
  • 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. Yes / No and Rating or scale use the same <fieldset> and <legend>, because each is a group of radio buttons that answers one question.
  • An Opt-in checkbox uses its Agreement text as the checkbox's <label>, so a screen reader announces the full agreement when the box receives focus.
  • The individual boxes of a required checkbox group carry no required attribute. A browser reads required on 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 has role="status".
  • The honeypot field has aria-hidden and tabindex="-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:

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:

PHP

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:

PHP

$this->app->singleton(CaptchaVerifier::class, HCaptchaVerifier::class);

An implementation must follow two rules:

  1. verify() must return false on a network error, a timeout or a missing secret.
  2. isConfigured() must return false when 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

Not supported Detail
A public API There is no schema endpoint and no JSON submission endpoint. One would be added if a decoupled front end needed it
Calculated fields A field cannot compute its value from other answers, such as a total or a score
Step-level visibility rules Rules are set on fields, not on steps. A step is skipped only when every field on it is hidden by its own rules (see Step skipping)
Rules that refer to a later field A rule's Field can name only a field earlier in the form, and saving is refused otherwise (see The save error)
Payment Forms cannot take payments
Per-field encryption at rest Answers are stored in a plain JSON column
MCP tools The MCP server builds and edits forms (see "Form tools" in The MCP server). It does not read submissions, delete forms, or set the webhook; it sets notification recipients only when cms.mcp.forms.denied allows it