Skip to content

Validation

Ray Fung edited this page Feb 26, 2026 · 3 revisions

Validation

Razy provides a comprehensive validation system with field validators, nested data support (dot-notation wildcards), form requests, and 14 built-in rules. The system follows a pipeline pattern where rules execute sequentially with bail-on-failure support.


Table of Contents


Quick Start

use Razy\Validation\Validator;
use Razy\Validation\Rule\{Required, Email, MinLength};

// One-shot validation
$result = Validator::make($_POST, [
    'name'  => [new Required(), new MinLength(2)],
    'email' => [new Required(), new Email()],
]);

if ($result->fails()) {
    $errors = $result->errors();
    // ['name' => ['Name is required.'], 'email' => [...]]
}

$validated = $result->validated();
// ['name' => 'John', 'email' => 'john@example.com']

Validator

The Validator class orchestrates multi-field validation with before/after hooks:

use Razy\Validation\Validator;
use Razy\Validation\Rule\{Required, Email, MinLength, Between};

$validator = new Validator($_POST);

// Define fields and their rules
$validator->field('username')
    ->rule(new Required())
    ->rule(new MinLength(3));

$validator->field('email')
    ->rule(new Required())
    ->rule(new Email());

$validator->field('age')
    ->rule(new Required())
    ->rule(new Between(18, 120));

// Set defaults for missing fields
$validator->defaults([
    'role' => 'user',
    'active' => true,
]);

// Stop on first field failure
$validator->stopOnFirstFailure();

// Before hook ??transform data before validation
$validator->before(function (array &$data) {
    $data['email'] = strtolower(trim($data['email'] ?? ''));
});

// After hook ??post-validation processing
$validator->after(function (array $validated, array $errors) {
    if (empty($errors)) {
        // log successful validation
    }
});

$result = $validator->validate();

Bulk Field Definition

$validator->fields([
    'name'  => [new Required(), new MinLength(2)],
    'email' => [new Required(), new Email()],
    'role'  => [new In(['admin', 'user', 'editor'])],
]);

FieldValidator

Each field gets its own FieldValidator that manages a rule pipeline:

use Razy\Validation\FieldValidator;
use Razy\Validation\Rule\{Required, MinLength, Regex};

$field = new FieldValidator('username');

// Add rules individually
$field->rule(new Required());
$field->rule(new MinLength(3));
$field->rule(new Regex('/^[a-zA-Z0-9_]+$/'));

// Or in bulk
$field->rules([new Required(), new MinLength(3)]);

// Bail on first failure (default: true)
$field->bail(true);

// Conditional rules
$field->when($isAdmin, function (FieldValidator $f) {
    $f->rule(new MinLength(5));
});

// Validate a single value
$result = $field->validate('john_doe', $_POST);
// Returns: ['value' => 'john_doe', 'errors' => []]

Validation Rules

All 14 built-in rules extend ValidationRule and implement ValidationRuleInterface. Every rule (except Required) skips validation on null/empty values ??add Required first if presence is mandatory.

Required

use Razy\Validation\Rule\Required;

// Fails on null, empty string, empty array
$validator->field('name')->rule(new Required());

Email

use Razy\Validation\Rule\Email;

$validator->field('email')->rule(new Email());
// Uses FILTER_VALIDATE_EMAIL

Url

use Razy\Validation\Rule\Url;

$validator->field('website')->rule(new Url());
// Uses FILTER_VALIDATE_URL

Numeric

use Razy\Validation\Rule\Numeric;

$validator->field('price')->rule(new Numeric());
// Uses is_numeric() ??accepts ints, floats, numeric strings

MinLength / MaxLength

use Razy\Validation\Rule\{MinLength, MaxLength};

$validator->field('password')
    ->rule(new MinLength(8))     // mb_strlen >= 8
    ->rule(new MaxLength(128));  // mb_strlen <= 128

Between

use Razy\Validation\Rule\Between;

// Numeric value must be in [min, max] inclusive
$validator->field('age')->rule(new Between(18, 120));
$validator->field('price')->rule(new Between(0.01, 9999.99));

Regex

use Razy\Validation\Rule\Regex;

$validator->field('slug')->rule(new Regex('/^[a-z0-9\-]+$/'));

In

use Razy\Validation\Rule\In;

// Loose comparison (default)
$validator->field('status')->rule(new In(['active', 'inactive', 'pending']));

// Strict comparison
$validator->field('type')->rule(new In([1, 2, 3], strict: true));

IsArray

use Razy\Validation\Rule\IsArray;

$validator->field('tags')->rule(new IsArray());

Date

use Razy\Validation\Rule\Date;

// Any parseable date
$validator->field('birthday')->rule(new Date());

// Specific format
$validator->field('invoice_date')->rule(new Date('Y-m-d'));

Confirmed

use Razy\Validation\Rule\Confirmed;

// Looks for {field}_confirmation in data
$validator->field('password')->rule(new Confirmed());
// Checks $_POST['password_confirmation'] matches

// Custom confirmation field name
$validator->field('password')->rule(new Confirmed('password_repeat'));

Callback

use Razy\Validation\Rule\Callback;

// Custom validation logic
$validator->field('username')->rule(new Callback(function (mixed $value, string $field, array $data) {
    // Return true/false
    return !in_array($value, ['admin', 'root', 'system']);
}));

// Can also return transformed value
$validator->field('tags')->rule(new Callback(function (mixed $value) {
    if (is_string($value)) {
        return explode(',', $value); // transform string to array
    }
    return $value;
}));

Each

Validates each element of an array against a set of rules:

use Razy\Validation\Rule\{Each, Required, Email};

// Validate that every email in the list is valid
$validator->field('emails')
    ->rule(new IsArray())
    ->rule(new Each([new Required(), new Email()]));

// Access per-item errors
$each = new Each([new Required(), new MinLength(3)]);
$each->validate(['ab', '', 'hello'], 'items', []);
$itemErrors = $each->getItemErrors();
// [0 => ['items.0 must be at least 3 characters.'], 1 => ['items.1 is required.']]

Custom Rules

Create custom validation rules by extending ValidationRule:

use Razy\Validation\ValidationRule;

class UniqueEmail extends ValidationRule
{
    public function __construct(
        private readonly UserRepository $repo
    ) {}

    public function validate(mixed $value, string $field, array $data): mixed
    {
        if ($this->repo->emailExists($value)) {
            $this->fail();
        }
        return $value;
    }

    protected function defaultMessage(): string
    {
        return ':field is already taken.';
    }
}

// Usage
$validator->field('email')
    ->rule(new Required())
    ->rule(new Email())
    ->rule(new UniqueEmail($userRepo));

Custom Error Messages

Override the default message on any rule:

$validator->field('email')
    ->rule((new Required())->withMessage('Please provide your email address.'))
    ->rule((new Email())->withMessage('That doesn\'t look like a valid email.'));

The :field placeholder is automatically replaced with the field name.


ValidationResult

An immutable value object containing validation results:

$result = $validator->validate();

// Pass/fail checks
$result->passes();  // bool
$result->fails();   // bool

// Get validated data (only fields that passed)
$data = $result->validated();
// ['name' => 'John', 'email' => 'john@example.com']

// Get a single validated value
$name = $result->get('name');
$role = $result->get('role', 'default');

// Error access
$allErrors = $result->errors();
// ['email' => ['Email is required.', 'Email is invalid.']]

$emailErrors = $result->errorsFor('email');
// ['Email is required.', 'Email is invalid.']

$firstEmailError = $result->firstError('email');
// 'Email is required.'

$firstErrors = $result->firstErrors();
// ['email' => 'Email is required.', 'name' => 'Name is required.']

$hasEmailError = $result->hasError('email'); // bool

// Flat list of all error messages
$flat = $result->allErrors();
// ['Email is required.', 'Email is invalid.', 'Name is required.']

$count = $result->errorCount(); // total number of errors

NestedValidator

Validates deeply nested data structures using dot-notation paths and wildcard expansion (*):

use Razy\Validation\NestedValidator;
use Razy\Validation\Rule\{Required, Email, MinLength, Numeric, IsArray};

$data = [
    'user' => [
        'name' => 'John',
        'email' => 'john@example.com',
    ],
    'items' => [
        ['sku' => 'ABC', 'qty' => 2, 'price' => 19.99],
        ['sku' => 'DEF', 'qty' => 1, 'price' => 49.99],
    ],
    'shipping' => [
        'address' => [
            'city' => 'Taipei',
            'zip' => '100',
        ],
    ],
];

$validator = new NestedValidator($data);

// Dot-notation for nested fields
$validator->field('user.name', [new Required(), new MinLength(2)]);
$validator->field('user.email', [new Required(), new Email()]);

// Wildcard (*) expands to each array element
$validator->field('items.*.sku', [new Required()]);
$validator->field('items.*.qty', [new Required(), new Numeric()]);
$validator->field('items.*.price', [new Required(), new Numeric()]);

// Deeply nested
$validator->field('shipping.address.city', [new Required()]);
$validator->field('shipping.address.zip', [new Required()]);

$result = $validator->validate();

One-Shot Factory

$result = NestedValidator::make($data, [
    'user.name'      => [new Required(), new MinLength(2)],
    'user.email'     => [new Required(), new Email()],
    'items.*.sku'    => [new Required()],
    'items.*.price'  => [new Required(), new Numeric()],
]);

Dot-Notation Utilities

The NestedValidator exposes static utilities for working with nested arrays:

use Razy\Validation\NestedValidator;

// Get value by dot-path
$city = NestedValidator::dataGet($data, 'shipping.address.city');
$sku  = NestedValidator::dataGet($data, 'items.0.sku');

// Set value by dot-path
NestedValidator::dataSet($data, 'shipping.address.country', 'TW');

// Check existence
NestedValidator::dataHas($data, 'user.email'); // true
NestedValidator::dataHas($data, 'user.phone'); // false

FormRequest

An abstract class for structured form validation with authorization, auto-population from HTTP input, and lifecycle hooks:

use Razy\Validation\FormRequest;
use Razy\Validation\Rule\{Required, Email, MinLength, Numeric, Between};

class CreateOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        // Return false to block with 403
        return true;
    }

    public function rules(): array
    {
        return [
            'customer_email' => [new Required(), new Email()],
            'items'          => [new Required(), new IsArray()],
            'total'          => [new Required(), new Numeric(), new Between(0.01, 99999)],
        ];
    }

    public function defaults(): array
    {
        return [
            'currency' => 'USD',
            'status'   => 'pending',
        ];
    }

    public function messages(): array
    {
        return [
            // Override default messages per rule
        ];
    }

    public function prepareForValidation(array $data): array
    {
        // Transform data before validation
        $data['customer_email'] = strtolower(trim($data['customer_email'] ?? ''));
        return $data;
    }
}

Creating FormRequest Instances

// From $_GET + $_POST
$request = CreateOrderRequest::fromGlobals();

// From JSON body (php://input)
$request = CreateOrderRequest::fromJson();

// From array
$request = CreateOrderRequest::fromArray($data);

Using FormRequest

$request = CreateOrderRequest::fromJson();

// Check authorization + validation
if ($request->fails()) {
    $errors = $request->errors();
    // If unauthorized: ['_authorization' => ['This action is unauthorized.']]
    $json = $request->errorsAsJson(JSON_PRETTY_PRINT);
    return;
}

// Get validated data
$validated = $request->validated();
$email = $request->input('customer_email');

// Data access helpers
$all = $request->all();                          // raw input
$subset = $request->only(['customer_email', 'total']);
$without = $request->except(['_token']);
$exists = $request->has('customer_email');        // bool
$filled = $request->filled('customer_email');     // present and non-empty

API Reference

Validator

Method Signature Description
__construct (array $data = []) Create with input data
setData (array $data): static Replace input data
field (string $name): FieldValidator Get/create field validator
fields (array $fieldRules): static Bulk define fields
defaults (array $defaults): static Set default values
stopOnFirstFailure (bool $stop = true): static Stop at first failed field
before (callable $callback): static Pre-validation hook
after (callable $callback): static Post-validation hook
validate (): ValidationResult Run validation
make (static) (array $data, array $rules): ValidationResult One-shot factory

FieldValidator

Method Signature Description
rule (ValidationRuleInterface $rule): static Add a rule
rules (array $rules): static Add multiple rules
bail (bool $stop = true): static Stop on first failure
when (bool $condition, callable $callback): static Conditional rules
validate (mixed $value, array $data = []): array Returns {value, errors}
getField (): string Field name
getRules (): array Registered rules

ValidationResult

Method Signature Description
passes (): bool All passed
fails (): bool Any failed
errors (): array array<field, string[]>
errorsFor (string $field): array Errors for one field
firstError (string $field): ?string First error for field
firstErrors (): array array<field, string>
hasError (string $field): bool Check field has errors
validated (): array Validated data
get (string $field, mixed $default = null): mixed Single value
allErrors (): array Flat error list
errorCount (): int Total error count

Built-in Rules

Rule Constructor Validates
Required (none) Non-null, non-empty
Email (none) Valid email format
Url (none) Valid URL format
Numeric (none) Numeric value
IsArray (none) Array type
MinLength (int $min) String length ??min
MaxLength (int $max) String length ??max
Between (float|int $min, float|int $max) Numeric in [min, max]
Regex (string $pattern) Matches regex
In (array $allowed, bool $strict = false) Value in allowed list
Date (?string $format = null) Valid date / format
Confirmed (?string $confirmationField = null) Cross-field match
Callback (callable $callback) Custom logic
Each (array $rules) Per-element validation

Common Mistakes

Problem Cause Fix
Required rule ignored Required placed after other rules Always list Required first in the rule chain
Validation stops too early Bail causes first failure to skip remaining rules Remove Bail or reorder rules if you need all errors
Wildcard path not working Using . instead of * for array items Use items.*.name for nested array validation
Custom callback not called Callback returns false without a message Return a string message on failure, or throw an exception
Confirmed always fails Confirmation field name doesn't match convention Ensure password_confirmation field exists for password

??Previous: Cache Logging

Clone this wiki locally