Skip to content

Validation constraints

A field carries validation metadata->required(), ->maxLength(200), ->in([...]), ->before(...). This page is the reference for that vocabulary: the constraint model, the create/update context that scopes every rule, the full list of constraints as a table, and the few rules whose behaviour needs a worked example. By the end you can declare any built-in rule, scope it to POST or PATCH, and reach for constrain() when the built-ins don't cover a case.

Constraints are metadata core never executes

Every constraint is a final readonly value object implementing Resource\Constraint\ConstraintInterface, whose single method context(): Context reports when it applies. You rarely construct one directly — the fluent field builders append them for you, so Str::make('title')->required()->maxLength(200) adds a Required and a MaxLength. Core stores this metadata and exposes it; it does not run it. Three consumers read it instead:

  • the SchemaCompiler (validation) compiles the structural subset into a per-resource JSON Schema, so the document-validation layer tightens request bodies for free;
  • the SchemaProjector (OpenAPI) projects the same structural subset into the published OpenAPI 3.1 / JSON Schema document;
  • a framework adapter translates the full set into its native validator and executes value-level validation, rendering a failure as 422 with a source.pointer.

The two schema consumers agree because a structural constraint self-describes its JSON Schema keyword: it implements ProvidesJsonSchema (contribute(Schema): Schema), and both the compiler and the projector reduce over that one method — a single source of truth rather than two mirrored mappings.

That split is deliberate: core defines the vocabulary, an adapter runs it. The boundary statement appears once at the end of this page.

The create/update context

JSON:API treats POST (create) and PATCH (update) differently — a PATCH is a partial update, so absence means "no change". Every constraint therefore carries a Context declaring whether it applies on create, update, or both:

final readonly class Context
{
    public function __construct(public bool $onCreate = true, public bool $onUpdate = true) {}

    public static function always(): self;      // both (the default)
    public static function onlyCreate(): self;
    public static function onlyUpdate(): self;

    public function appliesTo(bool $creating): bool;
}

Constraints default to always(). Scope the constraints a field adds with the per-field onCreate() / onUpdate() builders — each runs a closure and stamps every constraint appended inside it with that context:

Str::make('handle')->onCreate(static function (StrBuilder $field): void {
    $field->required()->slug();   // both stamped onlyCreate()
});

Each built-in helper reads the field's current context when it runs, so it re-stamps the constraint it adds. The one exception is constrain(): it attaches a constraint you built yourself and does not re-stamp it, so a custom constraint carries whatever Context you constructed it with.

For the common presence case there are direct shortcuts — requiredOnCreate() and requiredOnUpdate() — that need no closure.

Required and Nullable semantics

Required means present and non-empty, but its strictness follows the context:

  • On create (POST) the field must be present and non-empty.
  • On update (PATCH) absence means "no change", so a plain ->required() does not force the member to appear; only an explicitly-supplied empty value fails.

If you need a member present on a PATCH too, scope a Required to update with ->requiredOnUpdate(); ->requiredOnCreate() is the create-only form, and ->required() applies in both contexts (with the per-context strictness above).

Nullable widens the allowed value to include an explicit null (a nullable type union in JSON Schema). It is independent of presence — a field can be required and nullable (must be supplied, but null is an acceptable value).

The constraint vocabulary

Every rule below is a value object under Resource\Constraint\. The Emitted by column is the fluent field method that adds it (see fields for which field type carries which method); construct the VO directly only inside each(), sequentially(), atLeastOneOf(), or constrain().

Constraint Applies to Emitted by Options / notes
Required any field ->required() / ->requiredOnCreate() / ->requiredOnUpdate() present + non-empty (context-sensitive on PATCH)
Nullable any field ->nullable() explicit null allowed; independent of presence
Min / Max Integer, Decimal ->min($v) / ->max($v) inclusive numeric bound
ExclusiveMin / ExclusiveMax Integer, Decimal ->exclusiveMin($v) / ->exclusiveMax($v) exclusive numeric bound
MultipleOf Integer, Decimal ->multipleOf($v) value must be a multiple of $v
MinLength / MaxLength Str (+ subtypes) ->minLength($n) / ->maxLength($n) string length bounds
Pattern Str, Id ->pattern($regex) regex match (no delimiters)
EmailFormat Str, Email ->email($strict = false) / Email's ->strict() strict: bool (RFC vs HTML5)
UrlFormat Str, Url ->url($allowedSchemes = []) list<string> $allowedSchemes
UuidFormat Str, Uuid ->uuid($version = null) ?int $version
UlidFormat Id ->ulid() ULID format (26-char Crockford base32)
IpFormat Str, Ip ->ip($version = null) / Ip's ->v4() / ->v6() ?int $version (4 or 6; null = both)
SlugFormat Str, Slug ->slug($regex = null) string $regex (defaults to a kebab-case pattern)
MinItems / MaxItems ArrayList, HasMany ->minItems($n) / ->maxItems($n) array length bounds
UniqueItems ArrayList ->uniqueItems() no duplicate items
MinProperties / MaxProperties ArrayHash ->minProperties($n) / ->maxProperties($n) object key-count bounds
In / NotIn any field ->in($values) / ->notIn($values) list<mixed> allow/deny set
Each ArrayList ->each(...$constraints) applies the wrapped constraints to every item
Before / After Date, DateTime, Time ->before($bound) / ->after($bound) \DateTimeInterface or \Closure(): \DateTimeInterface
Between Date, DateTime, Time ->between($min, $max) inclusive range; each bound fixed or closure
Sequentially any field ->sequentially(...$constraints) apply in order, stop at first failure
AtLeastOneOf any field ->atLeastOneOf(...$alternatives) pass if any one alternative holds
When any field ->when($condition, $builder) conditional set gated by a closure
CompareField any field ->compareWith($field, $operator) cross-field comparison; see the Comparison enum
RelationshipType relations the make() type argument (see relations) constrains linkage type member(s)

RelationshipType is the one relation-facing constraint — it is not an attribute rule. It pins a relationship's resource-identifier type member(s) to an allowed set; for a polymorphic relationship the list carries every permitted inverse type. It is added for you by a relation field's type() / types() builders.

The Comparison enum

CompareField (and ->compareWith()) takes a Comparison case. The operator reads <this field> <operator> <other field>:

Case Symbol
EqualTo =
NotEqualTo !=
GreaterThan >
GreaterThanOrEqual >=
LessThan <
LessThanOrEqual <=

Worked: closure date bounds

A date bound is either a fixed \DateTimeInterface or a closure resolved at validation time. AlbumResource forbids a future release date with a closure bound:

DateTime::make('releasedAt')
    ->before(static fn(): \DateTimeImmutable => new \DateTimeImmutable())
    ->useTimezone('UTC')
    ->sortable(),

The two bound kinds round-trip differently. A fixed bound is schema-visible — the compiler emits a formatMinimum / formatMaximum keyword for it. A closure bound is opaque PHP, so it does not round-trip to JSON Schema; only an adapter that executes validation evaluates the closure (here, against "now" for each request). That is the right trade: a relative bound like "no future dates" can't be frozen into a static schema, so it stays adapter-only.

Worked: composition

Sequentially and AtLeastOneOf build compound rules from the same vocabulary. The difference: Sequentially requires all wrapped constraints (in order, stopping at the first failure) and therefore round-trips to JSON Schema by merging them into the field's own schema; AtLeastOneOf requires any one alternative (an anyOf). UserResource demands a password fragment that is either long enough or contains a digit:

Str::make('passwordConfirm')
    ->atLeastOneOf(
        new MinLength(8),
        new \haddowg\JsonApi\Resource\Constraint\Pattern('^.*[0-9].*$'),
    )

Each alternative is itself a single constraint. When one alternative needs to be several rules at once (say, "a valid URL" and "at least 10 characters"), nest those rules in a Sequentially so the whole group counts as one alternative:

->atLeastOneOf(
    new \haddowg\JsonApi\Resource\Constraint\UrlFormat(),
    new \haddowg\JsonApi\Resource\Constraint\Sequentially([
        new \haddowg\JsonApi\Resource\Constraint\MinLength(10),
        new \haddowg\JsonApi\Resource\Constraint\SlugFormat(),
    ]),
)

Worked: CompareField direction

compareWith() puts this field on the left. In AlbumResource the availability window reads availableUntil > availableFrom:

Date::make('availableUntil')
    ->nullable()
    ->compareWith('availableFrom', Comparison::GreaterThan),

A non-directional comparison drops out as a special case — UserResource asserts passwordConfirm = password with Comparison::EqualTo, where left/right order doesn't matter.

Worked: the when() fluent form

when($condition, $builder) applies a constraint set only when the condition closure returns true for the value under validation. The builder closure appends constraints to the field as usual; when() captures them into a single When. UserResource requires a minimum length only when the confirmation field is actually supplied:

->when(
    static fn(mixed $value): bool => $value !== null && $value !== '',
    static function (StrBuilder $field): void {
        $field->minLength(8);
    },
)

The condition is opaque PHP, so When never round-trips to JSON Schema; an adapter evaluates it. Internally, when() swaps in a fresh capture buffer, runs the builder, then folds the collected constraints into one When carrying the field's current context — so anything you append inside the builder (here minLength(8)) is captured rather than added to the field directly.

constrain(): the typed escape hatch

For a rule the built-in vocabulary doesn't model, implement your own ConstraintInterface value object (carrying whatever typed config the rule needs) and attach it with constrain():

Str::make('coupon')->constrain(
    new RedeemableCoupon(context: \haddowg\JsonApi\Resource\Constraint\Context::onlyCreate()),
);

The constraint is the contract — an adapter translates it by matching on its class, and the schema compiler skips constraints it doesn't recognise. Unlike the fluent helpers, constrain() does not re-stamp the context, so scope a custom constraint by constructing it with the Context you want (onlyCreate() / onlyUpdate() / always()), as above. Constraints added inside a when() builder are still captured into that When like any other.

If your custom constraint has a lossless JSON Schema form, also implement ProvidesJsonSchema and return the accumulated node augmented with your keyword:

final readonly class HexFormat implements ProvidesJsonSchema
{
    public function __construct(public Context $context = new Context()) {}

    public function context(): Context
    {
        return $this->context;
    }

    public function contribute(Schema $schema): Schema
    {
        return $schema->withPattern('^[0-9a-f]+$');
    }
}

Now the constraint appears in both the request-validation schema and the published OpenAPI document — with no core change. (JSON Schema is framework-neutral, so the schema shape lives on the constraint; the host-specific execution still lives in each adapter's translator.)

Shape: composite-schema constraints

Shape asserts a composite JSON Schema — a oneOf / anyOf / allOf of raw member schemas — over a free-form value. It is the assertional counterpart to the constructive composite fields (Obj and OneOf): declare fields when the children are individually typed attributes; attach a Shape when the value stays a free-form map (ArrayHash) but its overall shape must still hold.

Builder Meaning
Shape::oneOf(Schema ...$members) The value must match exactly one member.
Shape::anyOf(Schema ...$members) The value must match at least one member.
Shape::allOf(Schema ...$members) The value must match all members (intersection).
->discriminator(string $property) Adds an OpenAPI discriminator naming the property that selects the matching oneOf member.
->onCreate() / ->onUpdate() Scope the shape to one operation context (default: always).
ArrayHash::make('contact')->nullable()->constrain(
    Shape::oneOf(
        Schema::ofType('object')
            ->withProperties([
                'kind' => Schema::ofType('string')->withConst('email'),
                'address' => Schema::ofType('string')->withFormat('email'),
            ])
            ->withRequired(['kind', 'address']),
        Schema::ofType('object')
            ->withProperties([
                'kind' => Schema::ofType('string')->withConst('phone'),
                'number' => Schema::ofType('string'),
            ])
            ->withRequired(['kind', 'number']),
    )->discriminator('kind'),
);

The members are plain Schema nodes — raw JSON Schema no host validator vocabulary can translate. Execution therefore does not follow the usual adapter-translation route: core ships SchemaValueValidator (backed by the optional opis/json-schema, exactly like document validation), which validates a value against a Shape's composed schema and returns 422 Errors whose pointers extend the field's own (/data/attributes/contact/...). Framework adapters wire it when opis is installed; without opis a Shape still documents — it contributes its combinator (and discriminator) to the field's projected OpenAPI schema via ProvidesJsonSchema — it just doesn't validate.

Value validation skips null — nullability is nullable()'s concern — and the projected schema agrees: on a nullable field a oneOf/anyOf gains an explicit null branch and an allOf hoists into anyOf: [null, {allOf}], so the document admits the same null the runtime accepts.

Rolling your own composite constraint

Shape holds no privileged status — it is just a ProvidesJsonSchema constraint that reaches for the withOneOf / withAnyOf / withAllOf (and withDiscriminator) withers inside contribute(). When the same composite recurs across resources, wrap it in a named constraint of your own rather than restating the member schemas at every Shape::oneOf(...) call site — the constraint becomes reusable, self-documenting vocabulary for that shape:

final readonly class GeoJsonGeometry implements ProvidesJsonSchema
{
    public function __construct(public Context $context = new Context()) {}

    public function context(): Context
    {
        return $this->context;
    }

    public function contribute(Schema $schema): Schema
    {
        $position = Schema::ofType('array')->withItems(Schema::ofType('number'));

        return $schema->withOneOf([
            Schema::ofType('object')
                ->withProperties([
                    'type' => Schema::ofType('string')->withConst('Point'),
                    'coordinates' => $position,
                ])
                ->withRequired(['type', 'coordinates']),
            Schema::ofType('object')
                ->withProperties([
                    'type' => Schema::ofType('string')->withConst('LineString'),
                    'coordinates' => Schema::ofType('array')->withItems($position),
                ])
                ->withRequired(['type', 'coordinates']),
        ])->withDiscriminator('type');
    }
}

Attach it like any other custom constraint — the field carries the base type, the constraint the combinator:

ArrayHash::make('location')->nullable()->constrain(new GeoJsonGeometry());

Because it rides the same seam as HexFormat above, the oneOf (and its discriminator) appears in both the compiled request-validation schema and the published OpenAPI document, with no core change. How it is executed is an adapter concern (see the core boundary below): Shape additionally carries a field-level SchemaValueValidator that adapters wire by type, so reach for the built-in Shape when you want the per-field 422 value pass, and author your own when you want a named, reusable, self-documenting composite.

The core boundary

Core defines the constraint vocabulary and nothing more. It ships:

  • no executor — core never validates a value against a constraint; that is a framework adapter's job (it translates the metadata and runs it natively);
  • no entity-level seam — checks that need the persisted store (a UniqueEntity-style uniqueness rule, for instance) have no representation in the core vocabulary and live entirely in the adapter.

What core does run, for free, is the structural subset: the SchemaCompiler turns the round-trippable constraints into a per-resource JSON Schema that the optional document validator enforces — including any constrain() VO that self-describes via ProvidesJsonSchema. The non-structural rules — When, CompareField, closure date bounds, and any custom VO with no lossless keyword — are skipped by the compiler and carried as metadata for an adapter to execute.

Next / see also

  • Fields — the fluent builders that emit these constraints, per field type.
  • Validation — the SchemaCompiler, the document validator, and the validation middleware that run the structural subset.
  • Relations — where RelationshipType is declared via type() / types().
  • Adapters — translating the full vocabulary into a native validator and rendering 422.