Add Custom Validation
This tutorial walks you through adding server-side validation for a custom field type. Building on the phone field from the previous guide, we'll add a validator that enforces an E.164-style phone number on the server, a semantic check no regex pattern configured in the editor can express as reliably.
You'll do three things:
- Decide which rule keys your descriptor's
extractRules()emits. - Implement
Hyva\FormBuilder\Api\FieldValidatorInterface. - Register it in
FieldValidatorPoolviadi.xml, keyed by your descriptor's type token.
The server is authoritative
The storefront pre-flight mirrors the compiled rules (required, lengths, pattern) so a customer sees an error before they hit submit. That's a convenience, not a guarantee. The server recompiles the validation schema from the published content tree on every POST and enforces it there. Never rely on the client having run; treat the server validator as the only real gate. See the architecture overview for the full client/server trust boundary.
Step 1: Decide Your Rule Keys
The descriptor's extractRules() output is the contract: SchemaCompiler builds the
FieldRule straight from it, and PayloadValidator enforces it. Reuse the built-in keys
wherever you can so the storefront pre-flight stays aligned with the server:
requiredmin_lengthmax_lengthpatternpattern_messageallowed_values
For the phone field, the pattern already lives in a built-in key, so the descriptor from
the previous guide needs no changes. Its extractRules() already emits pattern and
pattern_message. Your validator will read those off the FieldRule and apply the E.164
check on top of the generic pattern match.
Only reach for new keys when the built-ins don't fit
A date range, an uploaded file's MIME type, or a checksum are cases where you'd add a
bespoke key to the rule bag. When you do, emit it from extractRules() and read it back
off the FieldRule in your validator.
Step 2: Implement the Validator
Implement Hyva\FormBuilder\Api\FieldValidatorInterface. Its single method
validate(FieldRule $rule, mixed $rawValue): ?string returns the error message string when
the value is invalid, or null when it passes.
<?php
declare(strict_types=1);
namespace Acme\FormBuilderPhoneField\Model\Submission\FieldValidator;
use Hyva\FormBuilder\Api\FieldValidatorInterface;
use Hyva\FormBuilder\Model\Submission\FieldRule;
class PhoneValidator implements FieldValidatorInterface
{
public function validate(FieldRule $rule, mixed $rawValue): ?string
{
$value = is_string($rawValue) ? trim($rawValue) : '';
if ($value === '') {
return $rule->required ? (string)__('This field is required.') : null;
}
// E.164: optional leading +, 8 to 15 digits, nothing else.
if (!preg_match('/^\+?[0-9]{8,15}$/', preg_replace('/[ .\-]/', '', $value))) {
return $rule->patternMessage ?: (string)__('Enter a valid phone number.');
}
return null;
}
}
Empty and required are your responsibility
The pool dispatches every field to its validator, including empty optional fields.
Handle the empty case yourself, returning null for a blank optional field and the
required message for a blank required one, before applying type-specific logic. Prefer
the patternMessage set in the editor when it's present so error copy stays editable.
Step 3: Register the Validator
Add the validator to FieldValidatorPool, keyed by the type token your descriptor's
getType() returns. PayloadValidator dispatches each field to
pool->get($rule->type)->validate(...), falling back to the text validator for unknown
tokens.
For this to fire, the phone descriptor from the previous guide must return the matching
token from getType(). Change it from text to phone:
public function getType(): string
{
return 'phone';
}
Then register the validator under the same phone key:
<type name="Hyva\FormBuilder\Model\Submission\FieldValidatorPool">
<arguments>
<argument name="validators" xsi:type="array">
<item name="phone" xsi:type="object">
Acme\FormBuilderPhoneField\Model\Submission\FieldValidator\PhoneValidator
</item>
</argument>
</arguments>
</type>
This is the same merge-by-item-name mechanism as FieldDescriptorPool, so your validator
slots in alongside the built-ins without colliding.
The token must match on both sides
The descriptor's getType() and the validator's pool key are the join. If they
disagree, the pool falls back to the text validator and your E.164 check silently never
runs; the field validates as plain text instead. Keep the token identical in both
places.
Cross-Field Checks
The validator pool is per-field: each validator sees one field's rule and one raw value, never the
whole payload. When a rule spans fields (confirm-email must equal email, a total must sum
its parts), a plugin on Hyva\FormBuilder\Model\Submission\PayloadValidator::validate()
remains the option, since it sees the full submitted set. Reach for the pool for per-type
rules and the plugin only for genuinely cross-field logic.
Reference Implementation
In the module-form-builder package:
src/Api/FieldValidatorInterface.php- the interface you implement in Step 2.src/Model/Submission/FieldValidatorPool.phpand the shipped validators alongside it - the per-type validators the pool ships with, and the text fallback.src/Model/Submission/PayloadValidator.php- where the pool is dispatched, and the method to plug into for cross-field checks.src/Model/Submission/SchemaCompiler.phpandFieldRule- howextractRules()output becomes theFieldRuleyour validator reads.src/etc/di.xml- the pool wiring to mirror in Step 3.
These show the patterns from this tutorial in shipped code.
Related Topics
- Create a Custom Field - build the field and descriptor this validation attaches to.
- Create a Custom Form Root - ship a root that admits your validated field.
- Add a Security Gate - the security checks that run before validation, in front of email dispatch.
- Architecture - the map of every extension surface.
- Building Forms - the fields you validate as they appear in the editor.
- Creating Components
- the Hyvä CMS component model your field declarations build on.