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