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 root for single-page forms, the Form Step container for step-based forms - 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 roots 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.

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 already renders type="tel" with the pattern / data-pattern-msg pair the stricter validation rides on. 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. Which declaration to touch depends on the form structure:

  • Single-page forms - the fields sit directly inside the root, so the root's accepts is the gate (single_step_form, or a custom root that accepts fields directly).
  • Step-based forms - the fields live inside the form_step container, so form_step's accepts is the gate. Do not add a field to a step-based root's accepts: the editor would offer it as a sibling of the steps, outside any step.

The shipped declarations are closed lists, so admitting a new field means overriding the parent's declaration. 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 inside step-based forms, 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 form steps behave.

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 step 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 different single-page form style, shipping your own root that accepts the field directly avoids the override entirely (see Create a Custom Form Root), but for step-based forms 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.