Skip to content

Custom validators and filters

Implementing a validator

Implement Verja\ValidatorInterface or extend Verja\Validator (the base class handles the error storage for you):

use Verja\Validator;
use Verja\Error;

class DomainEmail extends Validator
{
    public function __construct(private string $domain) {}

    public function validate($value, array $context = []): bool
    {
        if (!str_ends_with($value, '@' . $this->domain)) {
            $this->error = new Error(
                'WRONG_DOMAIN',
                $value,
                "value must be an email address at {$this->domain}",
                ['domain' => $this->domain]
            );
            return false;
        }
        return true;
    }
}

Use it directly as an object:

->string('email', 'required', new DomainEmail('example.com'))

Error object

new Error(
    'ERROR_KEY',      // machine-readable code (SCREAMING_SNAKE_CASE by convention)
    $value,           // the value that failed (always included in parameters as 'value')
    'human message',  // shown to developers/users
    ['extra' => ...]  // additional parameters merged into Error->parameters
);

Return true from validate() on success, false on failure (and set $this->error).

Negating with !

Any validator can be negated with a ! prefix in string definitions passed to Verja::fromString() or Validator::fromString():

'!notEmpty'   // value must be empty

This wraps the validator in Verja\Validator\Not, which inverts the result and swaps the error key (e.g. NOT_EMPTYIS_EMPTY, or more generally prepends/removes NOT_). ! does not work for Filter, Converter, or NullPolicy string definitions.

Implementing a filter

Implement Verja\FilterInterface or extend Verja\Filter:

use Verja\Filter;

class Slugify extends Filter
{
    public function filter($value, array $context = []): mixed
    {
        return strtolower(preg_replace('/[^a-z0-9]+/i', '-', trim($value)));
    }
}

If the value is unsuitable for filtering (wrong type, invalid format), throw Verja\Exception\InvalidValue — this short-circuits the pipeline and adds an error:

use Verja\Filter;
use Verja\Gate;
use Verja\Exception\InvalidValue;

class JsonDecode extends Filter
{
    public function filter($value, array $context = []): mixed
    {
        $decoded = json_decode($value, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            $e = new InvalidValue('Invalid JSON');
            $e->errors = [new \Verja\Error('INVALID_JSON', $value, 'value must be valid JSON')];
            throw $e;
        }
        return $decoded;
    }
}

Implementing a converter

Implement Verja\ConverterInterface or extend Verja\Converter. A converter transforms raw input into a typed PHP value at Stage 1, before null handling and filters:

use Verja\Converter;
use Verja\ConvertResult;
use Verja\Error;

class YesNo extends Converter
{
    public function convert(mixed $value): ConvertResult
    {
        if ($value === null || $value === '') {
            return ConvertResult::null();
        }
        if ($value === 'yes') return ConvertResult::valid(true);
        if ($value === 'no')  return ConvertResult::valid(false);
        return ConvertResult::invalid(new Error('NOT_YES_NO', $value, 'must be "yes" or "no"'));
    }
}

Return ConvertResult::null() for absent/empty input (proceeds to Stage 2), valid($typed) on success (skips Stage 2), or invalid($error) on bad format (stops the pipeline immediately).

Implementing a null policy

Implement Verja\NullPolicyInterface or extend Verja\NullPolicy. A null policy runs at Stage 2 when the value is null or '':

use Verja\NullPolicy;
use Verja\NullPolicyResult;

class FallbackToEmpty extends NullPolicy
{
    public function apply(mixed $value, array $context = []): NullPolicyResult
    {
        return NullPolicyResult::shortCircuit('');
    }
}

Return NullPolicyResult::shortCircuit($value) to put a value into data and stop, reject($error) to fail validation, or skip() to omit the field from the result.

Namespace registry

Register your namespace once and use string definitions everywhere:

// Register at bootstrap time
Verja\Converter::registerNamespace('App\\Converter');
Verja\NullPolicy::registerNamespace('App\\NullPolicy');
Verja\Validator::registerNamespace('App\\Validator');
Verja\Filter::registerNamespace('App\\Filter');

// Now use string definitions as usual
$gate = (new Gate())
    ->any('active', 'yesNo', 'required')
    ->string('email', 'required', 'domainEmail:example.com')
    ->string('slug',  'required', 'slugify');
//           ↑ App\Converter\YesNo
//                              ↑ App\Validator\DomainEmail with param 'example.com'
//                                               ↑ App\Filter\Slugify

Namespaces are searched in LIFO order (last registered wins), so app namespaces take precedence over built-in ones. Class names are matched case-insensitively with ucfirst.

Multiple namespaces can be registered:

Verja\Validator::registerNamespace('App\\Validator');
Verja\Validator::registerNamespace('App\\Validator\\Specialized');

Aliases

For reserved PHP keywords or short names, register an alias:

use Verja\Verja;

Verja::addAlias('yesno', \App\Converter\YesNo::class);

// Now usable anywhere in string definitions
->any('active', 'yesno', 'required')

Built-in aliases: intConverter\Integer, boolConverter\Boolean, defaultNullPolicy\DefaultValue, omitNullPolicy\Optional.

Callback shorthand

For one-off validations without a class:

use Verja\Error;

$gate = (new Gate())
    ->string('username', 'required', function ($value, array $context) use ($db) {
        if ($db->exists('users', ['username' => $value])) {
            return new Error('USERNAME_TAKEN', $value, 'username is already taken');
        }
        return true; // valid
    });

A callable validator must return true on success or a Verja\Error on failure.

For filters, pass a callable that receives the value and returns the transformed value:

->string('name', 'required', function ($value) {
    return ucwords(strtolower(trim($value)));
})

Whether a callable is treated as a filter or validator depends on the return type: callables passed to addFilterOrValidator() are treated as validators. To explicitly add as a filter, use addFilter($callable) on the gate directly.