Skip to content

Create a Custom Field

This tutorial walks you through building a custom form field component from scratch. As a worked example we'll ship an E.164 phone field, a single-line input with a strict international-format validation pattern, that can be dropped into a form and edited in the Liveview Editor like any built-in field. The Form Builder already ships a permissive form_field_phone; a stricter variant is exactly the kind of field a project adds.

A custom field is three artifacts plus one wiring step:

  1. A Hyvä CMS component declaration with its property panel.
  2. A FieldDescriptor class registered in the submission pipeline.
  3. A storefront template at the convention path.
  4. An entry in the field's parent's accepts list, the Form Section container's for a field in a standard form, so the field is offered in the editor.

No PHP changes to the form-builder package itself are required - every step lives in your own module.

Prerequisites

You should already have shipped a Hyvä CMS component or two. If the component model is unfamiliar, read Creating Components first, then skim the Architecture page for the shipped root and field types your new field will sit alongside. The Form Builder ships as hyva-themes/commerce-module-form-builder and depends on hyva-themes/commerce-module-cms.

Step 1: Declare the Field Component

Add the component declaration to your own module's etc/hyva_cms/components.json. Set require_parent: true so the field never appears as a top-level pick in any editor - it only surfaces inside parents that admit it. Do not set context_flags on a field component; the form roots filter their children via their accepts list, not via context flags.

Your FieldDescriptor (Step 2) decides which config keys it reads off the saved node, so expose each of those as a panel field here, and don't expose rule keys the descriptor doesn't read. Keep the two in lockstep: a key the descriptor reads but the panel never exposes is a dead rule a customer can never trigger, and a panel field the descriptor ignores is a control that does nothing. (The shipped fields are held to exactly this by a parity test.) The phone field reads required, pattern and pattern_message, so the panel exposes those plus the usual label / field name / placeholder. Follow the shipped convention and declare pattern / pattern_message in the advanced section - they surface on the panel's Advanced tab, keeping the main tab for the everyday properties (the shipped text field keeps an Autocomplete select there too):

app/code/Acme/FormBuilderPhoneField/etc/hyva_cms/components.json
{
  "form_field_phone_e164": {
    "label": "Phone Field (E.164)",
    "description": "Single-line phone input with E.164-style validation.",
    "category": "Form Fields",
    "require_parent": true,
    "content": {
      "label": { "type": "text", "label": "Label", "default_value": "Phone" },
      "field_name": { "type": "text", "label": "Field Name", "default_value": "" },
      "placeholder": { "type": "text", "label": "Placeholder", "default_value": "" },
      "required": { "type": "boolean", "label": "Required", "default_value": false }
    },
    "advanced": {
      "pattern": {
        "type": "text",
        "label": "Validation Pattern",
        "default_value": "^\\+?[0-9 .-]{6,20}$"
      },
      "pattern_message": { "type": "text", "label": "Pattern Error Message", "default_value": "" }
    }
  }
}

Mirror the shipped fields for richer panels

When you need a more elaborate property panel, mirror the shipped field types in module-form-builder/src/etc/hyva_cms/components.json - they show the full range of panel controls in use.

Step 2: Register a Field Descriptor

The Form Builder keeps a single registry - FieldDescriptorPool - that drives both the storefront pre-flight (the Alpine seed) and the server-side SchemaCompiler. To register a new field you ship one descriptor class plus one DI entry. The descriptor names the component, its validator type token, and how to extract its per-instance rule bag.

Create the descriptor implementing Hyva\FormBuilder\Api\FieldDescriptorInterface. getComponentName() returns the component name, getType() returns the validator type token, extractRules() reads the properties set in the editor off the saved node into the rule bag, and ruleKeys() lists the config keys extractRules() reads - the panel fields this field consumes - which keeps the descriptor and the component declaration in step.

The shipped permissive phone field already ships exactly the descriptor this field needs. Copy module-form-builder/src/Model/Submission/FieldDescriptor/PhoneField.php into your module, adjust the namespace, and point it at your component:

app/code/Acme/FormBuilderPhoneField/Model/Submission/FieldDescriptor/PhoneField.php (the only change)
public function getComponentName(): string
{
    return 'form_field_phone_e164';
}

Everything else carries over unchanged: extractRules() reads required, pattern and pattern_message off the saved node through FieldRuleNormaliser, and ruleKeys() lists those same three keys - the lockstep contract from Step 1 in working code.

Which type token to return

getType() decides which validator handles the field on the server. Returning text reuses the built-in text validator. To enforce stricter, type-specific rules - an E.164 phone check - return a bespoke token and register a matching validator; see Add Custom Validation.

Now add the descriptor to the pool, keyed by component name:

app/code/Acme/FormBuilderPhoneField/etc/di.xml
<type name="Hyva\FormBuilder\Model\Submission\FieldDescriptorPool">
    <arguments>
        <argument name="descriptors" xsi:type="array">
            <item name="form_field_phone_e164" xsi:type="object">
                Acme\FormBuilderPhoneField\Model\Submission\FieldDescriptor\PhoneField
            </item>
        </argument>
    </arguments>
</type>

Magento merges array arguments by item key, so your entry slots in alongside the built-ins without colliding - module-form-builder/src/etc/di.xml registers every shipped descriptor with the same descriptors argument. Once registered, the submission pipeline walks the field while compiling the validation schema, includes its payload key in the validator's known-fields list, and emits a Label: Value row for it in the email body. The same descriptor seeds the storefront Alpine pre-flight, so client and server agree on type and rules. How Validation Works follows those rules from the descriptor to the browser and back to the server.

The descriptor is not optional

Without this entry, the validator rejects submissions for the field with an "unknown field" error. A field that renders but has no descriptor will never submit successfully.

Step 3: Write the Storefront Template

Drop the PHTML file at the convention path in your module and Hyvä CMS resolves it automatically - no template: declaration needed. For this field there is nothing new to write. The shipped phone template renders type="tel" with the merchant's pattern as a native pattern attribute, so the browser still checks it before scripts load and without JavaScript. The rule that is enforced, though, is the compiled one from the descriptor's extractRules(), which seeds the Alpine pre-flight in the browser and the validator on the server. The pattern is compiled by both the browser and the server, so a construction that one dialect rejects falls through to the server, which is the authority.

Copy module-form-builder/src/view/frontend/templates/elements/fields/phone.phtml to view/frontend/templates/elements/form_field_phone_e164.phtml in your module and it works unchanged.

For a field that isn't a variant of a shipped one, the templates under module-form-builder/src/view/frontend/templates/elements/fields/ are the reference shape; start from fields/text.phtml for any single-line input and adapt.

Two non-obvious details apply to every form-field template:

  • The Hyvä CMS renderer flat-sets each JSON node key onto the block, so $block->getData() is the node's property bag - reading $block->getData('data') returns null. Pass the bag to FormRenderer::extractFieldData() for the common properties (uid, field_name, label, help_text, default_value, required plus the derived input_id / help_id / error_id); read only your per-field extras (placeholder, pattern, …) off $data directly.
  • The extractor's field_name is already resolved to the wire name the schema compiler uses, so a blank Field Name still produces a working name attribute - don't derive your own.

The seam that hooks the input into the parent hyvaFormBuilder factory's state is the binding block every shipped field template repeats on its <input> - from phone.phtml:

data-field-name="<?= $escaper->escapeHtmlAttr($fieldName) ?>"
data-field-uid="<?= $escaper->escapeHtmlAttr($uid) ?>"
<?php if ($helpId): ?>aria-describedby="<?= $escaper->escapeHtmlAttr($helpId) ?>"<?php endif; ?>
aria-errormessage="<?= $escaper->escapeHtmlAttr($errorId) ?>"
:aria-invalid="fieldHasError"
<?php if (!$isPreview): ?>:value="fieldValue"<?php endif; ?>
@input="onFieldInput"
@blur="onFieldBlur"

The Alpine bindings are CSP-safe - a static value="…" plus :value="fieldValue", @input="onFieldInput", @blur="onFieldBlur", with the per-field context carried in data-field-name / data-field-uid (no x-model, no syncValue). Keep this binding shape plus the per-field error block and the JSON submission path, error binding and ARIA-live announcements work without any JavaScript from your module.

Repaint the Default Value live in the editor

Render the default into both the static attribute (value="…") and the :value mirror, but omit the mirror when $block->validPreview() is true. In the editor canvas the static attribute drives display, so an edited Default Value repaints live instead of being pinned to the once-seeded Alpine state.

Step 4: Allow the Field Inside a Form

The editor offers a field only where its direct parent admits it: every container declares a closed children.config.accepts list, and a require_parent: true field surfaces nowhere else. For the shipped hyva_form root, fields always live inside a Form Section (form_step), whichever Layout the form uses, so form_step's accepts is the gate. Do not add a field to hyva_form's own accepts, the editor would offer it as a sibling of the sections, outside any of them. A custom root that accepts fields directly instead of form_step is the exception, see the note at the end of this step.

The shipped declaration is a closed list, so admitting a new field means overriding it. Hyvä CMS uses full component overrides, not partial merges: redeclaring a component under the same key in your own components.json replaces the shipped one entirely, so anything you leave out is dropped. Sequence your module after Hyva_FormBuilder in module.xml so your version loads last, see Overriding Existing Components.

For the E.164 field, that means overriding form_step. Copy the whole shipped form_step declaration from module-form-builder/src/etc/hyva_cms/components.json into your own components.json verbatim, then make exactly two changes:

  1. Append "form_field_phone_e164" to the children.config.accepts array.
  2. Pin the template by adding "template": "Hyva_FormBuilder::elements/form_step.phtml" (see the warning below).

Don't retype or trim the declaration - anything that differs from the shipped copy, an entry missing from accepts, a dropped content key, silently changes how every Form Section behaves.

A Form Row (form_row) has its own closed accepts list. Override it the same way: copy its shipped declaration verbatim, append the field to its accepts, and pin its template to Hyva_FormBuilder::elements/form_row.phtml. Without that override the new field can only sit directly in a section, never inside a row.

Pin template when overriding

When a declaration omits template, Hyvä CMS resolves it to the declaring module's convention path - an override without one points at Acme_FormBuilderPhoneField::elements/form_step.phtml, which doesn't exist, and every Form Section stops rendering. Add an explicit "template": "Hyva_FormBuilder::elements/form_step.phtml"; the shipped declaration can omit the template only because it lives in the module that owns it. (The icon path is already module-qualified, so it survives the copy as-is.)

An override couples your module to the shipped declaration's shape, so re-check it when upgrading the Form Builder. When the new field is the centerpiece of a structurally different form, shipping your own root that accepts the field directly avoids the override entirely (see Create a Custom Form Root), but a root built that way sits outside the standard sections-and-fields family, and for the shipped root the form_step override is the only path today.

A registry for accepts lists may come later

A future change may expose accepts lists as their own DI registry to remove this trade-off. Until then, document which parents your module extends so it's clear where the new field appears.

Bespoke Property-Panel Inputs Are Hyvä CMS Custom Fields

The property inputs above use standard Hyvä CMS field types (text, boolean). If a property needs a bespoke editor UI, that's a Hyvä CMS CustomField - not anything form-specific. See the Hyvä CMS Custom Field Types documentation for the full walkthrough.

Reference Implementation

The best way to learn each surface is by reading the code that ships it. In the module-form-builder package:

  • src/etc/hyva_cms/components.json - the shipped field types, with the property panels to mirror in Step 1.
  • src/Model/Submission/FieldDescriptorPool.php and the descriptor classes alongside it - the pattern from Step 2 in working code; FieldDescriptor/PhoneField.php is the one this tutorial copies.
  • src/view/frontend/templates/elements/fields/phone.phtml - the template Step 3 copies, with fields/text.phtml as the generic single-line shape and src/ViewModel/Storefront/FormRenderer.php supplying the common field properties via extractFieldData().
  • src/etc/di.xml - the pool wiring and the CustomField property-panel registration.

These show the patterns from this tutorial in shipped code.

  • How Validation Works - how the rules your descriptor emits reach the browser and the server, and what a failed submission answers.
  • Add Custom Validation - enforce stricter, type-specific server rules on the field you just built.
  • Create a Custom Form Root - ship your own root when the new field is the centerpiece of a different form style.
  • Add a Security Gate - add a captcha provider or extra check in front of email dispatch.
  • Architecture - how the Form Builder fits together, with the map of every extension surface.
  • Building Forms - the fields you ship as they appear in the editor.
  • Creating Components
  • the Hyvä CMS component model your declarations build on.