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:
- A Hyvä CMS component declaration with its property panel.
- A
FieldDescriptorclass registered in the submission pipeline. - A storefront template at the convention path.
- An entry in the field's parent's
acceptslist, 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):
{
"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:
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:
<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 toFormRenderer::extractFieldData()for the common properties (uid,field_name,label,help_text,default_value,requiredplus the derivedinput_id/help_id/error_id); read only your per-field extras (placeholder,pattern, …) off$datadirectly. - The extractor's
field_nameis already resolved to the wire name the schema compiler uses, so a blank Field Name still produces a workingnameattribute - 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:
- Append
"form_field_phone_e164"to thechildren.config.acceptsarray. - 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.phpand the descriptor classes alongside it - the pattern from Step 2 in working code;FieldDescriptor/PhoneField.phpis the one this tutorial copies.src/view/frontend/templates/elements/fields/phone.phtml- the template Step 3 copies, withfields/text.phtmlas the generic single-line shape andsrc/ViewModel/Storefront/FormRenderer.phpsupplying the common field properties viaextractFieldData().src/etc/di.xml- the pool wiring and theCustomFieldproperty-panel registration.
These show the patterns from this tutorial in shipped code.
Related Topics
- 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.