Form Builder Architecture
Beta - APIs may change
This module is in beta. The classes, registries, component conventions, and contracts described in the developer documentation may change in backwards-incompatible ways before the general release. Customizations built against them may need updating between beta versions.
A Form Builder form is Hyvä CMS content: a component tree, built in the Liveview Editor, saved and published like any CMS page. What makes it a form is the root of that tree, a form-root component holding the fields and the customer-facing content settings, a form entity holding the operational submission settings, and a single submission controller that turns a customer's POST into an email. This page orients you on those moving parts; the extension guides at the bottom go deeper on each.
The Form Builder ships as hyva-themes/commerce-module-form-builder and depends on
hyva-themes/commerce-module-cms. Extending it follows the same patterns as extending
Hyvä CMS, so if you're new to Hyvä CMS components, read
Creating Components
first; everything here covers only the form-specific parts.
How a Form Works
A form starts as a tree built in the editor and ends as a notification email. End to end:
- A form type is chosen when the form is created, which seeds the form root. Fields go inside it, the customer-facing settings (submit button label, success message, success content, reply-to field, recipient routing) are edited in the root's property panel, and the operational ones (recipients, email subject, webhook, CAPTCHA) on the entity.
- Publishing writes the tree to
published_content, a standard Hyvä CMS published component tree. Entity-level settings are columns, so they take effect on save with no publish and are untouched by a content rollback. - On the storefront, the published tree renders as a real
<form>wrapped in an Alpine island that POSTs on submit. - Every submission hits one submission controller, which resolves a SubmissionContext and loads the form's stored tree, never anything the client sent.
- Security gates run in a fixed order: form key → honeypot → CAPTCHA.
- The payload is validated against a schema compiled from that tree, then the notification email is rendered and sent, and the submission is optionally mirrored to a webhook.
The server is authoritative
The validation schema is compiled from stored content on every request, never from the
request body. A tampered POST can't add fields or relax the rules. Which stored blob is
compiled, published_content or draft_content, is the context's decision alone, and
the draft is only ever reachable through a preview link whose signature validates. See
Add Custom Validation.
Live Sends and Test Sends
SubmissionContext is the one seam through which the submission pipeline knows whether it
is handling a live send or a test send. SubmissionContextResolver builds it from the
request and the form, and three things fall out of it:
| Source | Test? | Validated against |
|---|---|---|
SOURCE_STOREFRONT |
no | published_content |
SOURCE_FORM_PREVIEW (the form's own preview) |
yes | draft_content |
SOURCE_EMBEDDED_PREVIEW (a preview of content embedding the form) |
yes | published_content |
Test status derives only from preview parameters whose hash passes the same preview validation the preview page itself runs, so a client-supplied flag can never set or clear it, and any resolution failure falls back to the storefront source. A preview render echoes its own preview parameters into the submit URL, which is what carries them into the POST.
Every outbound effect of a test submission is marked: [test] on the email subject, and
X-Hyva-Form-Test: 1 on the webhook delivery. See
Webhook Deliveries.
Declaring Components
Forms are ordinary Hyvä CMS components. They are declared in
etc/hyva_cms/components.json and follow the same format as every other component: the
same label, children.accepts, context_flags, and property-metadata shape you'd use
for a CMS page component.
Two placement rules are specific to forms. Both shipped roots declare root_only: true, so
no nested picker or drag target admits them, and the form editor caps the document root at
one component, refusing an add at the root rather than only hiding it from the picker, so a
form document holds exactly one form. Both are enforced client-side in the editor and never
on save, so an imported tree, an undo or redo, or a crafted request can still persist an
invalid shape.
The shipped roots, field types, containers and the CMS-embed widget all live in one file:
Read that file rather than a list here; it is the reference for what ships and the shape a custom component follows.
The Shared Metadata-Include Contract
The component-side settings that make a root a form root are not baked into each root's declaration. They live in two shared metadata-include files, split by editor sidebar tab:
form_root_content_metadata.jsonholdssubmit_button_label,success_message,success_content_type,success_redirect_url,success_content_block.form_root_advanced_metadata.jsonholdsreply_to_field_uid,recipient_routing.
Everything in them is customer-facing or references a field inside the tree, which is why they are tree-versioned with the fields they belong to: restoring an old version keeps the references and their targets consistent, and the translatable ones stay translatable per store view.
The shipped roots both include these files, so their settings can't drift apart. More importantly, these includes are the published contract: the submission pipeline reads each setting by its property name and never branches on the root's component type. There is no aliasing layer between the JSON property and the pipeline: the name in the include is the name the controller reads.
That's what makes custom roots cheap. A third-party root that includes both files inherits the standard settings and stays compatible with the whole pipeline for free. The how-to is in Create a Custom Form Root.
Extending the set of settings has two independent halves, and both are needed for one
setting to work end to end. The editor half is the component declaration: includes
accepts an array, and a key declared inline beats the same key from an include, so a root
can add settings or retune shipped ones without copying an include file. The submission
half is RootMetadataPool, a di.xml registry of the keys PublishedForm::getMetadata()
will surface, with each entry's value acting as the read-time default. The registry is what
keeps structural keys such as children, design and uid out of the metadata read, so an
unregistered key stays invisible to the pipeline however it was declared.
Both halves attach to a root's own declaration, which means they extend a root you ship, not
one somebody else ships. Hyvä CMS replaces components wholesale rather than merging them, so
adding a setting to single_step_form or multi_step_form means overriding that whole
declaration. Shipping your own root is usually the cheaper answer, and
Create a Custom Field covers the same trade-off for accepts
lists.
Entity-Level Submission Settings
Recipients (to, cc, bcc), the email subject, the webhook URL and body format, and the CAPTCHA toggle are columns on the form entity, not component properties. They are operational rather than customer-facing, so they take effect the moment the settings are saved, without a publish, and a content rollback cannot silently redirect or expose submissions.
Everything consuming them, the mailer, the webhook dispatcher, the CAPTCHA gate and the subject renderer, reads one value object through one seam:
use Hyva\FormBuilder\Model\Submission\FormSubmissionSettingsReader;
/** @var FormSubmissionSettingsReader $reader */
$settings = $reader->fromForm($form);
$settings->getRecipientTo(); // comma-separated, '' when unset
$settings->getRecipientCc();
$settings->getRecipientBcc();
$settings->getSubjectTemplate();
$settings->getWebhookUrl();
$settings->getWebhookFormat(); // form_encoded | json_flat | json
$settings->isCaptchaEnabled();
FormSubmissionSettingsReader::fromForm(FormInterface $form) is stateless and the value
object is read-only, so custom code consumes these settings exactly the way the shipped
pipeline does. Read them through the reader rather than off the entity's getters, and a
later change to defaulting or coercion reaches your code too.
Extension Guides
Each guide is a self-contained, step-by-step walkthrough.
| Guide | What you'll build |
|---|---|
| Create a Custom Form Root | A new form type: a survey or wizard root with its own template and settings. |
| Create a Custom Field | A new field type: component declaration, descriptor, storefront template, and allowing it in a form root. |
| Add Custom Validation | Server-side validation for a field type, via a field validator registered in the validator pool. |
| Add a Security Gate | A new or replacement gate, for example an hCaptcha or Turnstile provider. |
| Webhook Deliveries | Not a build, a reference: what arrives at a webhook endpoint, and how a test submission is marked. |
Related Topics
- Building Forms - The same components as they appear in the editor.
- Creating Components (Hyvä CMS) - The generic component model these build on.