Create a Custom Form Root
A form root is the top-level component that anchors a form. It owns the storefront
<form> envelope, carries the component-side metadata the pipeline reads, and decides which
children the editor allows inside. The shipped roots (single_step_form,
multi_step_form) are two variants of the same idea; this guide builds a third, a
survey root with a progress bar, so you can see every moving part.
By the end you will have a survey_form root that is offered as a form type when a form is
created, inherits the standard component-side settings, renders its own <form> markup, and
carries two bespoke settings of its own: a presentational progress-bar style, and a campaign
identifier the submission pipeline reads.
The operational submission settings (recipients, email subject, webhook, CAPTCHA) are not part of a root's declaration at all. They are columns on the form entity, edited in the Form Settings slide-over and read through one seam, so a custom root gets them without declaring anything. See Entity-level settings below.
Prerequisites
You should have shipped a Hyvä CMS component before - a root is a Hyvä CMS component
with a few form-specific pieces. If components are new to you, read
Creating Components
first, then skim the Architecture page for how a root's metadata reaches
the submission pipeline. The Form Builder ships as
hyva-themes/commerce-module-form-builder and depends on
hyva-themes/commerce-module-cms; your module should depend on the Form Builder.
Step 1: Declare the Root Component
A root is marked with the context_flags: ["hyva_form_root"] flag. That flag is the signal
the editor uses to offer the component in form mode, and the same registry the new-form
dialog reads to list the available form types. Your label and description are what appear
on that type card, so write the description for whoever is choosing a form type.
Two more declarations matter. root_only: true keeps the root out of every nested
context, and it is required (see the warning below). children.config.accepts is the
authoritative gate for what can be placed inside this root.
Add the declaration to your own module's etc/hyva_cms/components.json. The shape is the
shipped multi_step_form declaration in
module-form-builder/src/etc/hyva_cms/components.json, so start from that rather than a
listing here. What the survey root adds is the delta below: it accepts form_step children
(reusing the shipped step container) and carries two bespoke settings - progress_bar_style
on the Content tab (presentational, read by the template) and survey_campaign_id on the
Advanced tab (read by the submission pipeline in Step 4) - alongside the standard metadata
you will include in the next step.
{
"survey_form": {
"label": "Survey Form",
"description": "Root for a multi-step survey with a progress bar. Accepts Form Step containers.",
"category": "Form",
"context_flags": ["hyva_form_root"],
"root_only": true,
"children": { "config": { "accepts": ["form_step"] } },
"content": {
"progress_bar_style": {
"type": "select",
"label": "Progress Bar Style",
"default_value": "bar",
"options": [
{"value": "bar", "label": "Bar"},
{"value": "dots", "label": "Dots"}
]
}
},
"advanced": {
"survey_campaign_id": {
"type": "text",
"label": "Campaign ID",
"default_value": ""
}
}
}
}
Reuse form_step when the survey looks structurally like a multi-step form; reuse the
shipped field components directly if it is single-page; ship your own survey_question
container when the root needs a bespoke child shape. Whatever it permits, list it in
accepts - the editor's child picker honors the list directly, and field components
flagged require_parent: true only surface inside parents that admit them.
root_only: true is not optional
A form is a complete <form> element, so nesting one inside another produces invalid
markup and a broken storefront. root_only: true is what keeps a root out of every
nested picker and drag target, the shipped Form Content container included. Both
shipped roots declare it. Omit it on yours and your root still appears in nested
pickers, and merchants can build a form inside a form.
The complementary rule needs nothing from you: the form editor caps the document root at one component, so once any form root is present the root picker shows "No components found" and an add at the root is refused outright, whichever root it is. Both rules are enforced client-side in the editor, never on save. The cap is checked when a component is inserted, not only when a picker is drawn, but an imported tree, an undo or redo, or a crafted request can still persist two roots.
Step 2: Include the Shared Metadata
A root inherits the standard component-side settings by including two shipped metadata
files, split by editor sidebar tab. The Content tab carries the everyday fields; the
Advanced tab carries the ones edited less often. Reference both from your root's content
and advanced blocks.
Extend the Step 1 declaration so an includes line sits alongside your bespoke settings in
each tab block. Hyvä CMS merges the included keys with your inline keys, so all of them end
up on the saved component. Both shipped roots carry exactly these two lines - see their
declarations in module-form-builder/src/etc/hyva_cms/components.json:
"content": {
"includes": "Hyva_FormBuilder::etc/hyva_cms/form_root_content_metadata.json",
"progress_bar_style": { "...": "as declared in Step 1" }
},
"advanced": {
"includes": "Hyva_FormBuilder::etc/hyva_cms/form_root_advanced_metadata.json",
"survey_campaign_id": { "...": "as declared in Step 1" }
}
The two Form Builder includes carry the keys the submission pipeline reads by name:
Hyva_FormBuilder::etc/hyva_cms/form_root_content_metadata.json- the Content tab fields:submit_button_label,success_message,success_content_type,success_redirect_url,success_content_block.Hyva_FormBuilder::etc/hyva_cms/form_root_advanced_metadata.json- the Advanced tab fields:reply_to_field_uid,recipient_routing.
That is the whole component-side contract. Everything in it is either customer-facing and translatable, or a reference to a field UID inside the tree, which is why it belongs on the component and is versioned with it.
How the Includes Merge
includes takes a single path or an array of paths, so a root can pull in the shipped file
alongside one of its own:
"content": {
"includes": [
"Hyva_FormBuilder::etc/hyva_cms/form_root_content_metadata.json",
"Acme_SurveyFormRoot::etc/hyva_cms/survey_content_metadata.json"
]
}
With an array, later files win on a repeated key. Two rules govern the result:
- Your inline keys beat the includes. Declare a key inline that the include also declares, and yours is the one that survives, so you can retune a shipped field's label, comment or default value without copying the whole file. Only the declaration changes, the property name stays the same, so the submission pipeline still reads it.
- Included fields are laid out first, then your inline ones. A key you override moves
out of its position in the include and down to the end of the tab, so overriding
submit_button_labelputs it below your bespoke settings rather than at the top.
Retuning beats redeclaring
Changing a shipped field's wording is a two-line inline override. Adding a genuinely new
setting is an inline key plus a RootMetadataPool entry, covered in Step 4. Neither
needs the include file to be copied.
The include is the published contract
The submission pipeline looks each metadata key up by name - there is no aliasing
layer. A custom root that renames success_message to something more themed
(survey_thanks, etc.) simply will not be picked up: the controller reads a blank value
and the customer sees no confirmation. Keep the standard keys exactly as the includes
ship them. A root that skips the includes entirely will still load, but the controller
treats every component-side property as blank, and the storefront falls back to a
default submit label with no success message and no reply-to. A blank or unrecognized
success_content_type resolves to redirect_url, so a root that omits it keeps the
original behavior rather than losing its success content.
Your Root and the Entity-Level Settings
Recipients, the email subject, the webhook URL and format, and the CAPTCHA toggle are not in the includes and must not be declared on your root. They are columns on the form entity, edited in the Form Settings slide-over, and they apply on save without a publish. Your root inherits them by doing nothing.
If your own code needs to read them, go through the same seam the shipped pipeline uses:
use Hyva\FormBuilder\Model\Submission\FormSubmissionSettingsReader;
$settings = $this->settingsReader->fromForm($form);
$settings->getRecipientTo();
$settings->getWebhookFormat(); // form_encoded | json_flat | json
$settings->isCaptchaEnabled();
Declaring a recipient_to (or any other moved key) on your root would render an input that
nothing reads. There is no dual-read compatibility layer to fall back on.
Design colors belong to the hosting content
Neither shipped root declares a design block, and neither root template renders a
design value, so a root's Design tab carries nothing that reaches the storefront. Form
colors are set on the CMS embed component instead: hyva_form_widget includes
Hyva_FormBuilder::etc/hyva_cms/form_design_colors.json (Background Color and Text
Color) and wraps the rendered form in a styled card, so a form takes its colors from
the page or block hosting it. That was a deliberate reversal: root-level background
styling was lost on the widget path, which is the most common way a form is placed.
Declaring the Hyvä CMS design includes on your own root is possible, but the values only
render if your template reads and stamps them itself.
Step 3: Ship the Root Template
The root template owns the entire storefront <form> envelope: the Alpine factory
initialization, the hidden form_key and form_identifier, the children loop, the
honeypot input, the optional reCAPTCHA fragments, the ARIA-live region, the form-level
error region, the submit action row, and the inline success surface.
Hyvä CMS resolves the template from the convention path
<Module>::elements/<component-name>.phtml, so the survey root's file lives at
view/frontend/templates/elements/survey_form.phtml in your module, and the declaration
needs no template: key.
The template gets its context from two view models. FormRenderer
(Hyva\FormBuilder\ViewModel\Storefront\FormRenderer) supplies everything the submission
pipeline needs - submit URL, form key, honeypot name, the entity-level settings, the Alpine
state seed. ChildElementRenderer renders the child components. On the storefront the block
is a Hyva\FormBuilder\Block\Form carrying the resolved PublishedForm; in the editor
canvas it is the generic Hyvä CMS Element block, which has no getPublishedForm() - the
instanceof check below covers both paths.
Three render paths matter, and a root that collapses them will either break testing or let the canvas post for real:
| Path | How to spot it | Submit button |
|---|---|---|
| Storefront, or any preview embedding this form | $block->getPublishedForm() is a PublishedForm |
live |
| The form's own full-screen preview | preview, and not isEditorCanvas() |
live, wired from the preview URL |
| The editor canvas | preview, and isEditorCanvas() |
inert, with a message |
The shipped templates are the working envelope, so start from one of them rather than
copying a listing here.
module-form-builder/src/view/frontend/templates/elements/single_step_form.phtml is the
single-step envelope; multi_step_form.phtml beside it adds the Back/Next navigation and
step indicator. Copy whichever is closer to your root into your module as
elements/survey_form.phtml, then adapt the markup - a progress bar reading
$block->getData('progress_bar_style'), in the survey root's case.
The heart of either file is the branch that resolves the three render paths above:
$isEditorCanvas = $isPreview && $formRenderer->isEditorCanvas();
// FormBlock supplies the resolved PublishedForm; the canvas/preview-rendered Element path doesn't.
$publishedForm = $block instanceof FormBlock ? $block->getPublishedForm() : null;
if ($publishedForm instanceof PublishedForm) {
// One live pipeline for the storefront and for any entity's preview embedding this form.
$submitUrl = $formRenderer->getSubmitUrl();
$formKey = $formRenderer->getFormKey();
$honeypotFieldName = $formRenderer->getHoneypotFieldName($formIdentifier);
$alpineInitialState = $formRenderer->getAlpineInitialStateJson($formIdentifier, $publishedForm);
} elseif ($isPreview && !$isEditorCanvas) {
// Form's own full-screen preview - submissions send for real, wired from the preview URL.
$formIdentifier = $formRenderer->getPreviewFormIdentifier();
if ($formIdentifier !== '') {
$submitUrl = $formRenderer->getSubmitUrl();
$formKey = $formRenderer->getFormKey();
}
$alpineInitialState = $formRenderer->getPreviewInitialStateJson($children, $formIdentifier);
} else {
// Editor canvas - inert; clicking the submit button selects the component instead.
$alpineInitialState = $formRenderer->getCanvasInitialStateJson($children);
}
The success surface is where success content lands
The data-hyva-form-result="success" div bound to x-html="resultMessage" is the one
place a confirmation renders, whether that is the success message or the CMS block chosen
as the root's Success Content. Including the shared Content metadata gives your root
success_content_type and success_content_block without declaring anything, but the
include only supplies the settings. Drop the success div from your template and a
successful submission leaves the customer looking at nothing.
The pieces every root template builds on:
$block->validPreview()- true in any preview, the editor canvas included, false on the storefront.FormRenderer::isEditorCanvas()narrows that to the canvas specifically, so combine the two rather than treating every preview as the canvas.$block->getData('children')- the child component nodes; render each throughChildElementRenderer::renderChildHtml($child). Component properties are flat-set onto the block, soform_identifier,submit_button_labeland your ownprogress_bar_styleare all read via$block->getData('…').FormRenderer::getSubmissionSettings($formIdentifier)- the entity-level settings value object. Pass the identifier where the block carries one, otherwise the form resolves from the preview URL parameters.FormRenderer::isSavedForm($formIdentifier)- false until a saved form entity backs the render, which is what keeps the advisory callouts off a brand-new form's canvas. Resolves the same two ways as the settings.FormRenderer::getFormTitle($formIdentifier)- the entity title, which is the form's accessible name. Resolves the same two ways as the settings, and returns an empty string when neither resolves, so keep a fallback.FormRenderer::getSubmitUrl()/getFormKey()- the submission controller URL and the Magento CSRF token for the hidden inputs. On a preview render the submit URL carries an echo of the current preview parameters, which is what lets the server classify the submission as a test.FormRenderer::getHoneypotFieldName($formIdentifier)- the per-(store, form) HMAC-derived honeypot input name; render the honeypot input only on the storefront.FormRenderer::getAlpineInitialStateJson(...)(storefront and embedding previews),getPreviewInitialStateJson($children, $formIdentifier)(the form's own preview) andgetCanvasInitialStateJson($children)(canvas) - thehyvaFormBuilderstate seed. The factory reads it from a<script type="application/json" data-initial-state>element inside the root - not anx-dataliteral (the CSP-safe Alpine build can't eval one), and not an HTML attribute (a large field tree doesn't belong in attribute soup).FormRenderer::isReCaptchaConfigured(),getReCaptchaInputHtml()andgetReCaptchaLegalNoticeHtml()- the server-rendered reCAPTCHA fragments, to render when the entity's CAPTCHA setting is on.FormRenderer::renderFormScriptHtml()- the sharedhyvaFormBuilderform script. Echo it once from the root (the shipped roots render it right after the closing root</form>). A named layout block dedupes it, so several forms on one page still emit it once, and it only ships on pages that actually render a form. A root that brings its own form script can skip this and render its own instead.
The rest of the envelope is in the shipped templates: the hidden form_key and
form_identifier inputs, the initial-state script element, the honeypot input, the
reCAPTCHA fragments gated on the entity's CAPTCHA setting, the editor-canvas callouts (no
destination configured, reCAPTCHA or honeypot misconfigured, all gated on
method_exists($block, 'renderEditorMessage') and on isSavedForm()), and in
multi_step_form.phtml the step navigation. Read the full envelope before shipping your
own.
Step 4: Register Extra Root-Level Settings
The progress_bar_style field from Step 1 is purely presentational - the template reads
it straight off the block, and that kind of setting needs nothing further. But some root
settings exist for the submission side. Say each survey belongs to a marketing campaign,
and the CRM receiving the webhook needs a campaign identifier alongside the submitted
fields. That value must surface through PublishedForm::getMetadata(), and only keys
registered against RootMetadataPool do. This is a registry, not a blanket passthrough,
which is what keeps structural keys (children, design, uid, _*) out of the
metadata read.
You already declared survey_campaign_id on the root's Advanced tab in Step 1 - the
natural home for a set-and-forget value. What's left is registering the key and its
read-time default in di.xml:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Hyva\FormBuilder\Model\Submission\RootMetadataPool">
<arguments>
<argument name="keys" xsi:type="array">
<item name="survey_campaign_id" xsi:type="string"/>
</argument>
</arguments>
</type>
</config>
Magento merges array arguments by item key, so your entry slots in alongside the built-ins without colliding. The item value is the read-time default, returned when the field was left blank. A custom key can't clobber a standard one - the standard key's coercion and default always win - and non-scalar values fall back to the registered default rather than leaking structure into the metadata.
A setting needs both halves
The field declaration and the pool entry are independent, and each fails quietly on its
own. Declare the field without registering the key and an admin user can fill it in, it
saves into the tree, and getMetadata() never returns it, so the submission side reads a
blank. Register the key without declaring the field and the pipeline reads the default
forever, because there is nothing in the editor to set it with. Ship the pair together.
Purely presentational settings need only the declaration. progress_bar_style is read
straight off the block by the template, so it never goes near the pool.
Your submission-side code reads the value back with
$publishedForm->getMetadataValue('survey_campaign_id'). For the campaign id the natural
consumer is a plugin on WebhookDispatcher::dispatch() (or on SubmissionMailer::dispatch()),
stamping the id onto the outgoing payload. Both methods take the destination and format from
the entity-level FormSubmissionSettings rather than from root metadata, and both end in a
required SubmissionContext:
WebhookDispatcher::dispatch(
FormSubmissionSettings $settings,
string $formIdentifier,
array $submittedFields,
SubmissionContext $context
): void
SubmissionMailer::dispatch() has the same shape; both signatures are in
module-form-builder/src/Model/Submission/WebhookDispatcher.php and
SubmissionMailer.php beside it. WebhookDispatcher::buildBody() is protected in the
same file, for a subclass adding or reshaping a body format, and its trailing
bool $isTest = false parameter is what stamps the test marker into the JSON envelope
format.
Keep the test marking intact
A plugin or subclass that drops the context argument, or rebuilds a body without the
$isTest flag, makes preview submissions indistinguishable from customer ones
downstream. That is the failure the marking exists to prevent, which is why the context
is a required argument rather than an optional one. See
Webhook Deliveries.
Only register keys the pipeline actually needs
Presentational settings like progress_bar_style stay out of the pool - the template
reads them off the block, and registering them would only leak display concerns into
the submission metadata. Register a key only when something on the submission side (a
webhook consumer, an email template, a plugin on the pipeline) genuinely reads it.
Reference Implementation
The shipped roots are the working reference for everything above. In the module-form-builder
package:
src/etc/hyva_cms/components.json- thesingle_step_formandmulti_step_formdeclarations, with thecontext_flags,root_only,acceptslists and metadata includes to mirror.src/etc/hyva_cms/form_root_content_metadata.jsonandform_root_advanced_metadata.json- the exact keys your root inherits, and the names the pipeline reads.
src/view/frontend/templates/elements/single_step_form.phtmlandmulti_step_form.phtml- the full
<form>envelope with the Alpine factory, honeypot, reCAPTCHA fragments, editor callouts and success surface, plus the three-path branching. src/Model/Submission/RootMetadataPool.php- how registered keys are surfaced and defaulted.src/Model/Submission/FormSubmissionSettingsReader.php- the entity-level settings seam your root inherits without declaring anything.
Read these end to end before shipping your own root - they show every pattern on this page in production code.
Related Topics
- Architecture - how the Form Builder fits together, the metadata-include contract, and how a root's properties reach the submission controller.
- Create a Custom Field - build the field components your
root's
acceptslist admits. - Add a Security Gate - the gate chain the entity's CAPTCHA setting drives.
- Webhook Deliveries - the delivery formats and the test marker a plugin must preserve.
- Testing a Form - the preview contexts your root has to keep working.
- Building Forms - the guide to building forms, so you know what your root looks like in use.
- Creating Components
- the Hyvä CMS component model your root builds on.