WebProCMS includes a built-in form builder. Editors create contact forms, job applications, photo contests, or any other lead-capture form in the dashboard, drop the form into any page from the design library, and review submissions in the same admin without standing up a separate service like Typeform, Wufoo, or Google Forms. Every form also has a layered spam protection stack — a choice of protection method (in-house Shield, honeypot, reCAPTCHA, Turnstile, or Akismet), an in-house content classifier (weighted heuristics + a seeded scam-template library + a self-learning model that trains on the spam you flag + an optional cross-install shared-intelligence feed), an origin-country / service-area filter, plus per-form rate limiting, keyword/domain blocklists, and duplicate-submission prevention — all configurable per form or site-wide.
The problem
Almost every site needs at least one lead-capture form: a contact box, a job application, a quote request, a contest entry. The default solutions are bad. A third-party service costs per-seat or per-submission, leaks form data through someone else's privacy policy, and breaks the page's visual style. Hand-rolling a <form> per page leaves the editor unable to add a field without a developer, and every form needs its own spam mitigation, validation, notification email, and submission archive — wiring all of that per-form is what kills hand-rolled forms in the wild.
The fix
Forms are a first-class entity. Each form has a name, a starter type (Contact, Job Application, Photo Contest), a list of fields the editor can reorder and customise, an optional notification email, an optional submission archive, and a per-form spam protection override. The form is dropped into a page from the design library, the public-facing render is handled by ContactForm, and submissions land in the dashboard.
Field types
The editor can add any of the following field types from the New Field menu:
| Type | Public render | Notes |
|---|---|---|
text |
Single-line input | Default. |
email |
Email input with HTML5 validation. | |
phone |
Tel input. | |
textarea |
Multi-line text. | |
file |
File upload. | Per-field accept (extension whitelist) and max_mb cap. |
checkbox |
Single checkbox (e.g. "I agree to terms"). | |
select |
Dropdown. | Editor manages an options list of value / label pairs. |
radio_group |
Radio buttons. | Same options shape as select; supports an orientation (vertical / horizontal). |
checkbox_group |
Multiple checkboxes. | Same options shape as select. |
note |
Rich text block (instructions, divider, terms). | Doesn't collect a value. |
date |
Date input (<input type="date">). |
Validated server-side as a date. |
number |
Number input. | Optional min / max extras enforce bounds on both the input and the server rule. |
hidden |
<input type="hidden"> — submitted invisibly. |
default_value extra; the page-level prefill mechanism (e.g. the job title on a job detail page) overrides the default. No label, help, or required toggle on the public form. |
page_break |
Splits the form into steps. | Extras: content (the title of the step it starts), next_label, back_label (empty = localized "Next" / "Back"). See "Multi-step forms". |
payment |
Collects a Stripe payment after submit. | Requires the Payment Links feature. Extras: amount_type (fixed / choice / open), amount, options ({label, amount} rows), min_amount. See "Payment fields". |
signature |
E-signature capture with three methods: Type (name rendered in a choice of script fonts), Draw (mouse/touch canvas pad), Upload (signature image). | See "Signature fields". |
time |
Time input (<input type="time">). |
Validated server-side as HH:MM. |
url |
URL input. | Validated as an http/https URL (a javascript: value is rejected). |
rating |
Star rating (1–N radio group with hover highlighting). | max_rating extra (3/4/5/7/10 in the editor). Validated as an integer within the star bounds. |
range |
Slider with a live value readout. | min / max / step extras; validated numerically within the bounds. |
name |
First / last name pair. | Submits as one combined string under the field key ("Jane Smith"); required means both parts. The parts get given-name / family-name autocomplete. |
address |
Multi-part address: street (+ optional apartment/suite line), city, state, ZIP (+ optional country dropdown). | include_street2 / include_country extras. Submits as a structured array; required applies to every part except the apartment line. |
country |
Dropdown of the built-in country list. | Options come from AddressFormats::countries(); the display name doubles as the submitted value so archives and emails stay readable. |
state |
Dropdown of the built-in US state list (States). |
Same name-as-value convention. |
price_quantity |
Quantity input with a live qty × unit price = total readout. |
unit_price extra (site currency) plus quantity min / max, and an optional unit_label (the singular noun being counted — "task") that makes the readout and the submission summary say "3 tasks", with an optional unit_label_plural for plurals the automatic English one gets wrong ("people", or any non-English base language). Both are translatable form strings ({field_id}.unit_label / .unit_label_plural, offered whenever the field has a unit — another language's plural never follows the English rule), resolved through FormUnitLabel; a translated singular without a translated plural drops the base language's hand-typed plural rather than pairing with it. Submits {quantity, unit_price, total}; the line total is usable as a calculation token and a logic source, and a payment field can charge it (see "Payment fields"). Required means at least one unit. |
calculation |
Read-only computed value (quote totals, scoring, character counts). | See "Calculated fields". Doesn't collect input — the server computes the stored value. |
repeater |
"Add another" groups of sub-fields (attendees, line items). | See "Repeaters". Submits as an array of rows. |
services |
Cascading "which of these do you do?" checklist: industry → categories → that category's services, plus write-ins. | See "Services checklist". Submits the chosen labels grouped by category. |
heading |
Section heading rendered from the field label. | heading_level extra (H2/H3/H4). Display-only. |
html |
Raw HTML block (admin-authored). | content extra rendered as-is. Display-only. |
Every field has a stable id (so reorder doesn't change which key submissions land under), a key (the slug used in submission data), a label, optional placeholder / help_text / aria_label, a required flag, and a Tailwind widths string for the responsive column span (col-span-12 md:col-span-6 etc.) so the editor can lay out fields side-by-side on a 12-column grid.
Calculated fields
A calculation field shows a live-computed value on the form and stores the server-computed result in the submission — the in-browser display (updated as the visitor types by form-flow.js) is cosmetic and never trusted. The formula references other fields with {key} tokens:
({guests} * 25) + {addons}
round({subtotal} * 1.0825, 2)
min(len({message}) / 10, 5)
Supported grammar (evaluated by FormCalculator, a small recursive-descent parser — no eval, ever): + - * /, parentheses, decimal numbers, and the functions round(x[, places]), min(…), max(…), ceil(x), floor(x), abs(x). len({key}) substitutes the character count of the field's raw string value. Token values resolve numerically: numbers/ranges/ratings parse as numbers, a checkbox is 1/0, a checkbox group sums its numeric option values, a price_quantity field contributes its line total, and an earlier calculation field contributes its result (calculations run in field order). Anything non-numeric is 0; parse errors and division by zero yield an empty stored value. Display extras: decimal_places (0–4), calc_prefix / calc_suffix (e.g. $ / pts). The editor's Formula panel lists the tokens available on the form.
Repeaters
A repeater field renders a bordered group of sub-fields with an "Add another" button — attendees, line items, references. Sub-fields are deliberately simple (text, email, phone, number, date, time, URL, or a comma-separated-options dropdown) with a label and a per-sub-field required toggle; the editor manages them in the field's settings panel. Extras: min_rows / max_rows and a custom add_label.
Rows submit as an array of {sub_key: value} maps under the field's key. Fully empty rows are dropped — an untouched trailing row never blocks the submit. A required repeater needs at least min_rows filled rows; required sub-fields apply to every filled row. Row add/remove runs through Livewire (addRepeaterRow / removeRepeaterRow), and the last row clears instead of disappearing so the group never renders empty. The submissions inbox lists each row on its own line; the notification email and autoresponder tokens flatten rows through FormValueDisplay.
Services checklist
A services field asks a business what it actually does, and it is built for onboarding: every box they check becomes a service page we build for them.
It reveals itself one level at a time, because the alternative does not survive contact with a real trade — an electrician's full list is 66 services across four categories, and showing all of them (never mind every industry's) buries the twelve that matter:
- Industry — a dropdown (electrician, plumber, lawyer, accountant, plus "Other / not listed").
- Categories — only that industry's, e.g. Residential / Commercial / Industrial / Other & specialty. Nothing below appears yet.
- Services — only for the categories they checked. A shop that does no industrial work never renders an industrial checkbox.
At every level they can add their own: a category the catalog missed (checked on arrival, since typing one you don't work in makes no sense) and a service under any checked category. Write-ins render as removable chips, are de-duplicated and whitespace-collapsed, and are capped per category.
The catalog lives in ServiceCatalog as a plain PHP map, so it ships with the CMS and improves with releases. Its keys are permanent — a stored submission references them. Relabel freely; never rename a key. Catalog group keys are hyphen-slugs and written-in ones carry a custom_ prefix, which is what keeps the two namespaces provably disjoint.
Field settings. industry pins the field to one industry and hides the picker — for a form only one trade ever fills out; an unknown value is ignored rather than rendering a picker-less field with nothing under it. min_services sets a floor, enforced once the visitor has started answering even when the field is optional (a half-answered service list is worse than none).
Reactivity. The industry and category checkboxes are .live because each decides what renders beneath it; the service checkboxes are deferred, so ticking twenty boxes costs zero round trips.
Two rules the server enforces regardless of the UI, since the whole tree is a visitor-writable public property:
- Changing the industry resets the tree rather than re-filtering it. Filtering alone is not enough —
residentialexists under both electrician and plumber, so an overlapping key would survive the switch and they would submit a category picked while answering a different question. - Every selection is re-checked against the catalog on each change and again at submit. The submit pass is what catches the case no visitor action can trigger: the form's own configuration changing mid-session.
What gets stored is labels, not catalog keys — a submission is read by a person in an email, a CSV and the dashboard, and has to stay readable after a relabel:
['industry' => 'Electrician', 'groups' => [
['label' => 'Residential', 'items' => ['Panel & service upgrades', 'EV charger installation']],
['label' => 'Commercial', 'items' => ['LED lighting retrofits']],
]]
A category checked but left empty is dropped — it says nothing about what they do. FormValueDisplay recognises the shape structurally (no marker key leaks into the payload or the API) and flattens it to one grouped line — Electrician — Residential: …; Commercial: … — for the notification email, CSV export and SMS; the submissions inbox renders it as a grouped list instead.
Localization. Catalog labels are deliberately not wrapped in __(). They are public-site copy, and the public site translates through content_overrides / HasTranslations rather than lang/*.json — adding ~250 service names to the dashboard corpus would cost every locale a pass for strings the dashboard never renders. A localized catalog is a deliberate follow-up.
Dynamic dropdown options (data sources)
select, radio_group, and checkbox_group fields can source their options from live CMS data instead of a manual list — switch the field's Options source to Data source and pick a preset plus a value token and label token. Options resolve at render time through the same PresetRegistry the design library uses, and server-side validation only accepts resolved option values. Available sources:
- Content types — one preset per registered content type (Services, Locations, Events, …).
- Taxonomies — one preset per content-type taxonomy that has terms (
TaxonomyTermsPreset) — blog categories, service areas, event tags. - Users — CMS accounts (
UsersPreset); exposes only name and id as tokens (never email — options render into public HTML).
Richer validation
Text-ish fields (text, textarea, email, phone, url) carry a Validation rules panel:
- Min / max length — server-enforced on top of the type's base rules.
- Pattern — a regular expression (typed without delimiters, e.g.
^[A-Z]{2}-[0-9]{4}$). An invalid pattern is ignored rather than breaking the form; patterns containing|are safe (rules are built in array form). - Custom error message — replaces the message for any failing rule on the field.
- Unique value — rejects a value any earlier archived submission of the same form already used (one entry per email, one vote per code).
Prefill
Each input-collecting field has a Prefill panel with two sources, applied to empty fields only, in priority order page context → URL parameter → logged-in user → hidden-field default:
- URL parameter (opt-in per field):
[email protected]fills the field on page load; the parameter name defaults to the submission key and can be overridden. Values are clamped to 500 characters and still validate normally on submit. - Logged-in visitor: full name, first name, last name, or email — resolved from the web guard first, then the Memberships guard. On a composite
namefield the full name splits across the first/last parts. Cached-page safe:GuestOnlyCacheProfilenever caches (or serves cache to) logged-in visitors, so user data can never bake into the shared anonymous page cache.
Conditional logic
Any field can carry a logic key:
{ "action": "show", "match": "all", "rules": [ { "field": "fld_abc12345", "operator": "equals", "value": "Support" } ] }
match is all or any; each rule references another value-bearing field by its stable id with an operator and a comparison value. Operators: equals, not_equals, contains, starts_with, ends_with, greater_than, less_than, between (inclusive, using value and value2), empty, not_empty. The numeric comparisons never match a non-numeric side. A field with no logic key — or an empty rules list — is always visible. The builder UI lives in the selected field's settings panel under "Conditional logic".
Actions:
action |
Effect while the rules match |
|---|---|
show / hide |
Toggle the field's visibility (the classic pair). |
require |
The field stays visible and becomes required only while matched — the static required flag is ignored. |
disable |
The field's controls grey out and can't be edited; its value still submits and required never blocks. |
set_value |
The field's submitted value is forced to logic.set_value, replacing whatever the visitor typed. Forced values cascade — other rules see the forced value. |
skip |
Page breaks only: the step this break starts is skipped entirely — its fields behave as hidden and step navigation branches over it. |
The evaluator is FormLogic, mirrored client-side by resources/js/form-flow.js so fields react instantly as the visitor types:
- Strings compare trimmed and case-sensitively;
containsis a substring match,starts_with/ends_withare prefix/suffix matches. - Single checkboxes normalize to
yes(checked) / empty (unchecked). - Checkbox groups are arrays:
contains= the option is selected,equals= exactly that one selection,empty/not_empty= selection count. calculationandprice_quantityfields are valid rule sources — rules compare against the computed result / line total.- A rule referencing a field that is itself hidden evaluates against an empty value — visibility resolves iteratively (capped at the field count, so rule cycles can't loop).
Enforcement is server-side, not just cosmetic: hidden fields contribute no validation rules (a required field the visitor never saw can't block the submit) and their values are dropped from the submission data entirely; a matched require adds the required rule server-side; a matched set_value overrides the stored value server-side. A payment field hidden by logic charges nothing. Client-side, hidden required inputs have their required attribute parked on a data-wp-required marker so browser validation never traps focus on an invisible control.
Multi-step forms
Adding one or more page_break fields splits the form into steps: everything before the first break is step 1, and so on. The public render wraps each step, shows a progress bar ("Step X of N" plus the step title when set), and renders Next/Back buttons using the break's configured labels. The submit button — and the spam-shield/captcha area — live on the last step. Clicking Next runs the browser's own checkValidity over the current step's visible fields before advancing; full validation still runs server-side on submit, and a validation failure jumps the visitor back to the first step containing an errored field (via a form-validation-failed Livewire dispatch). A step whose fields are all hidden by conditional logic is skipped in both directions, and a page break carrying skip logic branches over its step while the rules match (conditional step branching — "only show the pet details step when they said they have pets"). Forms without a page_break render exactly as before.
Payment fields (Payment Links feature)
A payment field turns a form into a paid form — an event registration with a fee, a paid application, a deposit-with-inquiry. It requires the Payment Links feature (its Stripe credentials, currency setting, and checkout plumbing power the charge); the palette button only appears while the feature is on, and a form that still carries a payment field after the feature is switched off simply skips it — the form submits free.
Three amount modes:
amount_type |
Behaviour |
|---|---|
fixed |
The field displays the amount (amount extra, a decimal string in the site currency); every submission charges it. |
choice |
Renders a radio list of options ({label, amount}); the picked amount is validated server-side against the configured list. |
open |
The payer types an amount; validated against the strict decimal parser and the field's min_amount floor (never below 1.00). |
field |
Charges the line total of the price_quantity field whose id is amount_field_id (an id, not a key, so renaming the field's key can't break the link). The charge is recomputed server-side from the validated count — never the figure the browser displayed — and the count and the unit as it reads beside that count — base language, already pluralised ("tasks" for 3) — are frozen on the submission (payment_quantity, payment_unit_label), because those lines are the owner's and a later re-pluralisation could not honour a hand-typed plural. Checkout then bills that many units at the unit price ("Qty 3, $35.00 each") instead of one lump, and the paid email / SMS line, the pay page and the inbox badge read "$105.00 (3 tasks × $35.00)" via FormPaymentSummary. A required payment makes at least one unit required on the linked field, whatever that field's own required toggle says. The editor refuses to save a field payment that names no price_quantity field on the form — pointing at nothing it would resolve no amount and the paid form would quietly submit free. The public form shows a live mirror of the linked total inside the payment block. |
The flow: when a submission has a visible payment field with a resolved amount, ContactForm::submit force-persists the submission (even when save_submissions is off — the pending state has to live somewhere) with payment_amount_cents, payment_currency, and a random 40-char pay_token, skips the owner email / CRM capture / conversion, and redirects to forms/pay/{token} — an embedded Stripe checkout page inside the PaymentLinks module (⚡form-pay, sessions built by FormPaymentCheckoutStarter with form_submission_id metadata). The Stripe webhook and the return page (⚡form-return) both run FormPaymentRecorder, which claims the submission atomically (whereNull(paid_at)) and — exactly once — fires the deferred pipeline: the owner notification (with a "Paid {amount}" line), CRM capture, the analytics conversion, the form's submit-time actions, and the Webhooks feature's form.submitted event. The submissions inbox shows a green "Paid" badge (plus Refunded / Partially refunded), an amber "Payment pending" or Payment processing badge, or a red Payment failed badge on rows with a payment amount.
Checkout prefills the email address the visitor already typed into the form (its first email field), and each new checkout session is recorded on the submission (stripe_checkout_session_id) the moment it is minted, so the recorder knows which checkout is live.
Bank payments (ACH). A visitor can pay by US bank account when the Stripe account offers it. That checkout completes days before the money arrives, so the submission is held as Payment processing (payment_processing_at) and none of the deferred pipeline runs: the return page shows the shared processing notice (a reload — or the pay link — never offers checkout again). When the debit clears (checkout.session.async_payment_succeeded) the same paid claim runs the pipeline exactly once. If it bounces (checkout.session.async_payment_failed) the submission is marked Payment failed (payment_failed_at) and the visitor gets one email saying it didn't go through, with a Try again button to the pay page — the submission is kept, and paying again starts a fresh checkout that clears the failed state. Only the submission's current checkout session can mark it processing or failed — checked inside the claim itself, so a retry stamped while an old session's event is being handled is never overwritten — and a bounced session's late events never touch a retry; a session that reports paid always lands. One that finds the submission already paid by a different checkout is logged as a warning (a second checkout paid for a form submission that was already paid) so one of the two payments can be refunded. A checkout the visitor already finished is never replaced: if the pay link finds it unreported (Stripe's completed event not yet delivered), it is recorded there and then and the visitor goes to the return page. The processing note on a paid form promises no email — nothing is sent to the visitor when the payment clears. Subscribe the Payment Links webhook to checkout.session.async_payment_succeeded, checkout.session.async_payment_failed and charge.refunded alongside checkout.session.completed. If Stripe never says how it ended (the webhook isn't subscribed to the two checkout.session.async_payment_* events, or Stripe gave up delivering them), the payment is checked against Stripe once it has been processing for a day — php artisan payments:reconcile-processing, every six hours — and settled or failed exactly as the webhook would have, with a warning in the log.
Refunds. A refund made in the Stripe dashboard (charge.refunded) records the running refunded total on the submission (amount_refunded_cents, plus refunded_at once all of it has gone back), found by the payment intent the paid claim kept (stripe_payment_intent_id). The visitor's answers are untouched.
The page-editor preview never charges: in editor-preview mode the payment field renders as a disabled placeholder and submit short-circuits to the normal success state.
Signature fields
A signature field lets a visitor sign the form — a liability waiver, a permission slip, an application attestation. The public render is the shared <x-signature-capture> component (the same capture used by Contracts) with three tabbed methods:
- Type — the visitor types their name and picks one of the script styles from
SignatureCapture::fonts()(Elegant / Classic / Casual / Serif italic — OS font stacks with a genericcursivefallback, no webfont download), previewed live on each style button. - Draw — a mouse/touch canvas pad (
<x-signature-pad>); strokes are stored as integer polylines in a logical 600×200 space. - Upload — a signature image (PNG/JPG/WebP/GIF, max 4 MB) through the normal Livewire upload path, stored on the private disk alongside file-field uploads (see "Visitor uploads are private").
The component writes a JSON payload ({mode, name, font, paths}) into the field's values.{key} slot; the server never trusts it — ContactForm::parseSignatureValue whitelists the font, runs drawn paths through SignatureCapture::sanitizePaths (integer-rounded, capped at 200 polylines × 2,000 points so a hostile payload can't balloon the row), and requires the upload to actually exist for uploaded mode. A required signature means the parsed payload must hold a real signature. The stored submission value is the sanitised array (plus its disclosure record — see below); the submissions inbox renders it as the styled name, an inline SVG of the strokes, or the uploaded image, and the notification email renders the styled name / a sign-in-required image link (drawn signatures say "view in the dashboard" — most mail clients strip SVG).
Signature fields can be shown/hidden by conditional logic but can't be a logic source (like file and payment, they carry no comparable value). The REST API skips them the same way it skips file fields.
E-sign disclosure and consent. Above the pad, every signature field shows a collapsible Electronic signature disclosure and a consent box: "I have read the electronic signature disclosure and agree to sign electronically. The signature I add below is my legal electronic signature." The disclosure follows the US ESIGN Act's consumer-consent rules: what the consent covers (this form), a free paper copy on request, how to withdraw consent before submitting, the hardware and software needed, and how to update contact details. It is built by ESignDisclosure::forForm(), the class Contracts and Proposals also use.
A form sits on a public, cached page, so the disclosure names only contact details the site publishes on purpose:
- the business from Privacy & Compliance → Legal name, falling back to the business name;
- the Privacy contact email and the business phone — never
business.email; - with neither set, "the contact page on this site".
Consent is only checked when the field holds a signature (exclude_without), so an optional signature left blank asks nothing. A form with two signature fields (a participant and a guardian, say) has each signer tick their own box.
The record. The disclosure is built once at mount into the locked esignDisclosure property, so a cached page's snapshot carries exactly the text its HTML shows. That same array is stamped into each stored signature value as disclosure: {version, text} — the full text, consent statement included. Because the record lives inside the signature, it reaches every reader of the data:
- the stored submission, and the submissions inbox, which shows "Signed electronically after accepting the electronic signature disclosure (version …)" under the signature, expandable to the text;
- the owner notification email — a line under the signature plus the full text at the end, the only record when the form keeps no submissions;
- the submission PDF (the owner's and the autoresponder's copy), the payment flow's later emails, webhooks, and the CRM timeline.
A page opened before its form gained a signature field has no disclosure in its snapshot: the submit builds one, fails on consent, and the re-render shows both. Drafts never keep consent, so a resumed form asks again.
Starter templates
Picking a form Type on the New Form page populates a starter field list. The templates live in FormTemplates; the type list is the FormType enum, which also sets the default submit-button label on the public form (e.g. "Request Booking", "Subscribe", "Send RSVP").
| Type | Starter fields (abridged) |
|---|---|
| Contact | Name, Email, Inquiry |
| Booking Request | Name, Email, Phone, Service (dropdown), Preferred Date + Time, Notes |
| Callback Request | First Name, Phone, Best Time to Call (dropdown), Topic |
| Quote Request | Name, Email, Phone, Company, Project Details, Budget + Timeline (dropdowns) |
| Estimate Request | Name, Email, Phone, Job Address (address), Type of Work (dropdown), Description, Photos (file) |
| Order Form | Section headings, Name, Email, Phone, Delivery Address, Items (repeater: item + quantity), Order Notes |
| Event Registration | Name, Email, Phone, Sessions (checkbox group), Additional Attendees (repeater), Dietary Requirements |
| RSVP | Name, Email, Will you attend? (radio), Number of Guests, Dietary Requirements |
| Newsletter Signup | First Name, Email, Interests (checkbox group), Consent checkbox |
| Survey | Satisfaction (star rating), Recommend 0–10 (slider), two open questions, optional Email |
| Feedback | Type of Feedback (dropdown), Rating (stars), Message, optional Email |
| Testimonial | Name, Email, Rating (stars), Testimonial, Photo (file), publish-permission checkbox |
| Support Request | Name, Email, Topic (dropdown), Priority (radio), Message, Attachment (file) |
| Job Application | Name, Email, Phone, Position, Cover Letter, Resume (file: pdf/doc/docx) |
| Volunteer Application | Name, Email, Phone, Areas (checkbox group), Availability (dropdown), Motivation |
| Photo Contest | Name, Email, Photo Title, Description, Photo (file), Terms checkbox |
The starter fields are a starting point, not a constraint. Once the form is created, every field can be edited, removed, reordered, or replaced — the type is just a header on the listing page.
Form import & export (portability)
Every form can be exported as a JSON definition and imported on another install (or the same one) — build a form once and ship it to every client site.
- Export — on the Forms page, open a form's dropdown menu and choose Export JSON. The download contains the complete definition: fields (including conditional logic, calculations, and repeaters), submit-time actions, spam settings, scheduling, retention/encryption flags, and any saved translations. It deliberately excludes install-local state — the database id, the "Default" (seeded) flag, and submissions.
- Import — click Import next to New Form and upload an exported file. The form is always added as a new form; existing forms are never overwritten, and a name collision gets an "(imported)" suffix.
Imports are re-validated by FormPortability, never trusted: unknown field types are dropped, malformed or duplicate field ids are regenerated (valid ids are preserved so conditional-logic rules and per-field translations keep working), keys are normalized and de-duplicated, and enum values (type, spam protection, field size) fall back to safe defaults. A file exported by a newer CMS version is rejected with a clear message rather than half-imported.
Notification email
Each form has a notification_email column. When set, ContactForm::submit sends a FormSubmissionMail to those addresses on every submission. Multiple recipients are comma-separated and validated by the CommaSeparatedEmails rule (max 500 characters total). Leaving the field blank disables the email — useful when the form's only purpose is to drop submissions into the dashboard.
Notification text message
A form can also text the owner when a lead comes in — instead of or in addition to the email. The notification_sms column holds a comma-separated list of numbers, validated by CommaSeparatedPhones (max 500 characters). The two channels are entirely independent: fill in either, both, or neither.
Sending runs through FormSmsNotifier, which is wired into all three notification points — the live submit flow, the deferred post-payment pipeline (the text carries the same "Paid {amount}" line as the email), and the API submissions endpoint.
Numbers are normalized to E.164 (+15551234567), the only shape Twilio accepts. An admin can type 555-123-4567 or +1 (555) 123-4567 and get a working number — bare 10-digit and 11-digit-leading-1 inputs are assumed US/Canada. Anything that can't plausibly be a phone number is rejected at save time rather than silently failing at send time.
The message body leads with the form name, adds the paid amount when there is one, then lists the submitted fields as Label: value lines — capped at 6 fields and 460 characters (roughly three SMS segments) so a long message field can't run up a bill. File and signature fields are named but not dumped (Resume: (see dashboard)) — a storage path is useless in a text. Values flatten through the same FormValueDisplay the email uses.
Delivery rides the shared Twilio gateway in SmsSender — the same account already powering campaign SMS, the Unified Inbox, booking reminders, and review requests. Credentials are entered once at Marketing → Settings and cover every SMS feature on the site. Because the number belongs to the site owner (typed into their own dashboard) this is a transactional alert, not a marketing send, so no "Reply STOP" notice is appended.
When Twilio isn't set up yet, entering a number surfaces a setup reminder — in the dashboard form editor, the new-form page, and the page-editor form panel — with a link straight to Marketing → Settings. Nothing breaks in the meantime: FormSmsNotifier::notify() no-ops when the gateway is unconfigured or the Marketing module is off, and submissions still save and email normally. Sending never throws either — a gateway outage or one bad number is reported and swallowed so the visitor's submission always completes.
Submit-time actions
Beyond the owner notification, each form carries a per-form actions config (the actions JSON column, normalized by Form::submitActions()) controlling what happens after a clean submission. The runners live in FormSubmitActions and fire from both the live submit flow and the deferred post-payment pipeline, so a paid form runs the same actions exactly once — after Stripe confirms. Every action is individually isolated: a failure is reported and swallowed, never breaking the visitor's submission. Spam-quarantined and honeypot-caught submissions run no actions (no autoresponder backscatter, no webhook noise) — they only see the confirmation state, so bots learn nothing.
Custom confirmation
The Settings tab's Confirmation section picks what the visitor sees after submitting:
| Type | Behaviour |
|---|---|
message (default) |
The green success panel, with an optional custom heading and message (empty = the localized "Submitted!" / "Thank you! We'll be in touch soon."). |
summary |
The message panel plus a label/value recap of everything the visitor submitted. Files show "(file attached)", signatures "(signed)", checkboxes Yes/No; labels are localized per the active language. |
redirect |
Sends the visitor to a site-relative path (/thank-you) or a full URL instead of showing the panel — the URL may carry {field_key} merge tags. Validated at save time. A page on this site follows the visitor's language: submitted from /es/…, /thank-you sends them to /es/thank-you (LinkLocalizer); other hosts and a path already naming a language are left as typed. |
Merge tags in the redirect URL
A redirect URL can carry {field_key} tags that expand to the visitor's own submitted values, so the same form can hand off to different destinations depending on what was typed. Each {field_key} becomes the submitted value, rawurlencoded, so it can change a query parameter's value or fill one path segment — never add a query key, a fragment, or a host. A composite name field renders as its joined "First Last" string, a checkbox as 1/0; an unknown tag, or a field whose value isn't a scalar (address, price × quantity, repeater rows), renders empty; uploads and signatures are left out entirely (their value is a storage path, which has no business in a URL). A canonical example: /members/subscribe?plan=monthly-updates&qty={task_count}&email={email}&name={name} — the members subscribe URL is built to accept exactly this hand-off.
A template with tags must be either a site-relative path starting with a single / or a full http(s):// URL with a fixed domain — judged the way a browser reads it, which deletes tabs and newlines, treats \ as /, and skips every extra slash after // or after the scheme before reading the host. So the editor rejects https://{host}/…, //{host}, ///{host}, /\{host} and the like: a tag may sit in the path, query, or fragment, never where it could name the domain. The submit path re-checks the template and the rendered result: if a blank answer collapses /{a}/{b} into //host, or a value forms a ./.. path segment, the redirect is refused, the confirmation panel shows instead, and a warning naming the form is logged. A template with no tags is passed through untouched, exactly as before. This works identically in inline mode and in frame mode (form-on-intent). See FormRedirectUrl.
Autoresponder
The Autoresponder email section sends a confirmation email (FormAutoresponderMail) to the submitter. The recipient comes from the configured email field (a picker appears when the form has more than one) falling back to the first email-type field holding a valid address — no valid address, no send. Subject and body support {field_key} tokens substituted from the submitted values plus {form_name}; unmatched tokens collapse to empty. Optional from name / from email / reply-to override the site mailer defaults. The autoresponder is held to the visitor mail caps: over a cap it is skipped and the submission is kept as usual.
On a multilingual site the autoresponder and the confirmation panel are written in the language the visitor is answered in — the one they wrote in, when the site publishes it — using the form's translations of the subject, body, heading and message (Forms → Auto Translate covers them). See Message Translation → The thank-you and the autoresponder.
Per-form webhook
The Webhook section POSTs a JSON payload (event, form, submission_id, fields, submitted_at) to a configured URL on every submission via the queued SendFormWebhookJob (3 attempts, backoff 60 s / 5 min). This works with zero setup and no feature modules. Separately, when the Webhooks feature is enabled, its engine already fires a signed form.submitted event for every archived submission (for a form with a payment field, when the payment completes — never for the pending row an abandoned checkout leaves) — and for forms with save_submissions off (where the engine's model-event hook can't fire), FormSubmitActions dispatches the event through the engine explicitly so subscribers still hear about it.
Marketing subscribe & CRM tags
With the Marketing feature on, the Subscribe submitter to marketing emails toggle adds the submitter to the marketing subscriber pool (source = form), respecting the site's double-opt-in setting (new subscribers are created pending and sent the confirmation email) — and it never re-subscribes someone who previously opted out.
Only subscribe people who tick (shown once the toggle is on) picks the form's checkbox that asks to join the list (actions.marketing.consent_field). With a box picked, only a submitter who ticks it is subscribed, and that box's label is kept as the wording of their consent; a picked box later removed from the form subscribes nobody, and the builder refuses to save a pick that isn't one of the form's checkboxes. Everyone who submits subscribes every submitter with an email address, as the toggle always did, and records no consent wording — never the label of an unrelated box such as a waiver. A form created from the Newsletter Signup template has its I agree to receive email updates box picked already, and the 2026-10-07 data migration picked that box (a checkbox keyed consent) on existing forms that had one and no choice of their own.
With CRM on, the existing "Add submitter to CRM contacts" switch gains a CRM tags input: comma-separated group names attached to the captured contact (groups are created on first use, resolved by slug so "Lead" never duplicates). Tags apply on both the live submit path and the post-payment capture.
Save & resume
The Settings tab's Let visitors save & resume toggle (off by default) adds a "Save & finish later" link under the public form. Clicking it opens a small panel where the visitor can:
- Save my progress — stores the current answers as a
FormDraftbehind an unguessable 40-character token and shows the private resume link (with a copy button). - Email me the link — sends the resume link to any address they type (
FormDraftResumeMail). If they haven't saved yet, the draft is saved first so the emailed link always works.
Opening the resume link (?form_resume={token} on the page hosting the form) restores their answers and — on multi-step forms — jumps back to the step they left off on. Once a draft exists, multi-step forms auto-save silently on every step change, so a visitor who saved on step 1 and typed through step 3 loses nothing.
Details worth knowing:
- Drafts keep text values, checkboxes, multi-selects, and repeater rows. File uploads and signatures can't be drafted (browser uploads don't survive a session).
- A successful submission deletes its draft immediately; abandoned drafts expire after 30 days and are swept daily by the
form-drafts:pruneLazyCron task. - Draft restoration happens via a browser-triggered call after page load — never during the cached page render — so draft data can never leak into the shared response cache.
- Saves and link emails are rate-limited per visitor (their browser session, never the IP — see No decision keys on the IP), and the link email is also held to the visitor mail caps: over a cap the visitor sees "Too many emails sent" and no email goes. Restored data is re-validated against the form's current field list (renamed/deleted fields are dropped silently).
- The panel notes that anyone with the link can open the saved answers — treat the resume link like the semi-secret it is.
Availability & entry limits
The Settings tab's Availability & limits section schedules when a form accepts submissions:
| Setting | Behaviour |
|---|---|
| Opens at | Before this date/time, visitors see a "not open yet" note instead of the form. |
| Closes at | From this date/time on, the form stops accepting submissions. |
| Maximum submissions | The form closes automatically once this many non-spam submissions have been archived. Because the cap counts archived rows, it needs "Save submissions to database" on — the editor warns about this. |
| Closed message | Optional custom text shown for the closed/full states (the "not yet open" state keeps its own default so a "no longer accepting" message never shows before launch). |
Enforcement is two-layered: the public page renders the closed panel instead of the form, and the submit handler re-checks server-side — so a page cached while the form was open (or left sitting in a tab) still can't slip a submission through after closing. In the page-editor preview the form always renders (with an amber "currently closed to visitors" note) so you can keep editing it.
Submission archive
Each form has a save_submissions boolean (default on). When on, every submission is persisted as a FormSubmission row with the field-keyed data array, the submitting IP address, and a timestamp. Dashboard → Forms → Submissions (⚡submissions.blade.php) lists them newest-first, with each field's label rendered alongside the submitted value. File uploads render as a download link. Each submission (inbox or Spam box) has a Delete button that, after a confirmation, permanently deletes it and its uploaded files from both disks — the way to honour a request to erase someone's data. The files are found from the submission's own data, so an upload through a field since removed or renamed goes too, and only a file under that form's own form-uploads/{form}/{field}/ folder is ever deleted. A contact record the submission created stays in the CRM.
Turning save_submissions off lets a form act purely as a notification trigger (the email is sent, nothing is persisted) — useful for contact forms when the inbox is the system of record and you don't want submission data living in two places.
Visitor uploads are private
Everything a visitor uploads — file fields (job-application résumés among them) and uploaded signature images — is stored on the private disk (storage/app/private/form-uploads/{form}/{field}/{random-name}), never under the web root. Older releases wrote these to the public disk, where anyone holding a file's /storage/... URL could open it with no login. VisitorUploads owns the trees and the only ways back out:
- Staff download route —
GET /dashboard/forms/{form}/upload?path=…(FormUploadController, Manager+ like the rest of the forms dashboard). It is addressed by path, not submission, because a form withsave_submissionsoff still stores its uploads and links them from the notification email. The path must belong to the form in the URL and sit insideform-uploads/. The submissions inbox, the owner notification email, the CSV/XLSX export and the staff PDF all link here; a recipient who isn't a dashboard user can no longer open a file from the email. - Responses are inert. The Content-Type comes from a closed extension list (anything else is
application/octet-stream),nosniffis always sent, and only raster images and PDFs open inline — an.htmlupload allowed by a field's accept list downloads, it never renders.Cache-Control: private, no-storekeeps a CDN from holding a copy. Bytes stream throughChunkedFileResponse(neverfpassthru, which the fleet's web PHP disables). - The visitor's own copies don't link. The autoresponder PDF shows "(file attached)" / "(signed)" instead of a staff link, and a
{file_field}token in the autoresponder renders empty. - Webhooks and the REST API still carry the stored relative path in
fields; it is no longer fetchable at/storage/{path}. - Nothing is written that nothing keeps. A honeypot hit stores no upload, and spam on a form with
save_submissionsoff stores none either; spam held in the Spam box keeps exactly one copy, which its row points at. Deleting a form deletes itsform-uploads/{form}/tree from both disks, along with the submissions the delete cascades away. A form with saving off still stores the uploads of real submissions, because its notification email links them. - Backups include them. Snapshots capture the private trees as their own
form-uploads/andcontract-signatures/subdirs (see backups-and-revisions.md), so a restore brings back the files along with the submissions that reference them.
The move for existing files is done by the migration in the update that made uploads private, and finished by the hourly uploads:privatize LazyCron task (VisitorUploadPrivatizer; php artisan uploads:privatize [--dry-run] by hand). The relative path is identical on both disks, so no stored submission is rewritten; every reader checks the private disk first and the public disk second, so a link keeps working whether or not its file has moved yet. Same-filesystem moves are one rename() each; across filesystems it copies one file at a time to a dotfile temp, verifies it, and only then removes the public copy, after checking there's room. Links in notification emails sent before the update pointed at the old public URLs and stop working once the file moves — open the submission in the dashboard instead.
Managing submissions (status, tags, notes, inline edit)
Every archived submission carries an inbox-style status — new → read → replied → closed — changeable from a dropdown on the submission card, plus a status filter above the list so you can work the queue ("show me everything I haven't replied to"). New submissions always arrive as new.
The pencil button on each card opens a view & edit modal:
- Text answers are editable — fix a typo'd phone number or email without asking the visitor to resubmit. Uploads, signatures, and structured values (addresses, repeater rows, checkbox groups) stay as submitted; the modal can never add new keys.
- Tags — free-form comma-separated labels (e.g.
VIP, Follow up) shown as badges on the card and included in exports. - Admin notes — an internal notepad per submission, never shown to the visitor. Cards with notes show a small speech-bubble icon.
On a site with more than one language, a Language filter sits next to the status filter. It matches the language recorded when the visitor submitted (detected from their own words, else the page's language): a detected variant counts toward its site language (es-MX → Spanish), and older submissions with no recorded language only show under All languages. The same per-language lead counts appear on the dashboard home, the Analytics Overview and the weekly digest / client report (Analytics → What a second language brings in).
Replying to a submission (Unified Inbox)
The Forms page is the archive; replying happens in the Unified Inbox, where submissions appear as their own channel alongside tickets, chats and SMS. From there you can email the submitter (the reply is recorded and the status flips to replied) or open a ticket from the submission, which turns a one-shot message into a real two-way thread with the answers attached and the sender pre-filled.
The submitter is resolved from the form's own field definitions — FormSubmission::submitterEmail() takes the first email-type field, submitterName() reads name / full_name / first_name+last_name. A form with no email field simply can't be replied to, and the inbox says so rather than failing silently.
Exporting submissions (CSV / Excel)
The Export button at the top of the Submissions page downloads the current view — the box you're looking at (Inbox or Spam) and the active status and language filters all carry over, so what you see is what you export. A language filter adds its code to the filename. Two formats:
- CSV — streamed with a UTF-8 BOM so Excel opens it correctly on double-click.
- Excel (.xlsx) — a real workbook, generated dependency-free by
FormSubmissionExporter.
Columns are: submitted-at, status, tags, one column per form field (in field order, using the field labels as headers), IP address, and admin notes. File answers export as their download URL; signatures as the typed name or a (signed) marker. CSV cells are hardened against spreadsheet formula injection (a leading =, +, -, or @ gets a ' prefix); xlsx cells are typed text and are safe by construction.
Submission PDFs
Any archived submission can be downloaded as a PDF from the card's download button — a clean letter-format document with the form name, timestamp, submission number, and every answer in a label/value table (FormSubmissionPdf, rendered with dompdf).
The Settings tab's PDF copies section can also attach that PDF to the emails automatically:
- Attach to the notification email — the owner notification gets the PDF (works even when
save_submissionsis off, and for paid forms the PDF rides the deferred post-payment notification). - Attach to the autoresponder — the visitor's confirmation email gets the same PDF. Requires the autoresponder to be on.
PDF rendering failures never block the email — the message simply goes out without the attachment.
Chinese, Korean and Japanese answers print correctly once the install has those fonts — an opt-in download that comes with turning one of those languages on (see Chinese, Korean and Japanese in PDFs); without them those characters print as boxes.
Form funnel (requires the Analytics feature)
With the Analytics feature on, every public form reports three anonymous counters via a tiny sendBeacon call from form-flow.js: a view when the form renders, a start on the first interaction, and — on multi-step forms — each step reached. The Submissions page turns them into a funnel panel: views → starts (with start rate) → submissions (with completion rate), plus a per-step drop-off table showing where multi-step visitors abandon.
Privacy posture matches the rest of the built-in analytics: rollup counters only (no per-visitor rows), Do-Not-Track / Global Privacy Control visitors are never counted (client-side bail and server-side header check), manager-and-above staff testing a form are skipped, and the collection endpoint (FormEventController) is rate-limited and only accepts events for forms that actually exist. Completions come from the existing conversions table, so the funnel agrees with the conversion/UTM source breakdown below it. When Analytics is off, nothing is collected and the panel doesn't render.
Privacy & data retention (GDPR)
The Settings tab's Privacy & data retention section holds two per-form data-governance controls:
- Auto-delete submissions after N days — a daily
form-submissions:pruneLazyCron task deletes archived submissions older than the window, including any files the visitor uploaded (file fields and uploaded signatures). Leave empty to keep submissions forever. Spam-quarantined rows age out on the same clock. A submission whose payment may still land is skipped — a bank payment still clearing, or an unpaid one whose checkout was opened in the last day — so money never arrives with nothing to record it against; an unpaid submission abandoned before that ages out like any other. - Encrypt submissions at rest — new submissions' answers are stored encrypted with the site's
APP_KEY(EncryptedFormData) and decrypted transparently everywhere they're read (dashboard, exports, PDFs, the payment pipeline). Rows written before the toggle stay readable as-is. Two trade-offs to know: encrypted submissions can't be matched by dashboard search, and the unique-value field validation can't see them.
Spam protection
Every form has an effectiveSpamProtection() method that resolves the active protection method. The per-form spam_protection column overrides the site-wide default (spam.default_protection, settable on Dashboard → Settings → General → Spam Protection). The six options (in SpamProtection):
| Method | Description |
|---|---|
shield |
In-house multi-signal: signed challenge, proof-of-work, behavioral telemetry, browser fingerprint. The default. |
honeypot |
Hidden field plus rate limiting (5 submissions per visitor per 10 minutes). |
recaptcha |
Google reCAPTCHA v3, score-based, invisible. Requires API keys configured on Settings → API Keys. |
turnstile |
Cloudflare Turnstile, invisible challenge. Requires API keys. |
akismet |
Akismet content classification (the WordPress-ecosystem spam service). Requires an API key. |
none |
No protection. Not recommended for public forms. |
The per-form override on the edit page is hidden inside an "Advanced" accordion — the message states explicitly that most forms should leave it on the site default. Picking reCAPTCHA, Turnstile, or Akismet when the API keys aren't configured shows the option as disabled with a hint to configure them on the API Keys page.
Akismet
With akismet selected, AkismetClient sends each submission to Akismet's comment-check API — the submitter's name and email (auto-detected from the form's field types) plus everything they typed as the comment content, tagged comment_type: contact-form. A "spam" verdict quarantines the submission exactly like a failed Shield signal (reason akismet); the visitor still sees the normal success state. The client fails open on every problem — missing or malformed key, network error, unexpected response — so an Akismet outage can never block a legitimate visitor. The API key is saved on Dashboard → Settings → API Keys → Spam Protection Keys (one key works across all your sites; get one at akismet.com).
Spam hardening (runs on top of any method)
Beyond the protection method, each form has opt-in hardening checks (form edit page → Settings → Spam protection → Additional hardening), stored in the spam_settings JSON column (normalized by Form::spamSettings()) and enforced by SubmissionGuard. They run for every protection method — including none:
- Limit submissions per visitor — a per-visitor (browser session) throttle: at most N submissions per window (default 5 per 10 minutes). Over-limit visitors get a visible "too many submissions" error. Runs before the protection method, so a throttled visitor never burns a single-use Shield token or CAPTCHA pass. Independent of the Honeypot method's built-in 5-per-10-minutes limiter, which keeps its historical behavior.
- Blocked keywords / blocked email domains — comma- or line-separated lists. A submission containing a blocked keyword anywhere (case-insensitive), or an email address at a blocked domain (subdomains included), is quarantined to the Spam box with reason
blocklist_keyword/blocklist_domain— the visitor sees the normal success state so bots learn nothing. Leave both lists empty to disable. - Only accept submissions from certain countries — the origin-country / service-area filter (see Origin-country filter below). A submission from outside the allowed countries is quarantined with reason
geo_blocked. Off by default per form; inherits the site-wide service-area default when the form doesn't set its own. - Block duplicate submissions — rejects an identical re-post (same form, same visitor, same input) inside the window (default 60 minutes) with a visible "already submitted" message. The fingerprint check is atomic, so even a parallel double-post can't create two rows.
The content checks evaluate the visitor's raw bound input rather than the built submission payload, so a rejected attempt never stores file uploads as a side effect.
No decision keys on the IP
Behind Cloudflare without trusted proxies, the IP address the app sees is the Cloudflare edge's. Thousands of visitors share it, and one visitor's next request can leave from a different edge. So nothing on the public form path decides on the IP. Every per-visitor limit, the dedupe fingerprint and the Shield token bind key on the visitor's browser session instead, through ClientKey (an HMAC of the session id under the app key, never the id itself). Every Livewire submit carries the session cookie, because the CSRF check needs it.
A session costs a script nothing, so a per-visitor limit stops a person double-posting, not an attacker. Whatever has to hold against a script that drops its cookie is bounded by something that isn't per visitor: the visitor mail caps, and Shield's per-form flood breaker (signal 5 below). The IP is still recorded on the submission and passed to Akismet (user_ip) and reCAPTCHA / Turnstile (remoteip), as a signal for them only.
Visitor mail caps
A form's autoresponder and its Save & Resume link email go to an address the visitor typed, so without a cap the form is a mail relay. VisitorMailCap holds every such send to two caps that don't key on the visitor:
- Per address — 3 an hour to one address, across every form (
cms.spam.visitor_mail.per_recipient_per_hour, envCMS_VISITOR_MAIL_PER_RECIPIENT). Stops mail-bombing one inbox. Case and surrounding spaces don't make a new address. - Per form — 100 an hour from one form (
per_source_per_hour, envCMS_VISITOR_MAIL_PER_SOURCE). Stops spraying many addresses.
Over a cap the email is skipped and the submission is kept; each skip is logged to the spam channel as visitor_mail_capped. The Tickets support form's confirmation email uses the same caps.
How Spam Shield works
shield is the in-house, no-third-party-call default. It runs a sequence of independent checks per submission, and any one of them failing quarantines the submission (see "Spam quarantine" below). The implementation is split between ChallengeService (token issue/verify/consume, PoW verify), SpamScorer (sequential signal evaluator — failureReason() returns null on a clean pass or the failing signal's name), SpamShieldController (HTTP endpoint that hands out fresh challenges), and resources/js/spam-shield.js (the client that gathers signals and solves the PoW).
Two ordering rules protect legitimate visitors from losing messages:
- Field validation runs before the Shield check (in
ContactForm::submit). A visitor who misses a required field gets normal validation errors without their challenge token ever being evaluated. - The token is consumed only after every other signal passes. A submission that fails a later signal — or a retry after a validation error — keeps its token, so the corrected resubmit works without refetching or re-solving the PoW. Consumption is the last step and is atomic (
Cache::add), so two parallel replays of the same otherwise-valid payload can never both pass.
1. Signed, single-use challenge token
Every submission must carry a token issued for that specific form. The token is a stateless JWS-style string (base64url(json) . base64url(hmac_sha256(json, app.key))) whose payload binds it to the requester:
| Claim | Purpose |
|---|---|
fid |
Form id — verifier rejects if it doesn't match the form being submitted. |
iat / exp |
Issued-at + expiry. Default lifetime 30 min (cms.spam.shield.token_ttl_minutes). Tokens issued more than 60 s in the future are rejected (clock skew defense). |
sid_hash |
The visitor's session key (ClientKey), truncated to 32 hex chars. Verifier recomputes from the submitting request and hash_equals, so a token farmed in one session fails in another. Not the IP: behind a CDN the token fetch and the submit can leave from different edge addresses. A token issued before the session bind carries ip_hash instead and is accepted unbound until it expires (at most token_ttl_minutes after the update). The login challenge's tokens bind no session. |
ua_hash |
Same construction over the User-Agent. |
pow_n, pow_d |
Per-token PoW nonce (16 random bytes hex) and difficulty (default 18 leading zero bits). |
jti |
16 random bytes. ChallengeService::consume() does an atomic Cache::add on spam:shield:consumed:{jti} for the token's remaining TTL — false means the token was already spent. A token is single-use on success, but verify() alone never consumes it, so failed attempts and validation retries don't burn it. |
The signature is HMAC-SHA256 over the raw JSON, keyed on app.key. There is no token row in any database table — verification is pure cache + crypto.
Why the token is fetched from JS, not baked into the form HTML. Public pages are aggressively response-cached. If the token were rendered server-side into the form, every visitor would receive the same one and a bot could harvest one cached page and replay its token forever. Instead, the form HTML ships with no token; spam-shield.js calls POST /_fc/challenge lazily on the visitor's first interaction (mousemove / keydown / touchstart / focusin / scroll) and only then receives a fresh, session- and UA-bound token. That endpoint is itself gated — it 404s for any form_id whose effectiveSpamProtection() isn't Shield, so it can't be used as a generic challenge oracle.
The endpoint path is deliberately non-descriptive (_fc = form challenge): ad-blocker URL filters match substrings like "spam", and a blocked challenge fetch would silently break legitimate submissions. The client is also resilient to fetch failures — a failed challenge request clears the memoized promise so the next interaction or the submit itself retries, and a token within a minute of expiry (a tab left open on a half-filled form) is transparently refetched at submit time. The response includes expires_in so the client can make that staleness call without parsing the token.
2. Proof-of-work
The token also dictates the PoW the client must solve before submitting: find a solution such that SHA-256(pow_n + ":" + solution) has at least pow_d leading zero bits. The default difficulty of 18 bits is roughly a quarter-million SHA-256 hashes — a second or two inside a browser worker, with high variance (the search is exponential). The client starts the search on first interaction, so it's usually finished before the visitor reaches the submit button; when it isn't, the submit button shows a "Verifying…" state while solve() awaits the worker. Be honest about what PoW buys: a native attacker solves 18 bits in milliseconds, so its value is imposing cost at scale (thousands of submissions per minute), not blocking a single determined bot — the other signals carry that load. The client runs the search inside a Web Worker so it doesn't jank the main thread, yielding to the event loop every 5,000 attempts.
The server reverifies in ChallengeService::verifyPow: it walks the binary SHA-256 byte by byte, counts leading zeros, and rejects unless the count is ≥ pow_d. The solution is also length- and charset-validated (ctype_alnum, ≤32 chars) before it's hashed, so a malicious client can't smuggle anything weird into the digest input.
3. Behavioral telemetry
The client tracks raw interaction signals from the moment the form mounts (t_load) until submit (t_submit):
- Time-on-page floor. Submissions faster than
cms.spam.shield.min_time_on_page_ms(default 1500 ms) fail. This is enforced server-side first: the token'siatis stamped when the visitor's first interaction triggers the challenge fetch, sonow - iatis a server-verified lower bound that a bot can't forge by lying about its clock. The client-claimedt_submit - t_loadis checked too, as a secondary signal. - Interaction floor.
mouse_moves + key_events + scroll_eventsmust be at leastcms.spam.shield.min_interactions(default 5). Headless browsers that bypass the worker by hand-crafting a payload trip this — they typically have 0 interactions.
The telemetry object is deliberately minimal — only the fields the server actually scores are collected and sent.
4. Browser fingerprint
Submission also includes a fingerprint object the server uses for two checks:
webdriverflag. Ifnavigator.webdriver === true(the standard automation tell), reject. This catches default-config Selenium / Playwright / Puppeteer.- UA round-trip. The UA reported by the client must match the UA on the actual submit request. If a bot fakes the UA in one but not the other, reject.
That's the whole fingerprint — deliberately. An earlier iteration also collected canvas-render hashes, OfflineAudioContext hashes, the WebGL renderer string, timezone, screen metrics, plugin count, and languages "for forensics", but none of it was ever scored or persisted server-side. Canvas/audio fingerprinting is exactly the category of tracking the cookie-consent feature exists to disclose (and Safari deliberately noises it), so collecting it for zero signal was pure privacy cost. If a future heuristic needs a signal, add it to both the client and the scorer at the same time.
5. Per-visitor and per-form circuit breakers
Cache-backed counters sit around the signals above. None keys on the IP (why):
global_attempts_per_hour(default 50), per visitor (browser session). Above the cap, every submission from that visitor rejects (global_rate) without even trying to verify the token.failure_ratio_threshold(default 0.8), per visitor. Once a visitor has ≥5 attempts in the window, if ≥ 80% of them failed, every later attempt rejects (failure_ratio) — even one that passes every signal. A bot that keeps its session is shadowbanned for the rest of the hour, and nobody else is affected.form_attempts_per_hour(default 200), per form across all visitors. A bot that brings a new session to every attempt never trips a per-visitor breaker; this one quarantines the rest of the hour's submissions on a flooded form (form_flood). Only an attempt carrying a valid token for the form and a solved proof-of-work counts, and each token only once, so junk, tokens from another session, and a re-sent token can't trip it for everyone.
The failure ratio is deliberately not form-wide: a steady trickle of headless-bot failures would hold a form's ratio high and quarantine every real lead on it.
The windows are fixed, not rolling: each opens at a key's first attempt and resets an hour later. The counters live in Cache::store() (Laravel default) — no database writes on the hot path. Each counter key is seeded with Cache::add($key, 0, 3600) before Cache::increment() — a bare increment on a missing key would create it with no expiry, silently turning the hourly cap into a permanent block. Don't "simplify" that pair away.
The challenge endpoint (POST /_fc/challenge) is limited to 30 tokens a minute per visitor session, for every form. A client that never brings its session cookie back would get a fresh session (and a fresh limit) per request, so those share one site-wide 30 a minute. No real visitor lands there: a form's page or frame sets the cookie before the first challenge, and without it the submit fails CSRF anyway.
6. Spam quarantine
A submission that fails any Shield signal — or any content, geo, or blocklist check — is not silently discarded. When the form keeps a submission archive (save_submissions), the rejected submission is stored flagged (is_spam = true) with the failing signal recorded in spam_reason — notification email, CRM capture, and conversion tracking are all skipped, and the visitor still sees the normal success state so bots learn nothing. The dashboard's per-form Submissions page grows an Inbox / Spam toggle (the Spam tab only appears once something has been caught); each quarantined entry shows its reason as a badge and a Not spam button that clears the flag and returns it to the inbox. False positives are therefore reviewable and recoverable instead of lost, and the reasons double as tuning data for the thresholds. Forms with save_submissions off keep the old fire-and-forget behavior.
An inbox submission also gets a Mark as spam button (markSpam) — it moves the entry to the Spam box with reason manual and, more importantly, creates a human-verified spam label. That label is the training signal for the self-learning classifier (below) and, on opted-in installs, the only thing reported to the shared-intelligence network. The Submitted from page and the country flag shown on each entry give the reviewer the context to make that call quickly.
7. Logging
Every failure is logged to the spam channel with the failure reason (Shield: malformed_payload, global_rate, token_invalid, pow_invalid, form_flood, too_fast, webdriver, ua_mismatch, low_interaction, failure_ratio, token_replayed; content layer: content_scam_phrase, content_bayes, content_feed, content_non_latin, content_gibberish, content_links, content_excessive_caps; hardening: blocklist_keyword, blocklist_domain, geo_blocked; plus akismet and manual), the form id, the IP hashed under the install's key (SpamIpHash, so entries correlate without retaining a recoverable address; nothing decides on it), and the first 16 characters of the visitor's session key. The spam log channel is separate from the main app log so a bot storm doesn't bury legitimate errors. Successful submissions log nothing.
8. Bundle gating
spam-shield.js is ~5 KB compressed but adds three event listeners to window and a Web Worker on first interaction. Public pages don't ship it unless they need to: ShieldUsage::isInUse() returns true only when spam.default_protection === 'shield' or at least one form in the database has spam_protection = 'shield'. The result is cached for 60 s and flushed by the Form model's save/delete hooks and by the API Keys settings page on save, so toggling protection methods takes effect immediately without a manual cache clear.
9. Tunables
All Shield knobs live in config/cms.php under cms.spam.shield:
| Key | Default | Effect |
|---|---|---|
pow_difficulty |
18 | Leading zero bits required. +1 doubles client work. |
token_ttl_minutes |
30 | Challenge lifetime. |
min_time_on_page_ms |
1500 | Time-on-page floor (server-anchored against token iat, plus client telemetry). |
min_interactions |
5 | mousemove + keydown + scroll floor. |
global_attempts_per_hour |
50 | Per-visitor hard cap. |
failure_ratio_threshold |
0.8 | Per-visitor failure ratio at which the circuit breaker opens (after ≥5 attempts). |
form_attempts_per_hour |
200 | Per-form flood cap across all visitors (env CMS_SPAM_FORM_ATTEMPTS_PER_HOUR). |
Trade-offs vs. Turnstile / reCAPTCHA
Shield runs entirely on first-party infrastructure — no third-party requests, no privacy disclosure, no API keys to provision, no failure mode where a third-party outage takes down every form on the site. The cost is that the signal mix is fixed at what we ship; it doesn't have access to Cloudflare's or Google's cross-site reputation data, so the most sophisticated targeted bots (the kind that solve CAPTCHAs through paid human-solver APIs) will get past it more easily than they would past Turnstile. For the contact / job / quote forms most WebProCMS sites run, the bulk-spam economics — a per-submission PoW × thousands of attempts × 80% failure-ratio shadowban — are decisive, and anything that does slip through (or get wrongly caught) lands in the reviewable spam quarantine rather than the inbox or the void. For a form that's a known high-value target for sophisticated abuse, Turnstile remains a one-click upgrade.
The content layer (in-house Akismet alternative)
Shield's signals answer "is this a real human in a real browser?" — bot detection, the same question Turnstile answers. But a lot of real spam is typed by an actual person (or a bot sophisticated enough to fake human behavior): the "our family is relocating to your area and needs a realtor" template, SEO/web-dev outreach, crypto pitches. Those sail past every bot signal because the sender is human. Catching them needs content analysis — the one thing Akismet does that Shield historically didn't.
ContentScorer is that layer, and it runs in-house — no paid API. It evaluates the submitted text for every protection method except an explicit none (opting out of protection opts out of the classifier too), inside the same hardening pass as the blocklist. Each signal contributes a weight; when the total reaches the threshold (default 1.0) the submission is quarantined to the Spam box under a content_* reason. It's a scoring layer, so it composes: three lighter signals can combine to cross the bar, or one strong signal (a scam-template match) crosses it alone. Everything quarantines — never a silent drop — so a false positive on a genuine lead is one Not spam click away.
Three tiers stack inside it, from shipped-defaults to fleet-wide learning:
-
Heuristics + a seeded scam-template library. Weighted signals that work on day one: a curated library of long, full-clause scam-template fragments (the realtor relocation template, SEO/crypto/bulk openers — deliberately long clauses, because short generic words like "buy a house" are exactly what a real lead writes), heavy non-Latin script on a Latin-script site (a script one of the site's own languages is written in never counts — a site publishing Korean or Russian receives it — nor, while Message Translation is on, the team's language), gibberish tokens (runs of 5+ consonants), link-stuffing, and shouting (mostly-uppercase). Reasons:
content_scam_phrase,content_non_latin,content_gibberish,content_links,content_excessive_caps. -
A self-learning Bayesian classifier (
BayesSpamModel, reasoncontent_bayes). A naive-Bayes filter trained on this install's own corpus — spam = everything flagged (auto-quarantined or human-marked), ham = everything kept — using Robinson-smoothed per-token spamminess combined over the most informative tokens. Retrained nightly by thespam:train-classifiercommand (a 4 a.m. LazyCron), cached as a compact token model. It stays dormant until there are ≥20 of both classes (cms.spam.shield.content.bayes.min_docs), so a fresh install never guesses from a thin corpus — it just runs the heuristics until it has learned enough. This is what adapts to the specific spam a particular site receives: flag a few of a recurring template with Mark as spam, and by the next night the classifier catches the rest. -
A cross-install shared-intelligence feed (
SharedSpamIntel, reasoncontent_feed) — the true Akismet analogue, because a bulk campaign hits many sites, so cross-site corroboration catches it on the first hit anywhere. It has two faces:- Reporting (client → mothership). When an admin clicks Mark as spam (a human-verified label only — auto-quarantines are never reported, so the shared corpus can't amplify its own false positives), the install computes a privacy-preserving fingerprint via
SpamFingerprint: hashed word-shingles of the message plus a salted one-way hash of the email. No message text, email address, or IP address ever leaves the site — only hashes, salted with a shipped pepper so the same campaign fingerprints identically everywhere. The sender's IP is not in the fingerprint at all (it used to be): with the pepper public, a hash of an IPv4 address is reversed by trying all 2^32 of them, and the mothership can't tell an IPv4 hash from an IPv6 one to drop only those. The mothership ignores aniphash an older install still sends, and the update that made this change purged the ones it held. A queuedReportSpamFingerprintJobPOSTs it to the mothership. Sharing is on by default for licensed installs, opt-out at Settings → API Keys (spam.share_intelligence); consuming the feed is always on. - Aggregation + distribution (mothership).
SpamReportControllerrecords each fingerprint against the reporting install (deduplicated per install). A fingerprint enters the feed only once it's been corroborated across ≥cms.spam.intel.min_installsdistinct installs (default 3) — the guard that stops one site from poisoning the fleet. The corroborated set is packed into a compactBloomFilterand shipped back inside the existing Ed25519-signed license-check response (the same signed channel and signature as the CMSupdateblock — a relay can't swap in a poisoned filter while keeping a valid verdict; clients ingest the feed only from the signed claims). Each install tests incoming submissions against the filter, requiring content corroboration (≥2 shingle hits, or an email-hash hit plus a shingle) so a Bloom false positive can never fire on its own.
- Reporting (client → mothership). When an admin clicks Mark as spam (a human-verified label only — auto-quarantines are never reported, so the shared corpus can't amplify its own false positives), the install computes a privacy-preserving fingerprint via
Config lives in config/cms.php: cms.spam.shield.content.* (the content layer — enabled, threshold, flag_non_latin, and the bayes.* sub-block) and cms.spam.intel.* (the shared feed — enabled, fingerprint_salt, min_installs, shingle_size, match_threshold). fingerprint_salt is a shipped pepper that must stay stable across versions — changing it invalidates every fingerprint fleet-wide.
Origin-country / service-area filter
For a local business — a realtor, a plumber, a regional law firm — a submission's origin country is often a stronger spam signal than anything in its content: nobody in Nairobi or Dubai is a genuine lead for a single-city US realtor. The service-area filter turns that into a control. A submission from outside the allowed countries is quarantined with reason geo_blocked, and (like everything else) it lands in the reviewable Spam box, never dropped.
How the country is resolved — GeoLocator, working with or without a CDN:
-
CDN geo header (Cloudflare
CF-IPCountry, CloudFront, Vercel) viaVisitorRegion— instant, free, no lookup. Used automatically when the install is behind a CDN. -
A local IP-to-country database (
.mmdb, read withmaxmind-db/reader) as the fallback when there's no CDN header — no external call and no rate limit. This matters specifically because many client sites share one server IP; a rate-limited geolocation API would let one busy site exhaust the quota for every site on the box, so the fallback is a local database instead. It's ~8 MB, downloaded intostorage/, never committed or shipped in the release. Two providers: DB-IP IP-to-Country Lite (the default — free, no account or key, CC BY 4.0) or MaxMind GeoLite2 (--provider=maxmind, needs a freeMAXMIND_LICENSE_KEY). Until a database is present, geo detection simply falls through.When it installs — automatically, no manual step. The moment an admin turns geo filtering on (the setup wizard's single-country answer, the General-settings service-area control, or a per-form filter),
GeoLocator::maybeInstallLocalDatabase()checks the request: if it already carries a CDN geo header the local DB is never needed (no-op); otherwise it dispatchesInstallGeoDatabaseJobafter the response to download it in the background. So enabling the filter on a non-CDN site provisions the database on its own — this is the "install it if Cloudflare isn't detected" step. A weeklygeo:install --if-installedLazyCron keeps it current afterward (a no-op on installs that never installed one). Thegeo:installcommand remains available to force or pre-seed an install manually.
When neither source can resolve a country, the filter fails open — it never blocks everyone just because detection is unavailable. The resolved country is stored on every submission (country column) and shown as a flag chip on the Submissions page for triage, regardless of whether filtering is on.
How the policy is set — site-wide default with per-form override:
- Site-wide "service area" (
spam.geo_countries) is the primary control, editable at Settings → General → Spam Protection → Service area. Every form inherits it. - The setup wizard asks "Where are your customers?" in the business-basics step. Answering "just one country" sets the service-area default to that country (non-domestic submissions quarantined by default — the sensible default for a local business); "multiple countries" leaves it off. So a US-only business is protected out of the box, an international one isn't, and either can change it later in Settings.
- A per-form override (the Only accept submissions from certain countries hardening control) resolves via
Form::geoAllowedCountries(): a form with its own enabled country list overrides the site default; an enabled form filter with an empty list is an explicit opt-out (accept anywhere, e.g. a careers form on an otherwise US-only site); a form that leaves the filter off inherits the site-wide default.
Config: cms.spam.geo.* (provider — dbip/maxmind, db_path, maxmind_license_key, maxmind_edition).
Submit button text
By default the submit button reads the form type's built-in label ("Send Message", "Request Quote", "Register" — FormType::submitLabel()). Submit button text in the form builder's settings sidebar replaces it with the owner's own words ("Send My Link"), up to 80 characters (Form::SUBMIT_LABEL_MAX). Empty means "use the type's label", so every form saved before the setting existed renders unchanged.
It is stored at actions.submit.label beside the rest of the visitor-facing copy, so the builder's save, form import/export and translation carry it with no column of its own; Form::submitActions() trims it and cuts anything longer than the limit (an imported or hand-written value never renders as an overlong button). Form::submitButtonLabel() is the one reader: the owner's text, translated for a visitor on another language's page when a usable translation exists, else the owner's base-language text, else the type's label (Form::defaultSubmitLabel(), which the builder's preview and placeholder use too). The base value decides whether there is a custom label at all — clear it and a translation left over from before is never shown — and a translation is held to the same 80-character cap. The key (actions.submit.label, listed in Form::FORM_COPY) is offered for translation only once it is set, and is translated with the rest of the form by Forms → Auto Translate — it is read before anyone submits, so it is deliberately not part of the visitor copy that FormVisitorCopy fills on demand at submit time. The page editor's form panel does not edit it and never writes actions, so saving there keeps it.
Submit button style
Each form stores a submit_button_style token (one of the global button utility classes — btn-primary, btn-secondary, btn-tone, btn-outline, btn-ghost, btn-inverted, btn-outline-inverted). A stored btn-outline-white (the pre-1.1.2 spelling) still renders via the permanent CSS alias. Empty means "use the default submit class". This lets the editor match a form's submit button to the surrounding section without writing CSS.
Dropping a form into a page
A form is rendered by reference, not by copy. Inserting a form into a page records a form_id content override on that page slug; the public render reads the override, looks up the form by id, and renders the live field list. Editing the form afterwards updates every page that uses it.
The Forms listing surfaces this — the "Used on" column queries content_overrides for key = 'form_id' and shows every page slug that points at this form. Use it before deleting a form to confirm nothing depends on it.
Where it lives in the dashboard
| Page | Purpose |
|---|---|
| Dashboard → Forms | Listing — name, type badge, notification email + text number, submission count, save-to-DB indicator, "Used on" page list, edit / view-submissions / delete actions. |
| Dashboard → Forms → New Form | Picks a starter type, captures name + notification email + notification text + spam-protection override, then redirects to the field editor. |
| Dashboard → Forms → Edit | Full-page editor (uses layouts.editor) — drag-to-reorder field list on the left, selected field's settings panel on the right, live preview at multiple breakpoints in the middle. |
| Dashboard → Forms → Submissions | Per-form submissions list with timestamps, IP addresses, the page it was submitted from, the sender's country (flag chip), and labelled values. Has an Inbox / Spam toggle when anything has been quarantined; spam entries show their failure-reason badge and a Not spam restore button, and inbox entries have a Mark as spam button that both quarantines and trains the classifier. |
The edit page caches the field list in the form's fields JSON column, re-keys any blank or duplicate keys on save (so submissions never land under a colliding key), and clears the response cache + spam shield usage cache on every save and delete via the model's booted hooks.
What lives where
| Path | Purpose |
|---|---|
app/Models/Form.php |
The form model — fields JSON cast, FormType / SpamProtection enum casts, submissions() relationship, effectiveSpamProtection() resolver, response-cache flush hooks. |
app/Models/FormSubmission.php |
Submission record — form FK, data JSON, IP address, spam quarantine flag + reason, timestamps. |
app/Models/FormDraft.php |
Save & Resume draft — token-keyed progress snapshot with a 30-day expiry, pruned by form-drafts:prune. |
app/Mail/FormDraftResumeMail.php |
The "resume your saved form" link email. |
app/Enums/FormType.php |
Form types — Contact, Job Application, Photo Contest. |
app/Enums/SpamProtection.php |
Spam protection options. |
app/Support/Forms/FormTemplates.php |
Starter field lists per type and the baseField factory the editor uses to construct new fields. |
app/Support/Forms/FormLogic.php |
Conditional-logic evaluator (server side of the show/hide rules). |
resources/js/form-flow.js |
Client twin of FormLogic + multi-step navigation (Alpine component, registered in public.js). |
app/Features/PaymentLinks/Support/FormPaymentCheckoutStarter.php |
Builds the embedded Stripe checkout session for a pending paid submission. |
app/Features/PaymentLinks/Support/FormPaymentRecorder.php |
Atomic paid-claim + deferred owner-email/CRM/conversion pipeline (webhook + return page). |
app/Livewire/ContactForm.php |
The public-facing render + submit handler — validates, applies spam protection, persists submission, sends notification mail, applies the confirmation behavior. |
app/Support/Forms/FormSubmitActions.php |
Submit-time action runners — autoresponder, marketing subscribe, webhooks, CRM tags — shared by the live and post-payment paths. |
app/Jobs/SendFormWebhookJob.php |
Queued JSON POST for the per-form webhook URL. |
app/Mail/FormSubmissionMail.php |
The notification email mailable. |
app/Mail/FormAutoresponderMail.php |
The submitter-facing autoresponder mailable with {field_key} token substitution. |
app/Rules/CommaSeparatedEmails.php |
Validates the multi-recipient notification email field. |
app/Rules/CommaSeparatedPhones.php |
Validates the multi-recipient notification text field. |
app/Support/Forms/FormSmsNotifier.php |
Builds and sends the submission text through the shared Twilio gateway. |
resources/views/pages/dashboard/forms/⚡index.blade.php |
Dashboard listing. |
resources/views/pages/dashboard/forms/⚡create.blade.php |
New-form page. |
resources/views/pages/dashboard/forms/⚡edit.blade.php |
Field editor with live preview. |
resources/views/pages/dashboard/forms/⚡submissions.blade.php |
Per-form submission viewer. |
Marketing angle
Build a contact form, a job application, or a contest entry without a third-party service, without a developer, and without leaking your prospects' data to whoever ran the last "free form builder" company. Submissions live in your own database, the notification email goes to whoever you list, and the public form inherits your site's typography and button style automatically. Spam protection is built in — there's no separate captcha plugin to configure unless you specifically want reCAPTCHA or Turnstile.
Notes
- Forms cache like every other entity. Saving a form clears the response cache via the model's
bootedhook, so changes go live without a manual cache flush. Form::is_seededflags demo forms created by the demo data seeder so they can be cleaned up without touching real ones.- File uploads inside form submissions are stored on the private disk; the submission viewer links them through the staff-only download route (see "Visitor uploads are private").