Skip to main content

Documentation

No results found.
Features

Forms and Submissions

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...

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. Submits {quantity, unit_price, total}; the line total is usable as a calculation token and a logic source. 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:

  1. Industry — a dropdown (electrician, plumber, lawyer, accountant, plus "Other / not listed").
  2. Categories — only that industry's, e.g. Residential / Commercial / Industrial / Other & specialty. Nothing below appears yet.
  3. 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 — residential exists 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 name field the full name splits across the first/last parts. Cached-page safe: GuestOnlyCacheProfile never 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; contains is a substring match, starts_with / ends_with are 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.
  • calculation and price_quantity fields 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.

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).

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, and the analytics conversion. The submissions inbox shows a green "Paid" badge or an amber "Payment pending" badge on rows with a payment amount.

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 generic cursive fallback, 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 public disk alongside file-field uploads.

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; 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 / an 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.

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. Validated at save time.

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.

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 — 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.

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 FormDraft behind 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:prune LazyCron 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 IP, and 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.

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.

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.

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 filter both carry over, so what you see is what you export. 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_submissions is 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.

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:prune LazyCron 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.
  • 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 IP 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-IP 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 IP, 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.

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).
ip_hash HMAC-SHA256 of the issuing IP, keyed on app.key, truncated to 32 hex chars. Verifier recomputes from the submitting request and hash_equals.
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, IP- 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's iat is stamped when the visitor's first interaction triggers the challenge fetch, so now - iat is a server-verified lower bound that a bot can't forge by lying about its clock. The client-claimed t_submit - t_load is checked too, as a secondary signal.
  • Interaction floor. mouse_moves + key_events + scroll_events must be at least cms.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:

  • webdriver flag. If navigator.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-IP rate limit + failure-ratio circuit breaker

Two cache-backed counters sit around the signals above:

  • global_attempts_per_hour (default 50). Keyed by sha1(ip), rolling 1 hr window. Above the cap, every submission rejects without even trying to verify the token. Defends against a single residential IP firing at one form for hours.
  • failure_ratio_threshold (default 0.8). Once an IP has accumulated ≥5 attempts in the window, if its failure count divided by total attempts is ≥ 80%, every subsequent attempt rejects. A bot whose first 50 attempts all fail PoW or UA-match is effectively shadowbanned for the rest of the hour without any block list.

Both 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 for shared IPs (offices, CGNAT). Don't "simplify" that pair away.

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, 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, and the IP hashed (sha1, the same derivation as the rate-limit cache keys, so log entries correlate with the counters without retaining raw addresses). 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-IP hard cap.
failure_ratio_threshold 0.8 Failure ratio at which the circuit breaker opens (after ≥5 attempts).

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:

  1. 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, 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.

  2. A self-learning Bayesian classifier (BayesSpamModel, reason content_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 the spam:train-classifier command (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.

  3. A cross-install shared-intelligence feed (SharedSpamIntel, reason content_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 salted one-way hashes of the email and IP. No message text, email address, or IP ever leaves the site — only irreversible hashes, salted with a shipped pepper so the same campaign fingerprints identically everywhere. A queued ReportSpamFingerprintJob POSTs 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). SpamReportController records 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_installs distinct installs (default 3) — the guard that stops one site from poisoning the fleet. The corroborated set is packed into a compact BloomFilter and shipped back inside the existing Ed25519-signed license-check response (the same signed channel and signature as the CMS update block — 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 a sender-hash hit plus a shingle) so a Bloom false positive can never fire on its own.

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:

  1. CDN geo header (Cloudflare CF-IPCountry, CloudFront, Vercel) via VisitorRegion — instant, free, no lookup. Used automatically when the install is behind a CDN.

  2. A local IP-to-country database (.mmdb, read with maxmind-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 into storage/, 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 free MAXMIND_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 dispatches InstallGeoDatabaseJob after 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 weekly geo:install --if-installed LazyCron keeps it current afterward (a no-op on installs that never installed one). The geo:install command 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 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 booted hook, so changes go live without a manual cache flush.
  • Form::is_seeded flags 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 public disk; the submission viewer renders them as direct download links.