Essentials

Validation

Validate request data with typed DTOs, rule strings, or rule objects

Glueful has three validation styles. Each one is a layer on top of the same Glueful\Validation\Validator and its rule objects:

  1. Typed request DTOs (RequestData + #[Rule]): the router hydrates and validates the body before your controller runs. This is the recommended default.
  2. #[Validate] / FormRequest: rule strings run by the validate middleware.
  3. Validator with rule objects: explicit, for services, jobs and custom flows.

A failed validation throws Glueful\Validation\ValidationException, which renders as HTTP 422:

{
  "success": false,
  "message": "The given data was invalid.",
  "errors": {
    "email": ["Invalid email address."],
    "title": ["The title field is required."]
  }
}

Typed Request DTOs

Implement Glueful\Validation\Contracts\RequestData and annotate constructor parameters with #[Rule]. When a controller method type-hints the DTO, the router decodes the JSON body, runs the rules, and constructs the object. On failure it returns a 422 and your controller never runs.

namespace App\DTOs;

use Glueful\Validation\Attributes\Rule;
use Glueful\Validation\Contracts\RequestData;

final class CreatePostData implements RequestData
{
    public function __construct(
        #[Rule('required|string|max:255')]
        public readonly string $title,

        #[Rule('required|string')]
        public readonly string $body,

        #[Rule('in:draft,published')]
        public readonly string $status = 'draft',

        public readonly ?string $excerpt = null,
    ) {}
}
public function store(CreatePostData $data): Response
{
    $id = db($this->context)->table('posts')->insertGetId([
        'title' => $data->title,
        'body' => $data->body,
        'status' => $data->status,
    ]);

    return $this->created(['id' => $id]);
}

How hydration works:

  • Property names are the exact JSON keys. There is no snake/camel conversion, so a $refresh_token property reads the refresh_token key.
  • Missing values: a non-nullable parameter without a default becomes a 422 ("field is required") rather than a TypeError. Defaults and nullables fill in when the key is absent.
  • Scalars are coerced to the declared int/float/bool/string type after validation.
  • Other sources: mark a parameter #[FromRoute] or #[FromQuery] to read it from the route parameters or query string instead of the body.
  • Arrays: #[ArrayOf('int')] validates scalar elements, and #[ArrayOf(LineItemData::class)] hydrates nested RequestData objects (errors are keyed like items.0.sku). Nesting is limited to 5 levels.
  • Cross-field rules: implement Glueful\Validation\Contracts\ValidatesSelf. Its validate(): array runs after construction and returns field => list<string> errors, which merge into the same 422.
use Glueful\Validation\Attributes\{ArrayOf, FromRoute, Rule};
use Glueful\Validation\Contracts\{RequestData, ValidatesSelf};

final class UpdateOrderData implements RequestData, ValidatesSelf
{
    public function __construct(
        #[FromRoute]
        public readonly string $uuid,

        #[Rule('required|array'), ArrayOf(LineItemData::class)]
        public readonly array $items,

        #[Rule('date')]
        public readonly ?string $ship_after = null,

        #[Rule('date')]
        public readonly ?string $ship_before = null,
    ) {}

    public function validate(): array
    {
        if ($this->ship_after && $this->ship_before && $this->ship_after > $this->ship_before) {
            return ['ship_before' => ['ship_before must be after ship_after.']];
        }
        return [];
    }
}

DTOs also feed the generated OpenAPI request schema. Scaffold one with:

php glueful scaffold:dto CreatePostData            # request DTO (RequestData + #[Rule])
php glueful scaffold:dto PostData --response       # response DTO (ResponseData)

Rule Strings

#[Rule], #[Validate] and FormRequest use pipe-separated rule strings, parsed by Glueful\Validation\Support\RuleParser:

RuleMeaning
requiredPresent and not empty
string, integer/int, boolean/bool, arrayStrict PHP type check
numericNumeric value or numeric string
min:N, max:NA string's length, a number's value, or an array's item count
in:a,b,cOne of the listed values (strict comparison)
regex:/pattern/Matches the pattern
email, url, jsonFormat checks
uuidRFC 9562 UUID, versions 1–8 (including time-ordered v6/v7)
date, date:Y-m-dParseable date (optional format)
before:DATE, after:DATEDate comparison (default now)
confirmedMatches {field}_confirmation
unique:table,column,exceptIdNot already in the table
exists:table,columnPresent in the table
nullableA null or empty-string value stops the remaining rules for the field
sometimesAn absent field skips all its rules and is left out of the validated data
file:pdf,docx, image:jpeg,pngUploaded file / image, optional allowed types
dimensions:min_width=100,max_height=1000,ratio=16/9Image dimensions

Every rule except required passes when the field is absent or null. Optional fields therefore need nothing extra, and required is what makes a field mandatory.

Use nullable when an empty string should count as "no value" too (for example nullable|email accepts ""), and sometimes for partial updates: with sometimes|required|string, an absent key is not validated and does not appear in validated() / filtered(), while a present key must pass every rule.

unique: and exists: query the database through the validator's connection. The router (DTOs), the validate middleware and FormRequest all supply it, so the rule strings just work. Still back uniqueness with a unique index: two concurrent requests can both pass the check, and the losing insert throws UniqueConstraintViolationException, which renders as 409.

Since 1.91 unique: / exists: are enforced (before, they silently passed), and nullable / sometimes take effect. Requests that passed before may now get 422s. The database rules fail closed: with no connection available they throw a LogicException instead of passing.

Unknown names are resolved, in order, from the RuleRegistry (register('slug', SlugRule::class)), from App\Validation\Rules\{StudlyName}, and from a validation.rules.{name} container service. Built-in names can't be overridden. Scaffold a custom rule with php glueful scaffold:rule.

#[Validate] and FormRequest

For array-style input without a DTO, put rules on the controller method and attach the validate middleware:

use Glueful\Validation\Attributes\Validate;
use Glueful\Validation\ValidatedRequest;

#[Validate(['email' => 'required|email', 'name' => 'required|string|max:100'])]
public function store(ValidatedRequest $request): Response
{
    return $this->created(['email' => $request->string('email')]);
}
$router->post('/subscribers', [SubscriberController::class, 'store'])->middleware(['validate']);

The middleware validates the merged query, form and JSON input, and stores the filtered values in the request's validated attribute. ValidatedRequest reads them back with get(), string(), integer(), boolean(), only() and so on.

For reusable rules with authorization and input preparation, generate a FormRequest (rules(), authorize(), messages(), prepareForValidation()):

php glueful scaffold:request CreateUserRequest

Rule Object Validation

Use Validator directly in services, jobs or anywhere outside the router:

use Glueful\Validation\Validator;
use Glueful\Validation\ValidationException;
use Glueful\Validation\Rules\{Required, Email, Length, Type, Range, InArray, Numeric};

$validator = new Validator([
    'email'  => [new Required(), new Email()],
    'name'   => [new Required(), new Length(2, 100)],
    'age'    => [new Type('integer'), new Range(18, 120)],
    'price'  => [new Numeric(min: 0)],
    'status' => [new InArray(['draft', 'published'])],
]);

$errors = $validator->validate($input);   // ['field' => ['message', ...]]

if ($errors !== []) {
    throw new ValidationException($errors);
}

$data = $validator->filtered();

Glueful\Validation\Support\Rules::of([...]) is a shorthand for new Validator([...]).

Type('integer') is strict. That suits JSON payloads. Form-encoded values arrive as strings, so use Numeric there (or a CastToInt mutator first, see below).

Database Rules

DbUnique and Exists implement Glueful\Validation\Contracts\DatabaseAwareRule: the Validator hands them its connection before they run. Give the Validator the application context (or a Connection) and the rules need no extra setup:

use Glueful\Validation\Rules\{DbUnique, Exists};

$validator = new Validator([
    'email'   => [new Required(), new Email(), new DbUnique('users', 'email')],
    'team_id' => [new Required(), new Exists('teams', 'id')],
], $context);

// Or pass a connection explicitly
$validator = new Validator($rules, connection: db($context));

// On update, exclude the current row (matched against the 'uuid' column)
new DbUnique('users', 'email', $userUuid, exceptColumn: 'uuid');

A rule can also carry its own handle with setConnection($connection) (or setPdo($pdo)), which wins over the validator's. A Validator built with neither context nor connection, including Rules::of([...]), throws a LogicException when a database rule runs on a non-empty value.

Sanitization and Casting

Mutating rules (MutatingRule) transform the value before the rules after them run. Put them first, then read the result from filtered():

use Glueful\Validation\Rules\{Sanitize, CastToInt, CastToBoolean, CastToDate};

$validator = new Validator([
    'title'  => [new Sanitize(['trim', 'strip_tags']), new Required()],
    'email'  => [new Sanitize(['lower']), new Email()],
    'qty'    => [new CastToInt(), new Range(1, 99)],
    'notify' => [new CastToBoolean()],
    'due'    => [new CastToDate('Y-m-d')],
]);

Sanitize ops: trim, ltrim, rtrim, strip_tags, lower, upper.

Custom Rules

Implement Glueful\Validation\Contracts\Rule. Return null on success or an error message:

use Glueful\Validation\Contracts\Rule;

final class Slug implements Rule
{
    public function validate(mixed $value, array $context = []): ?string
    {
        if ($value === null) {
            return null;
        }
        return preg_match('/^[a-z0-9-]+$/', (string) $value) === 1
            ? null
            : "The {$context['field']} must be a valid slug.";
    }
}

Saved as App\Validation\Rules\Slug, it is usable as #[Rule('required|slug')].

validateRequest() (Legacy Helper)

BaseController::validateRequest($data, $rules) predates the rule system. It only understands required, email and max:N, and ignores every other rule. It returns a 422 Response or null. Prefer a DTO for new code.

Throwing ValidationException Yourself

use Glueful\Validation\ValidationException;

throw ValidationException::forField('email', 'That email is not allowed.');

throw new ValidationException([
    'email' => ['The email field is required.'],
    'name'  => ['The name must be at least 2 characters.'],
]);