Skip to content

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:

  1. Decide which rule keys your descriptor's extractRules() emits.
  2. Implement Hyva\FormBuilder\Api\FieldValidatorInterface.
  3. Register it in FieldValidatorPool via di.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:

  • required
  • min_length
  • max_length, which every shipped free-text descriptor always sets. Each one declares a MAX_LENGTH_CEILING constant and clamps through FieldRuleNormaliser::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.
  • pattern
  • pattern_message
  • allowed_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.

app/code/Acme/FormBuilderPhoneField/Model/Submission/FieldValidator/PhoneValidator.php
<?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:

app/code/Acme/FormBuilderPhoneField/Model/Submission/FieldDescriptor/PhoneField.php
public function getType(): string
{
    return 'phone';
}

Then register the validator under the same phone key:

app/code/Acme/FormBuilderPhoneField/etc/di.xml
<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.php and 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 composes TextValidator for 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.php and FieldRule - how extractRules() output becomes the FieldRule your validator reads.
  • src/etc/di.xml - the pool wiring to mirror in Step 3.

These show the patterns from this tutorial in shipped code.