Skip to content

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.

app/code/Acme/SurveyFormRoot/etc/hyva_cms/components.json
{
  "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_label puts 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:

single_step_form.phtml (excerpt)
$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 through ChildElementRenderer::renderChildHtml($child). Component properties are flat-set onto the block, so form_identifier, submit_button_label and your own progress_bar_style are 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) and getCanvasInitialStateJson($children) (canvas) - the hyvaFormBuilder state seed. The factory reads it from a <script type="application/json" data-initial-state> element inside the root - not an x-data literal (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() and getReCaptchaLegalNoticeHtml() - the server-rendered reCAPTCHA fragments, to render when the entity's CAPTCHA setting is on.
  • FormRenderer::renderFormScriptHtml() - the shared hyvaFormBuilder form 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:

app/code/Acme/SurveyFormRoot/etc/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 - the single_step_form and multi_step_form declarations, with the context_flags, root_only, accepts lists and metadata includes to mirror.
  • src/etc/hyva_cms/form_root_content_metadata.json and form_root_advanced_metadata.json
  • the exact keys your root inherits, and the names the pipeline reads.
  • src/view/frontend/templates/elements/single_step_form.phtml and multi_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.

  • 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 accepts list 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.