oasys/validator

Lightweight validator for associative arrays with fluent API, custom rules, nested schemas, attribute binding, and message templating

Maintainers

Package info

github.com/kweensey/oasys-validator

pkg:composer/oasys/validator

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-03-18 15:54 UTC

This package is not auto-updated.

Last update: 2026-04-02 14:34:24 UTC


README

Tests Latest Stable Version PHP Version Require License

Lightweight validator for associative arrays.

  • Object-oriented, fluent interface
  • Extensible rules and format patterns
  • Nested schema validation
  • Field-to-field comparison
  • Attribute-based rule binding
  • Error message templating

Installation

composer require oasys/validator

Quick start

<?php declare(strict_types=1);

use Oasys\Validation\Validator;

$payload = [
    'email' => 'john.doe@email.com',
    'age'   => 17
];

$result = new Validator()
    ->require('email', 'age')
    ->regex('email', '/^[^@]+@[^@]+\.[^@]+$/')
    ->greaterOrEqualTo('age', 18)
    ->validate($payload);
[
    'age' => 'age must be greater than or equal to 18'
]

Built-in validators

Presence and access

require(string ...$fields)

Field must be present and non-empty (null, '', and [] are treated as empty)

Every required field is considered allowed automatically

$payload = [];
// - or -
$payload = [
    'email' => ''
];

$result = new Validator()
    ->require('email')
    ->validate($payload);
[
    'email' => 'email is required'
]

You can supply multiple fields at once and stack them

$validator = new Validator();

$validator->require('email', 'username');

if (true) {
    $validator->require('role');
}

$result = $validator->validate([]);
[
    'email'    => 'email is required',
    'username' => 'username is required',
    'role'     => 'role is required'
]

allow(string ...$fields)

Field can be present, unlisted fields will be rejected

If omitted, all fields are considered allowed

You can supply multiple fields at once and stack them

$payload = [
    'email' => 'john.doe@email.com',
    'city'  => 'Prague'
];

$result = new Validator()
    ->allow('email')
    ->validate($payload);
[
    'city' => 'city is not allowed'
]

notEmpty(string ...$fields)

If present, field must be non-empty

You can supply multiple fields at once and stack them

$payload = [
    'email' => ''
];

$result = new Validator()
    ->notEmpty('email')
    ->validate($payload);
[
    'email' => 'email cannot be empty'
]

...but

$result = new Validator()
    ->notEmpty('email')
    ->validate([]);
[] // valid, no errors

requireOn(string $field, string $conditionField, ?string $message = null)

If $conditionField is present and non-empty, field must be present and non-empty

$payload = [
    'company' => 'MyCorp Ltd.'
];

$result = new Validator()
    ->requireOn('vat_id', 'company')
    ->validate($payload);
[
    'vat_id' => 'vat_id is required when company is set'
]

requireWhen(string $field, string $conditionField, mixed $conditionValue, ?string $message = null)

If $conditionField equals $conditionValue, field must be present and non-empty

$payload = [
    'country' => 'US'
];

$result = new Validator()
    ->requireWhen('state', 'country', 'US')
    ->validate($payload);
[
    'state' => 'state is required when country is US'
]

Format and type

regex(string $field, string $pattern, ?string $message = null)

If present and non-empty, field must match given regex pattern

Used for one-off patterns, for repeating patterns use format() (see below)

$payload = [
    'zip' => '12C45'
];

$result = new Validator()
    ->regex('zip', '/^\d{5}$/')
    ->validate($payload);
[
    'zip' => 'zip has invalid format'
]

format(string $field, string $type, ?string $message = null)

If present and non-empty, field must match predefined regex pattern of the given alias

You can supply regex pattern aliases as array<aliasName, regexPattern> to the constructor's first parameter

$payload = [
    'departure' => '23:60'
];

$validator = new Validator(
    patterns: [
        'time' => '/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/'
    ]
);

$result = $validator
    ->format('departure', 'time')
    ->validate($payload);
[
    'departure' => 'departure has invalid format'
]

type(string $field, string $type, ?string $message = null)

If present and non-empty, field must match the given PHP type

Can check against native types, classes, or interfaces

Aliases available:

alias type
int integer
bool boolean
float double
Native types or aliases
$payload = [
    'amount' => 100.5
];

$result = new Validator()
    ->type('amount', 'int')
    ->validate($payload);
[
    'amount' => 'amount must be of type integer'
]
Classes and interfaces
class Money {}

class Price {}

$payload = [
    'amount' => new Price()
];

$result = new Validator()
    ->type('amount', Money::class)
    ->validate($payload);
[
    'amount' => 'amount must be of type Money'
]

...but

interface Money {}

class Price implements Money {}

$payload = [
    'amount' => new Price()
];

$result = new Validator()
    ->type('amount', Money::class)
    ->validate($payload);
[] // valid, no errors

Comparisons

Comparison rules use native PHP loose-comparison; for strict validation, ensure same type with type()

greaterThan(string $field, mixed $compareValue, ?string $message = null)

greaterOrEqualTo(string $field, mixed $compareValue, ?string $message = null)

lessThan(string $field, mixed $compareValue, ?string $message = null)

lessOrEqualTo(string $field, mixed $compareValue, ?string $message = null)

equalTo(string $field, mixed $compareValue, ?string $message = null)

If present and non-empty, field must be greater than (greater than or equal to, less than, less than or equal to, or equal to) $compareValue (if non-empty)

$payload = [
    'age' => 17
];

$result = new Validator()
    ->greaterThan('age', 17)
    ->validate($payload);
[
    'age' => 'age must be greater than 17'
]

greaterThanField(string $field, string $compareField, ?string $message = null)

greaterOrEqualToField(string $field, string $compareField, ?string $message = null)

lessThanField(string $field, string $compareField, ?string $message = null)

lessOrEqualToField(string $field, string $compareField, ?string $message = null)

equalToField(string $field, string $compareField, ?string $message = null)

If present and non-empty, field must be greater than (greater than or equal to, less than, less than or equal to, or equal to) $compareField's value (if present and non-empty)

$payload = [
    'start_date' => '2025-02-09',
    'end_date'   => '2025-01-01'
];

$result = new Validator()
    ->greaterThanField('end_date', 'start_date')
    ->validate($payload);
[
    'end_date' => 'end_date must be greater than field start_date'
]

Number and string constraints

maxLength(string $field, int $maxLength, ?string $message = null)

minLength(string $field, int $minLength, ?string $message = null)

length(string $field, int $length, ?string $message = null)

If present and non-empty, field must have a maximum of (minimum of, or exactly) specified number of characters

$payload = [
    'tag' => '2long'
];

$result = new Validator()
    ->maxLength('tag', 3)
    ->validate($payload);
[
    'tag' => 'tag must have a maximum length of 3 characters'
]

percent(string $field, ?string $message = null)

If present and non-empty, field must be a valid percentage (0-100)

$payload = [
    'discount' => 150
];

$result = new Validator()
    ->percent('discount')
    ->validate($payload);
[
    'discount' => 'discount must be a valid percentage'
]

Array membership and constraints

in(string $field, array $values, ?string $message = null)

If present and non-empty, field must be one of the specified values

$payload = [
    'role' => 'owner'
];

$result = new Validator()
    ->in('role', ['admin', 'editor', 'viewer'])
    ->validate($payload);
[
    'role' => 'role must be one of the specified values: admin, editor, viewer'
]

inEnum(string $field, string $enum, ?string $message = null)

If present and non-empty, field must be one of the enum's values

enum Role: string
{
    case ADMIN  = 'admin';
    case EDITOR = 'editor';
    case VIEWER = 'viewer';
}

$payload = [
    'role' => 'owner'
];

$result = new Validator()
    ->inEnum('role', Role::class)
    ->validate($payload);
[
    'role' => 'role must be one of the specified values: admin, editor, viewer'
]

itemsList(string $field, ?string $message = null)

If present and non-empty, field must be a list

$payload = [
    'categories' => [
        0 => 'foo',
        1 => 'bar',
        3 => 'baz'
    ]
];

$result = new Validator()
    ->itemsList('categories')
    ->validate($payload);
[
    'categories' => 'categories must be a list'
]

itemsIn(string $field, array $values, ?string $message = null)

If present and non-empty, all items in the field must be one of the specified values

$payload = [
    'categories' => ['foo', 'bar', 'baz']
];

$result = new Validator()
    ->itemsIn('categories', ['foo', 'bar'])
    ->validate($payload);
[
    'categories' => 'categories items must be one of the specified values: foo, bar'
]

itemsMax(string $field, int $maxCount, ?string $message = null)

itemsMin(string $field, int $minCount, ?string $message = null)

itemsCount(string $field, int $count, ?string $message = null)

If present and non-empty, field must have a maximum (minimum, or exact) specified count of items

$payload = [
    'ids' => [123, 456, 789]
];

$result = new Validator()
    ->itemsMax('ids', 2)
    ->validate($payload);
[
    'ids' => 'ids must have a maximum count of 2 items'
]

schema(string $field, self $schema, ?string $message = null)

If present and non-empty, field must be an array and pass the nested validation

The first nested error message is delegated into the message template as {error} (see Messages and templating section)

$payload = [
    'address' => [
        'city' => 'Prague'
    ]
];

$result = new Validator()
    ->schema('address', new Validator()
        ->require('city')
        ->require('zip'))
    ->validate($payload);
[
    'address' => 'address has invalid value: zip is required'
]

Custom callbacks

You can define your own rules and apply them to payload fields

One-off validation

custom(string $field, callable $callback, ?string $message = null, ...$args)

Validate using provided callback

$payload = [
    'number' => 15
];

$result = new Validator()
    ->custom(
        'number',
        fn (mixed $value): bool => intval($value) % 2 === 0,
        'Must be an even number'
    )
    ->validate($payload);
[
    'number' => 'Must be an even number'
]

Reusable validation

register(string $name, callable $callback, ?string $message = null)

Register a new rule...

$validator = new Validator()
    ->register(
        'even',
        fn (mixed $value): bool => intval($value) % 2 === 0,
        'Must be an even number'
    );

...and use it like a built-in

$payload = [
    'number' => 15
];

$result = $validator
    ->even('number')
    ->validate($payload);
[
    'number' => 'Must be an even number'
]

Empty values

Every applied rule validates the field regardless of its presence or value

If you want to skip on empty values just like built-ins do, use Validator::isEmpty() check

$payload = [
    'number' => ''
];

$validator = new Validator()
    ->register(
        'even',
        fn (mixed $value): bool => Validator::isEmpty($value)
            || intval($value) % 2 === 0,
        'Must be an even number'
    );

$result = $validator
    ->even('number')
    ->validate($payload);
[] // valid, no errors

Callback parameters

If a validation callback accepts additional parameters, you can supply them as additional variadic parameters

$validator = new Validator()
    ->custom(
        'alarm',
        fn (mixed $value, bool $format24 = true): bool => is_int($value) && $format24
            ? $value <= 23 && $value >= 0
            : $value <= 12 && $value >= 1,
        'Must be a valid hour',
        false
    );

...or

$validator = new Validator()
    ->register(
        'hour',
        fn (mixed $value, bool $format24 = true): bool => is_int($value) && $format24
            ? $value <= 23 && $value >= 0
            : $value <= 12 && $value >= 1,
        'Must be a valid hour',
    );

// ...

$validator->hour('alarm', false);

Positional and named parameters

Registered custom rules with additional parameters can be called with positional and/or named parameters

$validator->hour('alarm', format24: true);
// - or -
$validator->hour('alarm', true);

Variadic parameters

When passing a variadic parameter by name, it must be supplied as an array (even with a single value)

$validator = new Validator()
    ->register(
        'oneOf',
        fn (mixed $value, mixed ...$values): bool => in_array($value, $values, true),
        'Must be one of the allowed values'
    );

// ...

$validator->oneOf('category', values: ['one', 'two', 'three']);
// - or -
$validator->oneOf('category', 'one', 'two', 'three');

Callback types

You can supply validation callback in multiple ways

Provided callback must accept value as a first parameter

Applies to both custom() and register() functions

Anonymous function

$validator = new Validator()
    ->register(
        'even',
        fn (mixed $value): bool => intval($value) % 2 === 0,
        'Must be an even number'
    );

Function name string

function is_even(mixed $value): bool
{
    return intval($value) % 2 === 0;
}

$validator = new Validator()
    ->register(
        'even',
        'is_even',
        'Must be an even number'
    );

Static method string

class NumberUtility
{
    public static function is_even(mixed $value): bool
    {
        return intval($value) % 2 === 0;
    }
}

$validator = new Validator()
    ->register(
        'even',
        NumberUtility::class . '::is_even',
        'Must be an even number'
    );

Invokable object

class IsEven
{
    public function __invoke(mixed $value): bool
    {
        return intval($value) % 2 === 0;
    }
}

$validator = new Validator()
    ->register(
        'even',
        new IsEven(),
        'Must be an even number'
    );

Instance method array callable

class NumberUtility
{
    public function is_even(mixed $value): bool
    {
        return intval($value) % 2 === 0;
    }
}

$numberUtility = new NumberUtility();

$validator = new Validator()
    ->register(
        'even',
        [$numberUtility, 'is_even'],
        'Must be an even number'
    );

Static method array callable

class NumberUtility
{
    public static function is_even(mixed $value): bool
    {
        return intval($value) % 2 === 0;
    }
}

$validator = new Validator()
    ->register(
        'even',
        [NumberUtility::class, 'is_even'],
        'Must be an even number'
    );

Attribute binding

You can apply rules using attributes and bind them to the validator

Validation metadata

use Oasys\Validation\ValidationAttribute;

class RegisterUserDto
{
    #[ValidationAttribute('require')]
    #[ValidationAttribute('maxLength', 255)]
    public string $email;

    #[ValidationAttribute('minLength', 8)]
    public string $password;
}

Binding rules

bind(string $fqcn, ?string $prefix = null, string $separator = '-')

$payload = [
    'email'    => '',
    'password' => 'secret'
];

$result = new Validator()
    ->bind(RegisterUserDto::class)
    ->validate($payload);
[
    'email'    => 'email is required',
    'password' => 'password must have a minimum length of 8 characters'
]

Field prefix

$payload = [
    'account-email'    => '',
    'account-password' => 'secret'
];

$result = new Validator()
    ->bind(RegisterUserDto::class, 'account')
    ->validate($payload);
[
    'account-email'    => 'account-email is required',
    'account-password' => 'account-password must have a minimum length of 8 characters'
]

...or define your own separator

$payload = [
    'account.email'    => '',
    'account.password' => 'secret'
];

// ...

$validator->bind(RegisterUserDto::class, 'account', '.');

Messages and templating

Per-rule message override

You can override rule's default error message by supplying your own as the last parameter

require(), allow() and notEmpty() don't take custom messages as a parameter, use global dictionary override (see below) instead

$payload = [
    'discount' => 150
];

$result = new Validator()
    ->percent('discount', 'Must be a number 0-100')
    ->validate($payload);
[
    'discount' => 'Must be a number 0-100'
]

Global dictionary override

You can override selected or all default error messages by supplying your own as array<functionName, errorMessage> to the constructor's second parameter

$payload = [
    'title' => ''
];

$validator = new Validator(
    messages: [
        'require' => 'Required field'
    ]
);

$result = $validator
    ->require('title')
    ->validate($payload);
[
    'title' => 'Required field'
]

Template variables

If a validation callback accepts additional parameters, you can include their values in your error messages using {parameterName} notation

Field's name is always available as {field}

$payload = [
    'number' => 15
];

$result = new Validator()
    ->custom(
        'number',
        fn (mixed $value, int $min, int $max): bool => intval($value) >= $min && intval($value) <= $max,
        '{field} must be between {min} and {max}',
        20,
        50
    )
    ->validate($payload);
[
    'number' => 'number must be between 20 and 50'
]

Design notes

  • Only the first failed rule is returned for each field
  • Require and allow checks run before field rules
  • Built-in rules skip empty fields, ensure presence with require() or notEmpty()
  • If key is not present in the data, null is passed as value to the callback
  • Rules are accumulated on the validator instance, reuse intentionally