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
422with asource.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:
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:
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
RelationshipTypeis declared viatype()/types(). - Adapters — translating the full vocabulary into a native validator and rendering
422.