---
url: /internals/forms.md
---
# Forms internals

The path from a form built in the panel to a stored lead, two mails and a conversion event. The user side is on [Forms and leads](/panel/forms-and-leads) and [Form Spam Protection](/panel/form-spam-protection).

## Definition

`Models\Form` (`evolve_forms`): `label`, a unique `name` derived from the label (`createUniqueFormName()` adds a numeric suffix on collision), `is_active`, `data` (the fields as JSON) and the mail settings (`admin_address`, `replyto_address`, `sender_name`, `sender_address`, `email_subject`, `email_body`, `redirect_url`). `Http\Livewire\FormBuilder` edits `data`: text, email, textarea, checkbox and file fields with label, placeholder, required, width; file fields are posted as `name[]`.

Form types (2.17): `evolve_forms.type` holds the key of a registered `Contracts\FormType`, `builder` for built forms. `Evolve::registerFormType()` stores the class and resolves it through the container on first use; `getFormTypes()` and `getFormType($key)` read the registry. On the model, `Form::BUILDER`, `formType()` (the instance, `null` for built forms and unknown keys) and `isTyped()`. The `data` accessor returns `FormType::fields()` for a registered type and the stored JSON otherwise, so the theme, validation messages, file and carbon copy handling read typed and built forms the same way. `FormManager` offers the type only on create (`Rule::in` over the registered keys) and keeps it on save; `FormBuilder::ensureEditable()` answers 403 for every write on a typed form. Developer guide: [Form Types](/custom-modules/extending-evolve#form-types).

## Submission

```mermaid
sequenceDiagram
    participant V as Visitor
    participant T as Theme form component
    participant C as FormController
    participant L as Lead
    participant N as Notifications
    participant TR as TrackingManager
    T->>T: refresh CSRF token and honeypot timestamp<br/>through /api/csrf-token
    V->>C: POST api/form/name
    C->>C: validate the fields, Honeypot,<br/>Recaptcha when a secret is set, Keywords
    alt validation fails
        C-->>T: redirect back with errors
    else valid
        C->>L: store the lead and its attachments
        C->>N: NewLeadReceived to the recipient,<br/>LeadReceivedConfirmation to the sender
        C->>TR: record generate_lead with a fresh EventId
        C-->>T: redirect, trackingEventId in the session
        T->>T: push generate_lead to the dataLayer<br/>with the same EventId
    end
```

`Http\Controllers\Frontend\FormController` handles `POST api/form/{form:name?}`:

1. Builds the validation rules: for built forms from the form's fields through `Concerns\HandlesFormData`, for typed forms from `FormType::rules()` (`buildTypedValidationRules()`, which adds `addKeywordRules()` over the type's text fields). Both get `user_identification` with `Rules\Honeypot` (encrypted render time, must be at least `evolve.honeypot-threshold` seconds old; `fax_number` must be empty), `g-recaptcha-response` with `Rules\Recaptcha` when `services.google.recaptcha.secret` is set (verifies at Google, requires `score` for v3 tokens, rejects when Google is unreachable), and `Rules\Keywords` over `evolve.forms.blocked-keywords` and `blocked-patterns`.
2. Builds the lead attributes. Built forms: fillable request keys into their columns, the rest into `payload` (`builderLeadAttributes()`). Typed forms: the result of `FormType::prepare()`, fillable keys into their columns, the rest into `payload`, `path` from the request (`typedLeadAttributes()`). `applyConsent()` reads the consent field (`Form::consentField()`: `accepts_marketing`, or `sync.consent_field` set by the email module's Marketing card). When it is ticked, `Lead::recordConsent()` stores its label (its name when the label is empty) as `consent_text` with `consent_given_at` and `consent_hash` (SHA-256 of the text) and sets `accepts_marketing`. Under double opt in (`Form::usesDoubleOptIn()`) `accepts_marketing` stays `false` and `consent_given_at` empty until the email module records the confirmation. The consent and sync columns (`synced_at`, `sync_error`, `contact_id`) are not fillable.
3. Stores the `Lead` with its field data and attachments (limits from `evolve.forms`), downloadable through `LeadAttachmentController` behind auth.
4. Fires `Events\LeadCreated` (2.17), then notifies: `Notifications\NewLeadReceived` to the form's recipient (`Recipients\FormRecipient`), `Notifications\LeadReceivedConfirmation` to the sender when the form has a confirmation subject and body. Since 2.19 both notifications return a rendered mail template from `toMail()` when the form has `confirmation_template_id` or `notification_template_id`, and the Markdown mail otherwise. The core binds `Contracts\LeadMailTemplates` to `MailTemplates\FormMailTemplates` itself since 2.22 (`bindIf`, a project may bind its own), see [Mail templates](#mail-templates) below and [Mail Templates for Form Mails](/custom-modules/extending-evolve#mail-templates-for-form-mails). The lead's `locale` is set on creation from the session locale. `Notifications\UnreadLeads` exists for a digest but is not scheduled by the package.
5. Records `TrackingEvent::generateLead()` through `ServerSideTracking` with a fresh `Support\EventId`, see [Tracking pipeline](/internals/tracking-pipeline).
6. Redirects to `_redirect` from the request, else the form's `redirect_url`, else back to the page, with `trackingEventId` in the session so the theme pushes the browser event with the same ID.

Validation failures redirect back with errors; the spam fields have no visible input, so the theme's form component shows a generic message when any of them fails.

## Mail templates

Part of the core since 2.22, before in the email module (#338). `Models\MailTemplate` (`email_templates`, the table name stays) carries blocks through `HasBlocks` and implements `BlockContainer` (`blockEditUrl()` points at `evolve.mailtemplate.edit`) and `Selectable`. The rendering lives under `core/src/MailTemplates/`: `TemplateRenderer` renders a template for one lead in the lead's language (blocks and subjects without that translation fall back to the default locale) and returns a `RenderedMail` (subject, HTML, plain text through `HtmlToText`, locale, unknown placeholders); `Placeholders` fills {{ lead.\* }}, {{ form.\* }}, {{ site.\* }} and `FormType::placeholders()`; `LeadSummary` builds the rows of the lead summary block; `SampleLead` is the unsaved lead of the preview and the test send; `TemplatedMail` wraps a rendered mail for the mailer; `FormMailTemplates` implements `LeadMailTemplates`. `Services\EmailDocumentRenderer` turns any block carrier into the table layout `evolve::email.document` with the block views `evolve::email.blocks.*`; the email module's campaigns render through the same class with `email::render.campaign` as the wrapper. The 16 `email.*` blueprints live in `core/src/MailTemplates/Blueprints` and are registered as a package path in `evolve.blueprints.paths`, with `selectable: false` so the page editor never offers them. Panel: `Http\Livewire\MailTemplateManager`, `Http\Controllers\Backend\MailTemplateController`, `MailTemplatePreviewController`, `MailTemplateTestSendController`, views `evolve::mailtemplate.*`, routes `evolve.mailtemplate.*` behind the forms' role middleware, translations `evolve::mail-templates.*`. The look comes from `evolve.mail` (`theme`, `dialect`, `merge-tags`, `asset-base`); a published `config/email.php` of the module with those keys wins, see `EmailDocumentRenderer::theme()` and `mergeTags()`.

Stored names did not change. Blocks (`evolve_blocks.blockable_type`), activity rows (`subject_type`) and block props (`__model`) written by the module name the template `Racerfish\Email\Models\MailTemplate`. That string is the morph alias of the core model (`MailTemplate::MORPH_ALIAS`, registered with `Relation::morphMap()` in `EvolveServiceProvider::bootMailTemplates()`), so old rows resolve and new rows are written with the same name; no migration touches the data. The module keeps `Racerfish\Email\Models\MailTemplate`, `Racerfish\Email\Templates\*` and `Racerfish\Email\Services\EmailDocumentRenderer` as deprecated empty subclasses until 3.0; an instance of the old model class reports the same morph class and reads the same rows. Tests: `core/tests/Feature/MailTemplates/`, all with the email module switched off (`Tests\Support\SwitchesModules`), `MailTemplateMorphAliasTest` for the stored names, `EmailModuleDefaultTest` for the default and the binding; the module tests extend `email/tests/EmailTestCase.php`, which switches the module on.

## Cached pages

The honeypot timestamp and the CSRF token would go stale inside a page-cache hit. The theme refreshes both on load through `/api/csrf-token`, which the page cache never writes.

## Events

Event registrations (`event/src/Http/Controllers/Frontend/EventController::register()`) reuse the honeypot and reCAPTCHA rules (2.16) and the form's recipient for their notification, but store `Participant` rows instead of leads.

## Tests

`core/tests/Feature/*Form*`, `*Honeypot*`, `*Recaptcha*`; the 2.16 changes are covered in the tests added by #274, form types, `LeadCreated` and the consent snapshot in `FormTypeTest` (2.17, with the workbench type `CallbackRequestFormType`).
