spiral/validator
Spiral Validator is a flexible validation library for PHP and the Spiral Framework. It offers rule-based checkers, nested array and object validation, custom messages, and attribute-driven constraints, making it easy to validate incoming request data cleanly.
spiral/validator is a standalone component from the Spiral ecosystem — there is no Laravel service provider, no facade, no FormRequest integration. Treat it as a pure library you wire up yourself.
composer require spiral/validator
First place to look, in this order:
src/Checker/ — the built-in rule implementations (StringChecker, NumberChecker, ArrayChecker, DatetimeChecker, FileChecker, BooleanChecker, MixedChecker, TypeChecker). This directory is the real documentation; the methods become your DSL aliases.src/Validator.php and the config class (ValidatorConfig) — how checkers/aliases/messages are registered.https://spiral.dev/docs/validation-spiral.A minimal first use case — validating a nested API payload outside of Laravel's validator:
use Spiral\Validator\Validator;
use Spiral\Validator\Config\ValidatorConfig;
use Spiral\Validator\Checker\StringChecker;
use Spiral\Validator\Checker\NumberChecker;
use Spiral\Validator\Checker\ArrayChecker;
$config = new ValidatorConfig([
'checkers' => [
StringChecker::class,
NumberChecker::class,
ArrayChecker::class,
],
]);
$validator = new Validator($config);
$errors = $validator->validate($payload, [
// array-of-arrays DSL: ['alias', options...], options array is the last element
'email' => [['notEmpty'], ['email']],
'age' => [['notEmpty'], ['number::range', ['min' => 18]]],
'tags' => [['notEmpty'], ['array::of', ['string']]],
]);
validate() returns an array of errors keyed by field; an empty array means valid. Before you build anything on top, write one test asserting the exact shape of that error array — it is the contract you will be mapping to Laravel's 422 responses everywhere.
Rule alias names above follow the checker-method convention; confirm the exact strings in
src/Checker/*for the version you install (see release 1.7.0 / 1.6.0 notes — aliases and size semantics have changed across minors).
Wrap it behind your own interface — always. The single most useful day-to-day pattern is to never let Spiral\Validator\Validator leak into controllers, form requests, or jobs. Bind it once and expose a narrow contract:
// app/Validation/SpiralValidatorFactory.php
namespace App\Validation;
use Spiral\Validator\Validator;
use Spiral\Validator\Config\ValidatorConfig;
final class SpiralValidatorFactory
{
private ?Validator $validator = null;
public function make(): Validator
{
return $this->validator ??= new Validator(new ValidatorConfig([
'checkers' => [/* ... */],
]));
}
}
// app/Providers/AppServiceProvider.php
$this->app->singleton(\App\Validation\SpiralValidatorFactory::class);
Then a thin adapter that converts Spiral's error array into a Laravel exception:
namespace App\Validation;
use Illuminate\Validation\ValidationException;
final class ArrayPayloadValidator
{
public function __construct(private SpiralValidatorFactory $factory) {}
/** @throws ValidationException */
public function validateOrFail(array $data, array $rules): array
{
$errors = $this->factory->make()->validate($data, $rules);
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
return $data;
}
}
This gives you Laravel's standard 422 JSON response, @error Blade directives, and assertInvalid() in tests — while the rules themselves stay in Spiral's DSL.
Keep rule schemas in dedicated classes, one per payload. Mirror the shape you'd otherwise put in a FormRequest, just in a plain class so it isn't tied to HTTP:
namespace App\Validation\Schemas;
final class CreateOrderSchema
{
public function rules(): array
{
return [
'customer.email' => [['notEmpty'], ['email']],
'items' => [['notEmpty'], ['array::of', [
'sku' => [['notEmpty'], ['string::length', ['min' => 3]]],
'qty' => [['notEmpty'], ['number::range', ['min' => 1]]],
]]],
];
}
}
This is the package's genuine strength versus Illuminate\Validation: nested array-of-struct shapes expressed declaratively without dotted-key archaeology.
Use it selectively, not as a replacement. A workable split that actually pays off:
exists/unique DB rules, and authorization-sensitive checks.spiral/validator for deep nested payloads (webhooks, JSON APIs, imported CSV rows) validated in jobs, console commands, or queued consumers — contexts where FormRequest doesn't exist and you'd otherwise be hand-rolling loops.Custom checkers as pure functions. Domain rules (e.g. "SKU matches warehouse prefix") should extend the abstract checker and contain zero Laravel dependencies — no container, no Eloquent, no facades. Pass dependencies as constructor args. That keeps them unit-testable and keeps the Spiral layer swappable if you later drop the package.
Normalize before you validate. $request->all() is fine, but for JSON bodies prefer json_decode($raw, true) so you validate exactly what arrived, not Laravel's coerced request bag. Flatten objects to arrays first — there is no object hydration.
Test the schema, not the framework. For each schema class: valid payload returns [], each failure path returns the exact expected messages. Use data providers so adding a rule means adding one row, not one test method. Assert message keys, because those are what your API clients depend on.
No Laravel integration at all. No service provider, no ValidationException mapping, no $request->validate(), no Rule:: objects, no unique/exists. Every one of those is code you write. Budget for it: the adapter is ~50 lines, the error mapping is where surprises live.
Unregistered aliases fail loudly, but at the wrong time. If a rule references a checker that isn't in ValidatorConfig::$checkers, you get an exception (or a rule that never runs) rather than a validation error. Symptoms: a field that "passes" everything. Debugging tip: when a rule seems ignored, dump the checker list and grep src/Checker/ for the method name — the DSL alias is derived from the class, and a typo is silent-ish.
Missing keys are not invalid. A field absent from the payload with no explicit "required/notEmpty"-style rule is skipped entirely. Every mandatory field needs the presence rule spelled out. This is the #1 source of "validation passed but the DB rejected it".
Rule order short-circuits. Inside a single rule array, the first failure stops evaluation; you won't get the email-format message if notEmpty already failed. That's usually desirable for UX — but if you need a full error report per field, split rules into separate arrays and confirm the behaviour with a test rather than assuming.
Translations are not Laravel's. Message translation goes through the component's own translator and message templates (lang/ru ships in the package). Your resources/lang/en/validation.php is invisible to it. Supply message overrides in the config, or post-process the returned error array in your adapter. Decide this on day one — retrofitting message keys across a live API is painful.
Pin the minor version and read the changelogs. Semantics have shifted in ways that matter:
'true' vs true differs).DatetimeChecker before/after messages and brute-value handling — anything below 1.5.4 has known edge cases.If you validate file sizes or dates, add explicit boundary tests and treat a minor bump as a behaviour change. Require ^1.7 (PHP 8.4 deprecations fixed in 1.5.4) rather than floating.
Two error formats in one API is a bug. If half your endpoints return Laravel's {message, errors} and half return a raw Spiral error array, clients break. Funnel everything through the adapter shown above so errors is always field => [messages].
Don't rebuild the validator per request. Construction plus checker registration is not free. Register it as a singleton (#[Singleton] is a Spiral attribute and does nothing for you here — use Laravel's container), or memoize in a factory as shown.
No object graph, no ArrayAccess magic. Arrays in, arrays out. DTOs must be converted (e.g. $dto->toArray()), and ArrayObject should be cast with (array) before it hits the validator. Trying to validate models directly will not work.
Extension points are narrow and that's fine. Custom checker classes, alias overrides, and message templates are the whole surface — there are no Laravel middleware or pipeline hooks. Keep the adapter thin enough that swapping back to Illuminate\Validation is a one-file change.
Adoption risk is real. Four stars, zero dependents, a small maintainer group, and a dependency tree rooted in Spiral conventions. Vendor the adapter behind your own interface, keep the schemas as plain arrays in your repo, and you retain the option to migrate to Rule::forEach / Validator::make on nested data later without touching call sites.
How can I help you explore Laravel packages today?