Skip to content

Validation

Validation is always on. Unlike the Symfony bundle (where the validator bridge is an opt-in suggest), illuminate/validation ships with Laravel, so this package translates core's declared constraint vocabulary into real Laravel rules with real, localizable messages on every write — no configuration (PLAN decision 6, ADR 0004 for the ordering). Core declares the constraints on the fields; this package executes them.

How it works

A field's constraints (declared with the fluent methods or constrain()) become Laravel rules; the document is validated before hydration, create-vs-update aware. A violation renders as a 422 with a JSON:API source.pointer derived from the dot path:

Str::make('title')->required()->maxLength(200);
Email::make('email')->required()->strict();
Str::make('password')->writeOnly()->minLength(8)->requiredOnCreate();
{ "errors": [ {
  "status": "422",
  "source": { "pointer": "/data/attributes/title" },
  "detail": "The title field is required."
} ] }

Messages come from Laravel's translator (the validation language files), so they localise for free and you override them the usual Laravel way.

The constraint → rule map

Core's constraint VOs translate to the natural Laravel rule:

Core constraint Laravel rule
required() / requiredOnCreate() required (create/update-context aware)
nullable() nullable
minLength() / maxLength() min: / max: (string length)
min() / max() (numeric) min: / max:
Pattern regex:
In Rule::in(...)
enum(BackedEnum::class) Rule::enum(...)
After / Before / date bounds closure rules coercing to DateTimeImmutable
Sequentially bail (stop on first failure)
When Validator::sometimes(...) (conditional re-validation)
CompareField gte:/lte: or closures against the sibling value
AtLeastOneOf a composite closure
Each (list items) array-element rules
Map children nested dot-notation rules → /data/attributes/<map>/<child> pointers
Obj children the same dot-notation cascade as Map (one value, one json column)
OneOf variants the discriminator selects the variant; its children cascade like Map's, an unknown discriminator is a 422 at /data/attributes/<field>/<discriminator>
Shape not translated — value-validated by core's opis SchemaValueValidator when opis is installed (see composite attributes)

The example's users.passwordConfirm composes three of these (an AtLeastOneOf, a conditional When, and an equality CompareField).

Unique values: Rule::unique pre-hydration

Core's UniqueEntity maps to Laravel's Rule::unique and runs before hydration — a better fit than the bundle's post-hydration seam, because the DB-level rule joins the same validation pass:

use haddowg\JsonApiLaravel\Validation\Constraint\UniqueEntity;

Email::make('email')->required()->strict()->constrain(new UniqueEntity(['email']));

A duplicate is a 422 at /data/attributes/email, caught against the table before any write.

Entity-level (post-hydration) validation

For a check that needs the assembled entity (or that must run after hydration), the post-hydration seam still exists — the Laravel twin of the bundle's EntityConstraintInterface. Use it for domain invariants that a field-level rule cannot express.

Filter-value validation

Filter values are validated too: a filter[releasedAt][min]=banana is a clean 400, not a database error. Declared filters carry their expected type (->integer(), ->boolean(), date-aware filters), and a value that fails coercion is rejected before it reaches the query.

Custom constraints

Register a class-keyed translator for your own ConstraintInterface value objects — the extension point (PLAN decision 6). It is consulted after the built-in vocabulary, first supports() match wins:

use haddowg\JsonApiLaravel\Facades\JsonApi;

JsonApi::constraintTranslator(\App\JsonApi\Validation\MyConstraintTranslator::class);

When a constraint has no natural Laravel rule, ship it as an invokable ValidationRule and translate to that.

Native Laravel rules without a translator: LaravelRules

For a one-off Laravel-native rule, skip the bespoke VO + translator and wrap the raw illuminate/validation rules in LaravelRules, attached with constrain():

Str::make('slug')->constrain(LaravelRules::make(['alpha_dash', 'min:3']));

The translator recognises the carrier and passes the wrapped rules straight to the validator — so they run in the same 422 pass (and, because the filter-value validator shares the translator, on filter[…] values too) with nothing to register. A rule can be a string ('min:3'), a Rule builder (Password::defaults()), or an invokable ValidationRule. Scope it with ->onCreate() / ->onUpdate().

A native rule is invisible to the generated OpenAPI/JSON Schema by default (it validates but doesn't document). Declare the value schema it implies with ->schema() — a closure over core's neutral Schema VO (it rides core's ProvidesJsonSchema seam) — when you want it in the document:

Str::make('slug')->constrain(
    LaravelRules::make(['alpha_dash', 'min:3'])
        ->schema(static fn(Schema $s): Schema => $s->withMinLength(3)),
);

Keep the fragment framework-neutral so the byte-compatible twin (the Symfony NativeConstraints carrier) emits the identical schema. LaravelRules couples the field to Laravel, so prefer a core constraint when one exists and reach here only for a genuinely native rule.

Non-goal: FormRequest integration

This package deliberately does not integrate Laravel FormRequest classes — they are a competing validation source that would fight the document-first bridge. Declare your rules on the resource fields (and the post-hydration seam); that is the single source of truth. The optional opis JSON-Schema linter (opis/json-schema) is a separate, structural document check — see multi-server-and-testing.