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 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:

  • required
  • min_length
  • max_length
  • 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 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.

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;

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:

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/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.