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 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):
{
"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.
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 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. Which declaration to touch depends on the form structure:
- Single-page forms - the fields sit directly inside the root, so the root's
acceptsis the gate (single_step_form, or a custom root that accepts fields directly). - Step-based forms - the fields live inside the
form_stepcontainer, soform_step'sacceptsis 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:
- 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 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.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
- 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.