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 stored 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.
A bespoke type token like phone runs on the server only. The browser doesn't know the
token, so it applies the text-style checks (required, lengths, pattern) and lets the
submission through. Your validator's message comes back in the HTTP 400 envelope, keyed
by field name, and the form script paints it under the field. See
How Validation Works for the full client/server picture.
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_length, which every shipped free-text descriptor always sets. Each one declares aMAX_LENGTH_CEILINGconstant and clamps throughFieldRuleNormaliser::maxLengthWithin($merchantValue, self::MAX_LENGTH_CEILING), so an editor-configured limit can lower the ceiling but never raise it. A custom free-text descriptor should do the same.patternpattern_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 applies the E.164 check first, then hands the FieldRule
to the shipped TextValidator so the editor-configured pattern and length limits still run.
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;
use Hyva\FormBuilder\Model\Submission\Validator\TextValidator;
class PhoneValidator implements FieldValidatorInterface
{
// Composing the shipped text validator keeps the editor's pattern and length rules in force.
public function __construct(private readonly TextValidator $textValidator)
{
}
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.');
}
// Length ceiling and the editor-configured pattern, matched exactly as the text field does.
return $this->textValidator->validate($rule, $rawValue);
}
}
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.
Match editor patterns through MerchantPattern
If your rule includes the editor-configured pattern, match it through
Hyva\FormBuilder\Model\Submission\MerchantPattern rather than delimiting it yourself.
It applies the same whole-value match the publish-time compile check uses, and matches()
returns null for a pattern that doesn't compile, which the shipped TextValidator
treats as a mismatch. Even simpler, compose
TextValidator for length and pattern after your own check, as the example above does.
UrlValidator is the shipped equivalent: it checks for an absolute http or https URL,
then hands off to TextValidator.
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/Validator/UrlValidator.php- a validator that adds its own check, then composesTextValidatorfor length and pattern.src/Model/Submission/MerchantPattern.php- how editor-configured patterns are delimited, compile-checked and matched.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.
- How Validation Works - the client pre-flight, the server schema, and the 400 error envelope your messages travel in.
- 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.