Skip to content

Create a Custom Form Root

A root is the advanced path

Most custom forms are a different arrangement of the same sections and fields, and that is a layout, not a root. A layout is one template and one di.xml entry, and the shipped root keeps its settings and submission pipeline. Start with Add a Form Layout unless your form is structurally different.

A form root is the top-level component that anchors a form. It carries the component-side metadata the pipeline reads, decides which children the editor allows inside, and names the layout template the form renders through. The shipped root, hyva_form ("Form"), accepts only Form Section (form_step) children and renders through one of two shipped layouts, Single page or Multi-step, chosen on its Layout field. This guide builds a survey root so you can see every moving part of a custom root. If what you need is another rendering of the standard sections-and-fields structure rather than a different kind of form, follow Add a Form Layout instead.

By the end you will have a survey_form root that is offered as its own form type when a form is created, inherits the standard component-side settings, carries a bespoke campaign identifier that the submission pipeline reads, and renders on the storefront through the shipped layouts. Choosing Multi-step on its Layout field gives the survey a progress bar, a step indicator and Back and Next navigation.

A root does not bring its own storefront markup. The storefront renders every form root through a registered layout template, so the way to change the markup is a layout, and a layout you register is offered on your root's Layout field too. See Ship the Root Template for the details.

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 hyva_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 section container), points its template at the shipped Single page layout (explained in Step 3), and carries one bespoke setting, survey_campaign_id on the Advanced tab, read by the submission pipeline in Step 4. The standard metadata, the Layout field included, comes in the next step. Accepting form_step is also what keeps a custom root in the family alongside the shipped layouts: the layout templates render Form Sections, and the field types, the section-per-email heading, and the upgrade migration all assume a form's fields live inside a Form Section.

app/code/Acme/SurveyFormRoot/etc/hyva_cms/components.json
{
  "survey_form": {
    "label": "Survey Form",
    "description": "A survey tied to a marketing campaign. Holds Form Sections. Choose Multi-step on the Layout field for a progress bar.",
    "category": "Form",
    "template": "Hyva_FormBuilder::elements/single_page_form.phtml",
    "context_flags": ["hyva_form_root"],
    "root_only": true,
    "children": { "config": { "accepts": ["form_step"] } },
    "advanced": {
      "survey_campaign_id": {
        "type": "text",
        "label": "Campaign ID",
        "default_value": ""
      }
    }
  }
}

This example reuses form_step, the same section container the shipped root uses, which is what keeps a root in the family. The layout templates your root renders through are built around Form Section children, so a root that accepts fields directly, the way the retired single-step root did, or a bespoke container of its own, sits outside that family and loses the section behaviors above. Whatever your root 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. The shipped root declares 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 limits the document root to 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 limit 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.

Getting the cap for your root

The shipped hyva_form root doesn't render as an ordinary draggable row, it renders as the components panel's fixed header, the cap: its icon, its label and a pencil that opens its property panel, with no drag handle, checkbox, copy, duplicate, delete or visibility control. A custom root gets the same treatment by declaring "protected_root": true alongside root_only, the same declaration key the new-form dialog and the editor both read. See Giving a component the protected root header in the Hyvä CMS docs for the full mechanics.

Step 2: Include the Shared Metadata

A root inherits the standard component-side settings, and the Layout field, by including the shipped metadata files, split by editor sidebar tab. The Content tab carries the Layout field and the everyday fields; the Advanced tab carries the ones edited less often. Reference them from your root's content and advanced blocks.

Extend the Step 1 declaration so an includes list 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. The shipped root carries these same includes, see its declaration in module-form-builder/src/etc/hyva_cms/components.json:

"content": {
  "includes": [
    "Hyva_FormBuilder::etc/hyva_cms/form_root_layout_metadata.json",
    "Hyva_FormBuilder::etc/hyva_cms/form_root_content_metadata.json"
  ]
},
"advanced": {
  "includes": [
    "Hyva_FormBuilder::etc/hyva_cms/form_root_advanced_metadata.json",
    "Hyva_CmsBase::etc/hyva_cms/default_advanced.json"
  ],
  "survey_campaign_id": { "...": "as declared in Step 1" }
}

The Form Builder includes carry the Layout field and the keys the submission pipeline reads by name:

  • Hyva_FormBuilder::etc/hyva_cms/form_root_layout_metadata.json - the Layout field, a variants field whose options come from the FormLayouts registry. This is the include that decides how your root renders on the storefront, see Step 3. List it first so the Layout field sits at the top of the Content tab, as it does on the shipped root.
  • 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.

Hyva_CmsBase::etc/hyva_cms/default_advanced.json is the standard Hyvä CMS Advanced tab include. It adds the CSS Classes field, whose value the layout templates merge onto the <form> tag.

That is the whole component-side contract. Apart from the Layout value and CSS Classes, 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

The shipped root declares no design block, and neither shipped layout 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 a layout template reads and stamps them, and the shipped ones don't.

Step 3: Ship the Root Template

A form root does not ship a storefront template of its own. It renders through one of the layout templates registered on the FormLayouts registry, the same templates the shipped root uses, and its Layout field picks which one. For the survey root there is no template file to write: the template key from Step 1 and the layout include from Step 2 are all it needs.

Where the form renders decides which value is read:

  • On the storefront, and in the preview of a page or block that embeds the form, Hyva\FormBuilder\Block\Form reads the root's stored Layout value (its variants property), checks it against the registered layouts and renders that template. A blank or unregistered value renders FormLayouts::DEFAULT_LAYOUT, the Single page template. The root's component name and its template key are not read here.
  • In the editor canvas and the form's own full-screen preview, the form renders through the Hyvä CMS element path. That path renders the chosen Layout option when it is one of the field's options, and the template the declaration names otherwise.

That is why the survey root declares "template": "Hyva_FormBuilder::elements/single_page_form.phtml", the same value the shipped root declares. The new-form dialog seeds a custom root with its declared template, no Layout value and no first Form Section, so a new survey renders as a single page in the editor and on the storefront alike until a merchant chooses a layout. Choosing Multi-step switches both to multi_step_form.phtml, which is where the survey's progress bar, step indicator and Back and Next navigation come from.

A root's own template never reaches the storefront

On the storefront a form root renders through the template its Layout value names, checked against the FormLayouts registry, and through the Single page layout when the value is blank or not registered. A root's own elements/<component>.phtml is not used there, whatever the component is called. If you leave out the template key, Hyvä CMS looks for that file by convention: with no file the canvas shows a "Component template not found or invalid" box instead of the form, and with a file the editor shows markup the storefront never renders. If your form needs different storefront markup, register it as a layout, see Add a Form Layout. A layout you register appears on your root's Layout field as well as on the shipped root's, because both fields read the same registry.

The shipped layout templates handle three render paths, and a layout 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

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 branch near the top of module-form-builder/src/view/frontend/templates/elements/single_page_form.phtml resolves the three paths, and multi_step_form.phtml beside it has the same branch:

single_page_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 rest of the envelope, the success surface, the form script and the view model methods a layout template uses are covered in What a Layout Template Renders.

Step 4: Register Extra Root-Level Settings

A bespoke root setting earns its place on 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(), which reads the published tree whichever layout the form renders through, 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.

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

Register a key only when something on the submission side (a webhook consumer, an email template, a plugin on the pipeline) genuinely reads it. A purely presentational setting has no reader on the storefront, because the shipped layout templates don't read it, so it belongs in a layout of its own rather than on the root.

Adding a Layout Instead of a New Root

Not every new form style needs a new root. If your form is still Form Sections holding fields, just laid out differently, register a layout for the shipped hyva_form root instead: one template plus one entry on the FormLayouts registry. The how-to is Add a Form Layout, which also covers the opt-in Layout field. A layout is also the only way to give a custom root different storefront markup, and once registered it is offered on the Layout field of every root that includes the layout metadata.

Reference Implementation

The shipped root is the working reference for everything above. In the module-form-builder package:

  • src/etc/hyva_cms/components.json - the hyva_form and form_step declarations, with the context_flags, root_only, protected_root, accepts lists and metadata includes to mirror.
  • src/etc/hyva_cms/form_root_content_metadata.json, form_root_advanced_metadata.json and form_root_layout_metadata.json: the exact keys your root inherits, and the names the pipeline reads.
  • src/view/frontend/templates/elements/single_page_form.phtml and multi_step_form.phtml
  • the two layout templates every root renders through, with the full <form> envelope, the Alpine factory, honeypot, reCAPTCHA fragments, editor callouts and success surface, plus the three-path branching.
  • src/Block/Form.php - the storefront block, and resolveTemplate(), which picks the template from the root's Layout value and falls back to Single page.
  • src/Model/Config/Source/FormLayouts.php - the layouts registry behind the Layout field and the new-form dialog's type step.
  • src/ViewModel/Adminhtml/ContentSetup/FormTypes.php - the new-form dialog's type cards, including the card for a custom root and what it seeds.
  • 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.

  • Add a Form Layout - the simpler path, for a form that's laid out differently rather than structurally different.
  • 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.