Appendix: Routing & Validation

The route, binding, validation and OpenAPI rules behind Routing & Validation. That guide covers declaring routes, binding and validating input, responses and the generated document; this page is the reference it links to.

Route registration and matching

Route attributes

#[Get], #[Post], #[Put], #[Patch] and #[Delete] implement one RouteAttribute interface (httpMethod(), path(), status()). Router finds route attributes through that interface rather than by class name.

A method may carry more than one route attribute — #[Get('/x')] and #[Post('/x')] on the same method register two independent routes sharing that controller method and its middleware. Methods without a route attribute are skipped, so a controller can mix routed actions with plain helper methods. Each {placeholder} in a path template is compiled to a named regex capture group once, when the route is registered — not on every request.

Path rules

Every declared path must start with / — a route path is absolute, so #[Get('users')] is a typo rather than a shorthand and is rejected at registration, as is #[RoutePrefix('users')]. The empty string is rejected for the same reason: it would resolve to / and quietly claim the root route.

Trailing slashes are normalised away, so every path is stored in one canonical form. #[Get('/users')] and #[Get('/users/')] are the same route, and declaring both is a duplicate. / itself is unchanged.

The request path goes through the same rule, so a request for /users/ reaches a route registered as /users and binds path parameters exactly as it would without the slash. Both URLs serve the response directly rather than redirecting; a 301 to the canonical form belongs in front of the application.

What a placeholder matches

A path template describes URL structure and nothing else. {id} occupies one whole segment and matches any run of characters up to the next /; there is no inline syntax for narrowing that. What a captured value may hold is described where the value is consumed — by the controller parameter’s own type and its validation attributes:

#[Get('/orders/{id}')]
public function show(#[GreaterThan(0)] int $id): array { /* ... */ }

#[Get('/files/{hash}')]
public function download(#[Regex('#^[0-9a-f]{40}$#')] string $hash): array { /* ... */ }

GET /orders/abc therefore reaches the route and fails binding with a 422 naming id, rather than falling through to a 404. That single place is also what the generated OpenAPI document reads: the path key is the plain template, and the parameter’s declared type and constraints become its schema, minus any constraint with no JSON Schema keyword to map onto (see Validation constraints).

A {...} expression that isn’t a plain placeholder name — {id:\d+}, {not a name}, or an unclosed {id — is a mistake in the template, not literal text, and is rejected at registration with an InvalidRoutePathException naming the expression. Placeholder names follow PHP’s identifier grammar restricted to ASCII, and the same name may appear only once in one template. Two placeholders may not sit directly against each other either — {first}{second} gives nothing to split a segment on, so it is rejected the same way; separate them with literal text ({first}-{second}) or capture the segment as one placeholder.

Which route wins

Matching follows a stable, content-only specificity order — never registration or discovery-scan order, so live discovery and a compiled cache always agree on which route wins for the same set of routes. Each real /-delimited path segment ranks into one of three tiers, most to least specific: fully static (self, report-2026.pdf); a placeholder mixed with literal text in the same segment (report-{id}.pdf); a placeholder occupying the whole segment ({id}). The tier always wins first — a mixed segment beats a pure placeholder. Once every shared segment ties, the route with more segments is treated as the deeper, more specific match. /users/self alongside /users/{id}, or /files/report-{id}.pdf alongside /files/report-2026.pdf, can therefore both be registered, in either order, and the more specific one always wins for a path it also matches.

A second route claiming exactly the same requests (the same method and path shape — placeholder names don’t count, so /users/{id} and /users/{userId} collide) is rejected at registration with a DuplicateRouteException, since it could never run at all.

Registering a controller

Registering a controller is all-or-nothing: every one of its routes is reflected and checked for conflicts before any of them are committed, so a later method’s bad path, or a conflict against an earlier one, leaves none of that controller’s routes registered.

Registering the same class a second time is a no-op only when the global-middleware context is identical to the one already used. That is what keeps a class discovered through more than one scan (a project’s own scan overlapping a package’s extra.kinetis root, for instance) from registering its routes twice, since every such scan shares the one project-wide global-middleware list. A second registration under a different context is rejected instead of silently kept under the first one.

Sharing routes across controllers

#[RoutePrefix] is resolved when the route is registered, so everything downstream sees the finished path: duplicate detection, the compiled cache, the OpenAPI document and kinetis routes:list. Two controllers sharing one trait under different prefixes therefore don’t collide, while two under the same prefix are rejected as duplicates, exactly as if the paths had been written out by hand. A route declaring / sits at the prefix itself.

A trait is the way to share route methods — not a base class. An attribute is only ever read from the class it is written on, so a routed method inherited from a parent is rejected at registration; see Where attributes are read from.

A middleware can own a prefix too

#[RoutePrefix] also reads from a middleware class, not just a controller — the shape API versioning takes, where the prefix and a piece of version-related behavior belong to the same class:

#[RoutePrefix('/v1')]
final class VersionMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        return $handler->handle($request);
    }
}

#[Middleware(VersionMiddleware::class)]
final class UserController
{
    #[Get('/users')]
    public function index(): array { /* ... */ }
}

/users becomes /v1/users, with no change to UserController itself — every controller referencing VersionMiddleware moves together the next time its #[RoutePrefix] changes. The same is true of a middleware discovered as global (#[AsGlobalMiddleware]): its prefix applies to every route in the project, ahead of everything else.

A route’s final path composes outer to inner, in the order the middleware itself runs: the global-middleware chain first (priority order), then the route’s own #[Middleware(...)] chain (class-level before method-level — see route middleware), then the controller’s own #[RoutePrefix], then the route’s own declared path. Declaration order decides both what runs first and which segment lands leftmost, so two controllers referencing the same prefixed middlewares in a different order end up with different URLs.

A middleware referenced only through a #[Middleware('@name')] group never contributes a prefix this way: group membership isn’t resolved until Kernel is constructed, after routing has already produced the final path.

Parameter binding

Binding order

A controller method’s parameters are resolved from six possible sources. Dispatcher checks them in a fixed order, and the first that claims a parameter wins:

  1. A ServerRequestInterface-typed parameter.

  2. An UploadedFileInterface-typed parameter.

  3. #[Body].

  4. #[Query].

  5. A name matching a {placeholder} in the route’s path template.

  6. Any other class-typed parameter, resolved from the request container.

The two type-matched sources come first, so neither an attribute nor a placeholder name can shadow them; the container comes last, so it can never shadow #[Body], #[Query], or a path placeholder. Binding runs before the controller is resolved, so a request refused with a 400, a 415 or a validation failure never constructs the controller, and no constructor or registered factory runs on its behalf.

A ServerRequestInterface parameter bypasses #[Body]’s decoding entirely, for anything that needs the request itself: a raw body stream, headers, a content type #[Body] does not read.

Reading the DTO from one top-level member

A wire contract that wraps its payload in a named member — {"article": {...}} — names that member as the attribute’s root, and the DTO is hydrated from the member alone. CreateArticle and ArticleResponse stand for the application’s own classes:

#[Post('/articles')]
public function create(#[Body('article')] CreateArticle $article): ArticleResponse

A root is exactly one top-level member name. It is never inferred from the parameter name and it is not a path: #[Body('data.article')] names a member spelled data.article. An empty root, and a second #[Body] parameter on the same method, are refused with an UnresolvableParameterException when the route registers — a request carries one document, and every #[Body] parameter validates its outer object as its own.

The document around the root is read exactly as an unrooted one is: the media type decides how it is decoded, uploaded files are merged into a form body first, and a malformed JSON document, or one whose outer value is not an object, is still a 400. The root member then reports what a nested DTO field reports (see Nested DTOs), under the root’s path:

Request

Violation

a blank body, or no article member

required at ["article"]

"article": null

null_not_allowed at ["article"]

"article": "text"

type_mismatch at ["article"]

"article": []

not_a_json_object at ["article"]

an invalid title inside it

that field’s own code at ["article", "title"]

A JSON document is closed around its root as a DTO’s own object is: every other top-level member is unexpected_field at its own path, reported after the root’s own failures. A form-encoded or multipart body stays open, so a CSRF token or a submit button’s name beside the root is accepted. See Unknown members are rejected for JSON for both policies, and OpenAPI generation for the published document.

Uploaded files as parameters

An UploadedFileInterface-typed parameter is resolved from the request’s uploaded files by parameter name. A request without the expected file — including one whose file control the user left empty — resolves like a missing #[Query] value: the parameter’s default if it has one, null if its type allows null, and a 422 (is required.) otherwise. A file that is present runs the parameter’s own Constraint attributes, after its transport status has been checked (see Uploads); the parameter itself stays outside the generated OpenAPI document.

Class-typed parameters

A class-typed parameter matching no other source is resolved from the request container. Anything the container can supply works — a repository, a MailerInterface, whatever a package bootstrap bound — and a dependency only one route needs is built only for that route, instead of on every request to the class.

If nothing can supply the parameter, the failure surfaces: a route that forgot the middleware meant to register the value fails loudly rather than handing the controller something disconnected. A default value, or a nullable type, says that absence is acceptable instead. That covers absence only — an id the container could never supply. Everything else, a registered service that failed to construct included, is a defect and is reported rather than quietly arriving as null; Appendix: Container Lifecycle states the rule constructor autowiring and Dispatcher share.

A class-typed parameter that is claimed by another source is decided by that source instead: #[Body] hydrates the class from the request body, and a #[Query] or path parameter admits one class only, a backed enum — see Enum path and query parameters.

Note

This applies to HTTP controllers. An MCP tool’s arguments arrive as one flat object, so a class-typed parameter there is a DTO hydrated from those arguments — see Model Context Protocol (MCP).

Enum path and query parameters

A #[Query] or path parameter typed as a backed enum binds the case its backing value names. Dispatcher resolves it through the same Hydrator::resolveEnumValue() a backed enum field goes through, so an HTTP parameter and a DTO field answer by one set of rules:

  • The wire text is resolved as the enum’s backing type first, under the raw-string rules below. ?priority=urgent on an int-backed enum is therefore the ordinary not_an_integer violation — the value named no case of anything — while ?priority=9 is an enum_case violation carrying {"choices": [1, 2, 3]}.

  • The lookup is tryFrom() on that backing value, so matching is exact. A string-backed asc case is not named by ASC.

  • A nullable parameter with no matching key binds null; one with a default binds that default case; one with neither is required. A path segment is always present.

  • Constraint attributes run against the resolved case, as they do on a DTO field.

Every other class type on a #[Query]/path parameter is refused when the route’s binding plan is derived, which Router::register() does eagerly — so the route never registers, is never advertised at /openapi.json, and never accepts traffic. A unit enum’s cases have no backing value, and an interface, an abstract class or a DTO has no wire spelling a single query value or path segment could carry; Kinetis\Http\Exception\UnresolvableParameterException names the parameter, its source and the class, and points at #[Body], where a DTO field may declare a class type.

The parameter’s compiled binding plan records the enum as enumClass and its backing type as scalarType, the same pairing a DTO field’s hydration plan uses. Both are plain strings, so the plan is written into .kinetis-cache/compiled.php unchanged — see the AOT artifact.

The generated document publishes the enum’s own domain for that parameter: the backing scalar’s JSON type and the exact enum of its case values, widened with null where the declaration admits it.

Parameters no source claims

A parameter matching none of the six — untyped, or scalar-typed with no attribute and no matching placeholder — falls back to its default value if it has one, and otherwise fails with an UnresolvableParameterException naming every source it could have come from, rather than passing null silently. Not every default can be captured for reuse — see Default values a plan captures, which applies to a controller parameter and a DTO field alike.

Query and path values are raw strings

A #[Body]/MCP value is already a real, JSON-decoded PHP value (a genuine bool, array, …); a #[Query]/path value only ever arrives as a raw string (or, for a #[Query] array-style parameter — ?tags=a&tags=b — a list of them). Dispatcher therefore resolves both under InputSource::Text, always, whatever the request body’s own content type says. Several consequences follow:

  • bool accepts the OpenAPI-documented "true"/"false" spelling too, not just "1"/"0". Once the type check has accepted one, Text translates those two literal spellings into real PHP booleans, so the (bool) cast never meets the string "false", which would cast to true.

  • A numeric string binds an int/float parameter, since text is the only spelling a query string or a path segment has. The same string in a JSON body does not; see Scalar type checking.

  • A #[Query]/path parameter typed outside the supported set is rejected at registration, not at request time — see Builtin types outside the supported set. A class type is refused there too, a backed enum excepted; see Enum path and query parameters.

  • A #[Query]/path parameter declaring a union or intersection type is rejected at registration. One request value has one shape, and the T|Absent presence union a DTO field may declare needs a member that is either present or absent, which a query key is not.

  • An array/iterable-typed path parameter is rejected at registration too, unconditionally. A route placeholder is always exactly one path segment — Route::match() captures it as a single string — so move it to #[Query] (where an array-style parameter is representable) or #[Body] instead.

  • A #[Query] array-style parameter accepts OpenAPI 3.1’s default query-array serialization (style: form, explode: true — never stated explicitly in the generated document, since it is the spec default): the repeated-key spelling, ?tags=a&tags=b. This is what a client generated from /openapi.json sends, and it is the only form that binds — parsed from the request’s raw query string, since PHP’s parse_str() (what getQueryParams() is built from on every runtime) collapses a repeated, non-bracketed key to its last value. PHP’s bracket spelling, ?tags[]=a, sends a different key — the name on the wire is tags[], not tags — so it satisfies no #[Query] array $tags parameter: the value is missing, and the parameter’s own default, null, or an is required. 422 follows, exactly as for a request that never mentioned the key.

Request bodies

Media types

Dispatcher picks how to read a #[Body] from the request’s Content-Type:

Content-Type

Read from

application/json, or an application/*+json subtype

json_decode() on the raw body

multipart/form-data

getParsedBody()

application/x-www-form-urlencoded

getParsedBody()

A nonblank body under any other media type — or under no Content-Type at all — is refused with a 415 before the DTO is hydrated and before the controller is constructed, so a handler never receives bytes read under a header that did not describe them. The error names the supported media types and never echoes the one received. A blank or whitespace-only body is a document with no members whatever the header says, so an all-optional unrooted DTO still hydrates from its own defaults.

A Content-Type is matched on its type and subtype alone — everything before the first ;, so a charset or a multipart boundary parameter changes nothing — and compared ASCII-case-insensitively, as RFC 9110 §8.3.1 requires: Application/X-WWW-Form-Urlencoded; charset=UTF-8 lands on the same row as application/x-www-form-urlencoded. The match is exact on the subtype apart from RFC 6839’s +json suffix, so a longer media type that merely begins with a listed one — application/x-www-form-urlencodedevil — names none of these rows and is refused. Kinetis\Http\MediaType is that classification, and the one place a Content-Type is read — by Dispatcher here, and by the Kernel’s own RequestBodyMiddleware before it — so an application gets the same answer under every runtime (see Runtime Adapters).

Empty and malformed JSON bodies

An empty body is treated as a document with no members — the same outcome a {} body produces. A non-empty body must be a JSON object: one that isn’t valid JSON, or that decodes to anything else (a top-level JSON array, null, a bare string, a number, a boolean), is a 400 before any field-level validation runs. That check belongs to the decoder, which is where the object/array distinction still exists:

{
    "error": "Request body is not valid JSON."
}

Form field names and limits

Field names nest the way PHP’s own parser nests them, under every runtime: user[address][city] builds nested arrays, tags[] appends, a repeated plain name replaces, and repeated or nested file names build the same tree in getUploadedFiles(). How large and how complicated a form may get is bounded by Kinetis\Http\Form\FormLimits — input variables, file parts, nesting depth, multipart part and header counts, and total bytes — identically under all four adapters, because one middleware inside the Kernel applies them; a form past any of those is refused with a 413 before the handler runs, never handed on with the over-limit fields missing. See “Request bodies: one contract under every runtime” in Runtime Adapters for the numbers.

An adapter delivers raw bytes the runtime never parsed — php://input under the SAPI adapters, which require enable_post_data_reading=0, the event body under kinetis/bref-adapter’s BrefLambdaAdapter, and the http.raw_body: true-preserved body under kinetis/roadrunner-adapter’s RoadRunnerAdapter — and the Kernel’s own RequestBodyMiddleware fills the uploaded-files bag from them through Kinetis\Http\Form. There is one parse, under every runtime and for every method a form can arrive on.

Form-encoded and multipart bodies get the raw-string rules too

A multipart/form-data/application/x-www-form-urlencoded #[Body] is read from getParsedBody(), not JSON-decoded — every field arrives the way a #[Query]/path value does: a raw string (or a real PHP array, for a repeated/bracketed field name), never an already-typed JSON value. The same #[Body] parameter type can receive either encoding depending on the client’s own Content-Type, so Dispatcher applies the textual rules only when the request is form-encoded, never to a JSON request reaching the identical DTO.

Every #[Body]-reachable route whose DTO declares no uploaded file advertises application/x-www-form-urlencoded and multipart/form-data in its generated requestBody alongside application/json — the identical schema under all three, since Dispatcher hydrates the same DTO class regardless of which the client sent. A DTO that does declare an upload advertises multipart/form-data alone, since neither of the other two can carry a file.

  • Every field is read under InputSource::Text, the vocabulary #[Query]/path values use, so bool accepts both the "1"/"0" and the "true"/"false" spelling and int/float accept their textual ones. The source is the request’s, not the route’s: a real JSON request for the identical DTO class still rejects the JSON string "true" and the JSON string "3" for an int field. It reaches a list’s own scalar elements, and a nested or #[ListOf] DTO’s own scalar fields, via PHP’s bracket-style field[sub]=value convention.

  • array/iterable get the identical map-shaped-value rejection documented for JSON (a form-encoded field parsed into an associative PHP array is rejected the same way a JSON object is), but the numeric-keyed-object provenance tracking JsonTree provides for a JSON body does not apply — a form-encoded array field’s shape comes directly from PHP’s parsed-body array.

  • An UploadedFileInterface-typed field is described as {type: string, format: binary} — OpenAPI’s convention for a file upload inside a multipart-serialized schema — and is satisfiable only via multipart/form-data.

Uploads

A #[Body] DTO can mix ordinary fields with an UploadedFileInterface-typed constructor parameter. Validation constraints work identically on a multipart-bound DTO’s ordinary fields as on a JSON one — Hydrator never knows which content type produced the data it validates.

An empty file control is an omitted field

A browser submits a file input the user left alone as a present part carrying UPLOAD_ERR_NO_FILE, not as nothing at all. Binding reads that as ordinary omission, exactly as it reads a text field the form never sent:

  • a defaultless field or parameter reports is required.;

  • one with a default takes its default;

  • a nullable one binds null;

  • an UploadedFileInterface|Absent DTO field binds Absent::Value, so an update DTO can tell “no file was chosen” from “this file was cleared”. See Required, optional, and absent fields.

The same pruning runs at every depth. A branch left with nothing in it is omitted in turn, so a repeated control whose every part was empty is an absent field rather than a supplied empty list — [] is a list the client sent, which a #[MinItems] rule would then measure.

This is what binding reads. The PSR-7 request keeps the bag its runtime adapter built, UPLOAD_ERR_NO_FILE parts included, so middleware and a ServerRequestInterface-typed parameter still see exactly what arrived.

Nested and repeated file controls

Files nest and repeat under the same bracket convention text fields do, and the two trees are merged into one set of fields before the DTO is hydrated. profile[name] arrives as text and profile[avatar] as a file; both hydrate the same nested DTO:

final readonly class ProfileDetails
{
    public function __construct(
        public string $name,
        public UploadedFileInterface $avatar,
    ) {}
}

final readonly class ProfileUploadRequest
{
    public function __construct(
        public ProfileDetails $profile,
    ) {}
}

At each key: two arrays merge, and anything else leaves the parsed text in place. A form naming one key as both a text value and a file has contradicted itself, so the text stays and the field reports the ordinary declared-type violation any other wrong value would get. A JSON body never consumes uploaded files at all — a JSON document names every value it sends.

A repeated control is a #[ListOf] field naming UploadedFileInterface, the one interface #[ListOf] admits:

use Kinetis\Validation\Constraints\FileExtension;
use Kinetis\Validation\{Each, ListOf};

final readonly class PhotoUploadRequest
{
    public function __construct(
        #[ListOf(UploadedFileInterface::class)]
        #[Each(FileExtension::class, ['png'])]
        public array $photos,
    ) {}
}

A flat repeated control closes its indices back up after empty parts are dropped: photos[] sent as file, empty, file binds two files at 0 and 1. An index there is nothing but position among files, so the elements a handler receives are the files that arrived, in order, with no holes.

A list of nested DTOs keeps its positions instead, because there an index is the one the parsed text names too:

final readonly class GalleryEntry
{
    public function __construct(
        public string $caption,
        public ?UploadedFileInterface $image = null,
    ) {}
}

final readonly class GalleryUploadRequest
{
    public function __construct(
        #[ListOf(GalleryEntry::class)]
        public array $entries,
    ) {}
}

Three captioned entries whose middle entries[1][image] control was left empty bind the second entry without an image and leave entries[2][image] on the third entry, where the client put it. Closing those indices up would merge the third file into the second entry’s caption, and an optional field would hydrate that misbinding without a violation. Map-shaped branches keep their keys as well, which are field names a DTO declares rather than positions.

A file that did not arrive

Every typed route into an uploaded file — a DTO field, a #[ListOf] element, a direct parameter — checks the part’s PSR-7 status before anything else looks at it. A status other than UPLOAD_ERR_OK is one violation at the field’s own path:

{
    "path": ["photos", 1],
    "code": "upload_failed",
    "message": "could not be uploaded.",
    "parameters": {"error": 3}
}

parameters.error is the raw UPLOAD_ERR_* constant, for a log to read. No rule on the field runs, and nothing opens the stream — a failed part has no readable stream, so a rule that ran would raise a 500 for what is a client-visible transfer failure. UPLOAD_ERR_NO_FILE never reaches this point: it is already omission, above.

Rules about an uploaded file

Two built-in rules describe a file, on a DTO field, on a #[Each] of an upload list, or on a direct controller parameter:

use Kinetis\Validation\Constraints\{FileExtension, FileSize};

#[Post('/scans')]
public function scan(
    #[FileSize(maxBytes: 5_000_000, minBytes: 1)]
    #[FileExtension(['png', 'jpg'])]
    UploadedFileInterface $file,
): array

#[FileSize] bounds the size the part itself reported, inclusive on both ends. PSR-7 permits a null size, and a bound cannot be checked against a size that does not exist, so such a file fails closed with file_size_unknown rather than passing unchecked.

#[FileExtension] is a policy about the client-supplied filename, an untrusted label the server has not verified. It is not a MIME type and it is not sniffed content: a name ending in .png says nothing about the bytes behind it. A suffix is written without its leading dot and may contain dots of its own, so png and tar.gz are both declarable, and each matches the end of the name after the dot that separates it — archive.tar.gz satisfies gz and tar.gz alike, while a name with no separating dot satisfies neither. Matching is ASCII case-insensitive, so PHOTO.PNG satisfies png. The name never appears in the violation: the message and its choices parameter carry the declared suffixes, which the server wrote.

Neither rule opens the file, and Kinetis validates no upload’s contents anywhere: content checks belong where the content is read.

Uploads in the generated document

An UploadedFileInterface field is published as {"type": "string", "format": "binary"} — OpenAPI’s convention for a file inside a multipart-serialized schema — and a #[ListOf] of them as an array of that item. A #[Body] DTO that declares an upload anywhere in it, on a nested DTO or a list element included, advertises multipart/form-data alone: only a multipart body can carry a file, and a JSON or urlencoded entry beside it would describe a request that cannot hydrate the DTO it names. An upload-free DTO keeps all three encodings it accepts.

A direct UploadedFileInterface parameter is outside generated input metadata entirely — it is not a request body, and none is synthesized for it. An application that needs its upload input documented declares the file as a field of a #[Body] DTO.

Validation

Validation constraints

Each rule owns three things: what it checks, the stable code its violation carries, and the JSON Schema keyword that states the same rule to a client reading the generated document. Every rule lives in Kinetis\Validation\Constraints.

Attribute

Checks

Constructor

Violation code

Schema keyword

#[Email]

filter_var($value, FILTER_VALIDATE_EMAIL)

(no arguments)

email

format: email

#[NotBlank]

not empty or all-whitespace after trim()

(no arguments)

not_blank

(none)

#[MinLength(n)]

mb_strlen($value) >= n

int $length

min_length

minLength

#[MaxLength(n)]

mb_strlen($value) <= n

int $length

max_length

maxLength

#[GreaterThan(n)]

$value > n

int|float $threshold

greater_than

exclusiveMinimum

#[LessThan(n)]

$value < n

int|float $threshold

less_than

exclusiveMaximum

#[GreaterThanOrEqual(n)]

$value >= n

int|float $threshold

greater_than_or_equal

minimum

#[LessThanOrEqual(n)]

$value <= n

int|float $threshold

less_than_or_equal

maximum

#[MultipleOf(n)]

$value % n === 0

int $divisor

multiple_of

multipleOf

#[Regex($pattern)]

preg_match($pattern, $value) === 1

string $pattern

regex

(none)

#[In($choices)]

in_array($value, $choices, true)

array $choices

in

enum

#[NotIn($choices)]

!in_array($value, $choices, true)

array $choices

not_in

not: {enum: ...}

#[MinItems(n)]

a list of at least n elements

int $count

min_items

minItems

#[MaxItems(n)]

a list of at most n elements

int $count

max_items

maxItems

#[Url]

filter_var($value, FILTER_VALIDATE_URL)

(no arguments)

url

format: uri

#[Uuid]

RFC 9562’s UUID string form

(no arguments)

uuid

format: uuid

#[Ip]

filter_var($value, FILTER_VALIDATE_IP)

(no arguments)

ip

anyOf (format: ipv4, format: ipv6)

#[Date]

YYYY-MM-DD naming a real calendar day

(no arguments)

date

format: date

#[DateTime]

an RFC 3339 date-time

(no arguments)

date_time

format: date-time

#[FileSize($max, $min)]

an uploaded file’s reported size, inclusive on both bounds

int $maxBytes, int $minBytes = 0

file_too_large, file_too_small

(none)

#[FileExtension($extensions)]

an uploaded file’s client filename ends in one of them

array $extensions

file_extension

(none)

#[GreaterThan], #[LessThan], #[GreaterThanOrEqual] and #[LessThanOrEqual] report not_a_number, #[MultipleOf] reports not_an_integer, #[MinItems]/#[MaxItems] report not_a_list, and #[FileSize]/#[FileExtension] report not_a_file, for a value of the wrong shape entirely. Through a request that never happens — the declared type is checked before any rule runs — but a rule invoked directly still answers rather than counting something with no count. The string rules have no such second code: #[Email], #[Url], #[Uuid], #[Ip], #[Date] and #[DateTime] each fold a non-string into their own code.

#[MultipleOf] is about whole numbers on both sides. Its divisor is an int of at least 1, and a float value reports not_an_integer rather than being tested: divisibility in binary floating point would need a tolerance the published multipleOf does not carry. Zero and negative multiples satisfy it.

#[Date] and #[DateTime] read a fixed ASCII grammar and then check the numbers, rather than handing the value to a PHP date parser — which would accept 2024-1-1, roll 2023-02-29 forward into March, and apply a local timezone the request never mentioned. #[Date] is YYYY-MM-DD alone, so its years run 0001 through 9999. #[DateTime] is RFC 3339’s date-time with two reductions: a leap second (:60) and the space separator RFC 3339 permits in place of T are both rejected, the first because nothing downstream of a validated string can place one, the second because admitting two spellings under one format: date-time would publish a document broader than the check. An offset is required — Z, z, or a signed HH:MM, -00:00 included — a fraction of any length is accepted, and a trailing newline is not part of either value.

#[Uuid] is RFC 9562’s string form and nothing more: 32 hexadecimal digits in 8-4-4-4-12 groups, upper or lower case. Version and variant are fields of the identifier rather than of its spelling, so the nil and max UUIDs and versions 6, 7 and 8 all bind.

#[Ip] accepts an address of either family and nothing around it: a zone identifier, a CIDR prefix, bracket notation and surrounding whitespace are each something other than an address, and none of them binds.

#[Regex] and #[NotBlank] are runtime-only: neither has an equivalent JSON Schema keyword. pattern holds an undelimited ECMA-262 expression, a different dialect from the delimited PHP PCRE #[Regex] takes, and no keyword carries #[NotBlank]’s trim-aware blank-string semantics — minLength: 1 rejects the empty string, not "   ". For those two a generated OpenAPI or MCP schema is broader than the check the request gets.

#[FileSize] and #[FileExtension] publish nothing for a different reason: both describe a multipart part, which the document represents as {"type": "string", "format": "binary"}. JSON Schema’s string keywords measure that string’s own characters and say nothing about the bytes of the part or the name the client attached to it, so publishing one would state a rule the request is not checked against. Every other constraint in the table maps onto a keyword; see OpenAPI generation.

A rule’s constructor arguments reach a client twice — as a violation’s parameters and as a published schema keyword — so a definition with no truthful form in either is refused with an InvalidArgumentException where the constraint is first instantiated, during hydration or schema generation: a negative #[MinLength], #[MaxLength], #[MinItems] or #[MaxItems] bound; a non-finite threshold on any of the four numeric bounds; a #[MultipleOf] divisor below 1, since zero divides nothing and a negative divisor names the same multiples as its absolute value while publishing a multipleOf JSON Schema does not allow; an #[In] or #[NotIn] set that is empty, keyed, or carries a non-scalar or non-finite member; a #[FileSize] with a negative bound or a minimum above its maximum; a #[FileExtension] set that is empty or keyed, or whose members are not alphanumeric suffixes written without a leading dot (png, tar.gz); and a #[Regex] pattern PCRE cannot compile, which left to run would match nothing and reject every value the field ever receives.

Two rules on the same field may not contribute the same keyword. #[MinLength(3)] #[MinLength(5)] states two different minimum lengths and only one of them could be published, so generating the schema fails with Exception\JsonSchemaException rather than letting declaration order decide which bound a client is told about.

A rule may not restate the shape the PHP declaration itself owns, either. The declared type is what Hydrator checks a value against, so a rule contributing type — or items, on a #[ListOf] field — would publish a shape no request is held to, and Exception\JsonSchemaException refuses that declaration for the same reason. A rule refines the declared shape (#[MinLength(3)] string narrows which strings bind); it cannot replace it. What a rule nests inside its own keyword is never inspected.

#[MinLength]/#[MaxLength], #[GreaterThan]/#[LessThan], #[GreaterThanOrEqual]/#[LessThanOrEqual] and #[MinItems]/#[MaxItems] compose on the same field for a length, numeric or cardinality range — Hydrator runs every Constraint-implementing attribute on a parameter, not just the first one.

#[MinItems]/#[MaxItems] bound a list-shaped array field: a plain one, or a #[ListOf] one whose elements are counted once they have hydrated. The list-shape check itself stays Hydrator’s, so a field that isn’t a JSON array fails there first and never reaches the bound. A rule about a list’s elements rather than the list itself is declared with #[Each].

Hydrator::hydrate() checks every constrained field before constructing the DTO, so a request with three invalid fields gets all three violations back in one RFC 9457 problem document. The response is produced at the terminal boundary, so an application can replace it — see Rendering validation failures.

Scalar type checking

Before a value is cast to a #[Body] field’s, #[Query] parameter’s, or path parameter’s declared scalar type, its actual shape is checked first — casting only ever happens once that check passes. Every source of typed input enters the same method, Hydrator::resolveScalar(): a #[Body] DTO field, a #[Query]/path parameter, and an MCP tool’s own top-level argument, which Kinetis\Mcp\McpDispatcher hands to it directly.

A request value binds to one of seven builtin types: string, int, float, bool, array, iterable, mixed. Every other builtin — null, true, false, object, callable — is a definition error, not a runtime one: see Builtin types outside the supported set.

Which spelling satisfies a declared type depends on where the value came from, because different sources can say different things. A JSON document distinguishes 42 from "42"; a query string has no spelling for a number other than its text. Kinetis\Validation\InputSource names the three vocabularies, and the caller that read the bytes picks one:

Source

Chosen for

Carries

Json

a JSON #[Body], an MCP tool argument

already-decoded JSON values, each with its own type

Text

#[Query], path segments, application/x-www-form-urlencoded and multipart/form-data bodies

raw strings only

Native

a direct Hydrator::hydrate() call — an array a service built itself

whatever PHP values the caller already holds

All three agree on these:

  • A string-typed field/parameter must be a string. An array, object, number, or boolean is rejected.

  • An array-typed field/parameter (no #[ListOf]) must be a real JSON array ([...]), never a JSON object ({...}) — including the empty object {}, and including one whose own keys happen to look sequential ({"0":"a","1":"b"}). The request body is decoded with json_decode(..., associative: false), not true, so this distinction survives: every JSON object anywhere in the body is marked before array_is_list() is consulted, so a map-shaped value — of any shape, empty included — is always rejected with its own message (“must be a JSON array, not a JSON object.”). #[ListOf]’s own array gets the identical list-shape check. An array field that is meant to take a JSON object declares it — see Object-map properties.

  • An iterable-typed field/parameter gets the identical check as array — decoded input can only ever produce a PHP array, never a real Traversable, so the accepted shape and the wire contract are the same as array’s.

  • mixed accepts anything, JSON null included. Its own JSON Schema is the empty schema object ({}), never a bare [].

They differ on the numeric and boolean scalars:

  • An int accepts, under Json and Native, a JSON integer (42) and a float with no fractional part (42.0) — JSON has one number type, so a producer writing an integer that way still wrote an integer — both inside PHP’s native integer range. Text and Native accept an integer’s textual spelling: a plain base-10 string ("42", "+42", "-42"), which under Text is the only spelling there is. Json accepts no string — the schema published for that field says {"type": "integer"}, and "42" is a string. Where a string is accepted it is read as written, never through a float, so a decimal spelling ("42.0"), an exponent spelling ("4.2e1"), a whitespace-padded one, and a value a double cannot tell apart from an integer ("1.0000000000000001") are all rejected. So is a fractional, non-finite, or out-of-range number: the result is a 422 (“must be an integer within the platform integer range.”), never a truncated cast — 4.5 does not become 4. An array or a boolean is rejected under every source.

  • A float accepts either JSON number under Json and Native, and a numeric string — Text’s only spelling — under Text and Native. It rejects any value that isn’t finite ("1e999" overflows to INF). A non-numeric string, an array or a boolean is rejected everywhere.

  • A bool is the JSON literal true/false under Json and Native, and Native adds 1, 0, "1", "0", which is what a TINYINT(1) column produces depending on the driver. Text has the four textual spellings OpenAPI documents and only those — "true", "false", "1", "0". Under Json, nothing but the literal: "true" and 1 are both a 422.

Each source is held to its own domain rather than trusted to stay inside it. Text carries raw strings, so a scalar declaration reading a Text value binds a textual spelling and nothing else: a real PHP int, float or bool is no more a Text value than "42" is a JSON integer. (array/iterable still take a real array under Text — a repeated key produces one — and mixed is unconstrained everywhere.)

A mismatch is a 422 carrying a violation at that field’s own path, in the same errors list a failed constraint produces — never a value silently coerced into something that looks plausible (an array becoming the string "Array", a non-numeric string becoming 0), and never a raw TypeError escaping the constructor. Its code is type_mismatch, with the declared and received type names in parameters. Every field’s own violations are collected before throwing once. An MCP tool’s own validation failure carries that same ordered errors list inside a tools/call result’s isError: true content — the violations, not the HTTP problem document around them — rather than a JSON-RPC-level error; see Model Context Protocol (MCP).

Builtin types outside the supported set

null, true, false, object and callable are rejected as declarations, before any request reaches them. A JSON body decodes into arrays and scalars, never a real PHP object; a query string and a path segment carry text only; and a callable-typed parameter fed an attacker-controlled string is an arbitrary-function-name-injection risk if it is ever invoked downstream. None of them has a request value worth supporting, so none of them is accepted anywhere a request value is bound.

The rejection fires wherever the binding is described:

  • A #[Body] DTO field is rejected with Exception\UnsupportedDtoDefinitionException when its hydration plan is compiled — at build time for an AOT-compiled plan, on the first hydration for a live one.

  • A #[Query] or path parameter is rejected with Kinetis\Http\Exception\UnresolvableParameterException when the route’s binding plan is derived, which Router::register() does eagerly — so the route never registers, is never advertised at /openapi.json, and never accepts traffic.

  • An OpenAPI or MCP schema is refused with Exception\JsonSchemaException, since there is no shape a client could be told to send.

A controller parameter that reads no request value at all — one filled from the request container or from its own default — is unaffected.

Class types meet the same registration-time boundary from the other side: a #[Query]/path parameter admits a backed enum and nothing else, and a #[Body] DTO field admits an instantiable class or a backed enum. Each refusal has a schema counterpart in Exception\JsonSchemaException, since a declaration neither can bind has no shape to publish either.

Required, optional, and absent fields

A constructor parameter with no default is required: omitting its member is an is required. violation (code required), whether or not the declared type accepts null. A field sent as null whose declared type doesn’t allow null is must not be null. (code null_not_allowed). Both are violations at the field’s own path, never a raw TypeError from the constructor. A parameter with a default may be omitted and receives that default, and no rule on it runs — an omitted default is the application’s own value, not something the client sent.

Nullability and required presence are independent: a nullable field with no default (?string $name) still rejects an absent key, accepting only one explicitly present and set to null. The generated OpenAPI schema (and an MCP tool’s inputSchema) states this exactly, listing that field in required and giving it type: ["string", "null"].

That leaves one question a default alone cannot answer. On an update, a field the client did not mention and a field the client explicitly cleared are different instructions, and ?string $bio = null binds null for both. Kinetis\Validation\Absent is the third state:

use Kinetis\Validation\Absent;
use Kinetis\Validation\Constraints\{MaxLength, NotBlank};

final readonly class UpdateArticleRequest
{
    public function __construct(
        #[NotBlank, MaxLength(255)]
        public string|Absent $title = Absent::Value,
        public string|null|Absent $summary = Absent::Value,
    ) {}
}

$title is Absent::Value when the member was omitted and the string when one was sent; sending null is a null_not_allowed violation, since the declared type does not name null. $summary adds null to the union, so all three answers are available: omitted, cleared, or set.

Exactly two union forms are supported, T|Absent and T|null|Absent, and the parameter must default to exactly Absent::Value. T is one otherwise-supported field type — a scalar, a backed enum, a nested DTO, a #[ListOf] list, an #[ObjectMap] — and a supplied value is checked against it exactly as T alone would be. Anything else about the declaration is a definition error; see DTO definitions Kinetis rejects.

Nothing a client sends can produce the marker. It comes from the parameter’s own default and nowhere else, so an Absent value appearing in hydrated data is read as the wrong type for that field, never as omission. The generated schema describes T (widened with null only where the union names it), never Absent, and leaves the member out of required because the declaration carries a default.

This is a DTO constructor field’s contract. A #[Query] parameter, a path parameter and an MCP tool method parameter still reject every union — an absent query key is already answered by that parameter’s own default. Create and update stay separate DTO classes: the HTTP method never selects behavior, and one class never changes shape per verb.

kinetis/query-builder does not recognize Absent. To write such a DTO to a table, leave its omitted fields out of the row first — see partial updates with RowValues.

Unknown members are rejected for JSON

A JSON request body, and an MCP tool call’s arguments, describe an object whose members are exactly the DTO’s own. A member outside that set is something the client believes it is sending and the application will never read — a misspelling, a renamed field, a value meant for a different endpoint — so it is an is not expected. violation on its own path (code unexpected_field), reported alongside every other failure the same request has:

{
    "errors": [
        { "path": ["email"], "code": "required", "message": "is required.", "parameters": {} },
        { "path": ["nmae"], "code": "unexpected_field", "message": "is not expected.", "parameters": {} }
    ]
}

Closure applies at every nesting level: a nested DTO’s own object and a #[ListOf] DTO element’s are closed under their own paths, and a #[Body('root')] document is closed around its root. An #[ObjectMap] property stays open inside itself — arbitrary keys are what it accepts — while the DTO holding it is closed like any other.

Form-encoded and multipart bodies are not closed. They legitimately carry members that are not fields — a CSRF token, the submit button’s own name, a honeypot — and rejecting those would break input that is doing nothing wrong. A direct Hydrator::hydrate() call is not closed either: its InputSource::Native default exists for callers handing over PHP values they already hold, such as an array wider than the DTO reading it.

Every generated object schema states the closed contract with additionalProperties: false, including a DTO with no fields at all. That is the strict reading of the two closed sources; a form body is more tolerant at runtime, so a client that sends only what the document describes is accepted whichever source it writes in.

Asymmetric-visibility properties

Hydrator and Dispatcher reason about a DTO through constructor-parameter reflection, so PHP 8.4’s asymmetric visibility needs no special handling:

use Kinetis\Validation\Constraints\MinLength;

final class UpdateStatusRequest
{
    public function __construct(
        #[MinLength(3)]
        public private(set) string $status,
    ) {}
}

The property’s visibility declaration is irrelevant to how it is bound and validated — the constructor parameter is what both classes inspect.

Nested DTOs

A constructor parameter typed as another class — not a builtin scalar — is hydrated as a nested DTO, recursively, the same way the top-level #[Body] DTO itself is:

use Kinetis\Validation\Constraints\MinLength;

final readonly class Address
{
    public function __construct(
        #[MinLength(3)]
        public string $street,
        public string $city,
    ) {}
}

final readonly class CreateOrderRequest
{
    public function __construct(
        #[MinLength(2)]
        public string $customerName,
        public Address $shippingAddress,
    ) {}
}

A nested DTO’s own validation runs the same way its parent’s does — every field, top-level and nested, is checked before construction, and a nested field’s violation carries the whole route to it as path segments:

{
    "errors": [
        {
            "path": ["shippingAddress", "street"],
            "code": "min_length",
            "message": "must be at least 3 characters.",
            "parameters": {"length": 3}
        }
    ]
}

A class-typed field accepts exactly two shapes and nothing else: an object-shaped value, hydrated into the declared class; or a value that is already an instance of that class, taken as given — most notably an UploadedFileInterface merged in for a multipart field, which is additionally checked for the transport status described in Uploads. A scalar, a null for a non-nullable field, or an object of some other class is a 422 at that field’s own path, never a raw TypeError from the constructor.

Object-shaped means a JSON object ({...}, including {}) or — for a direct Hydrator::hydrate() call or a form-encoded body, neither of which carries a JSON object/array distinction — a map-shaped PHP array. A JSON array is not an object: [] and [...] are both a 422 (“must be a JSON object, not a JSON array.”) even for a class whose every field has a default.

A field typed as a class that cannot be instantiated — an interface, an abstract class, a unit enum — accepts only an existing instance: nothing on the wire can construct one, so an array or a scalar for it is a 422 (“must be a Psr\Http\Message\UploadedFileInterface instance.”). That is how a #[Body] DTO’s own file field works, since Dispatcher merges the uploaded file in as an object. A backed enum is the one exception — its cases have wire values — see Backed enum fields.

Backed enum fields

A constructor parameter typed as a backed enum takes the case its backing value names:

enum Priority: int
{
    case Low = 1;
    case Normal = 2;
    case High = 3;
}

final readonly class CreateTicketRequest
{
    public function __construct(
        public string $subject,
        public Priority $priority,
        public ?Priority $escalation = null,
    ) {}
}
{ "subject": "Printer offline", "priority": 2 }

The wire value is the scalar the enum is backed by — a JSON number for an int-backed enum, a string for a string-backed one — and it is checked as that scalar before any case is looked up, so every rule in Scalar type checking applies unchanged: a JSON body refuses the string "2" where an int-backed enum is declared, a query string or form body accepts it (text is all either carries), and 2.0 binds because that is how a producer with one number type writes the integer 2.

A correctly typed value that names no case is a 422 at that field’s own path, carrying every backing value the enum has:

{
    "path": ["priority"],
    "code": "enum_case",
    "message": "must be one of: 1, 2, 3.",
    "parameters": {"choices": [1, 2, 3]}
}

A value that is already a case of that enum is taken as given, the way a class-typed field takes an instance. Rules run against the resolved case, not the backing value, so an application rule declared on the field receives a Priority; its own schema keywords merge beside the enum’s type and enum, which — like any other declared shape — a rule may not restate.

The generated document publishes both halves of the domain — the backing type and the exact enum — and a nullable field widens both, since null has to satisfy each:

{
    "priority": { "type": "integer", "enum": [1, 2, 3] },
    "escalation": { "type": ["integer", "null"], "enum": [1, 2, 3, null] }
}

A #[Query] or path parameter typed as a backed enum resolves through this same path and publishes this same schema pair — see Enum path and query parameters. An MCP tool argument typed directly as a backed enum still fails registration: a tool’s arguments are one flat object with no DTO to own the distinction. A unit enum has no backing values at all, so a field declaring one stays instance-only, exactly like an interface — the BackedEnum interface itself included.

Typed collections

A constructor parameter typed array and carrying #[ListOf] declares what its elements are — array itself carries no element type for Kinetis to reflect on. Four families are admitted, and the attribute names one of them:

use Kinetis\Validation\ListOf;

final readonly class PublishRequest
{
    public function __construct(
        #[ListOf('string')]
        public array $tags,
        #[ListOf(Priority::class)]
        public array $priorities,
        #[ListOf(OrderItem::class)]
        public array $items,
    ) {}
}
  • A scalarstring, int, float or bool — resolves each element exactly as a field of that type resolves its own value, including the source’s own spellings.

  • A backed enum resolves each element’s backing value first and then the case it names, exactly as a backed enum field does.

  • An instantiable class hydrates each object-shaped element into that class, or takes an element already an instance of it.

  • Psr\Http\Message\UploadedFileInterface, and no other interface, takes each element as the uploaded file it already is — the repeated file control a photos[] form sends. See Nested and repeated file controls.

The field itself must be a real JSON array; a JSON object for it is the same 422 a plain array field gets. No element is nullable — a list declares one element type — so a null element is a violation at its own index rather than a hole in the list.

use Kinetis\Validation\Constraints\{GreaterThan, MinLength};
use Kinetis\Validation\ListOf;

final readonly class OrderItem
{
    public function __construct(
        public string $product,
        #[GreaterThan(0)]
        public int $quantity,
    ) {}
}

final readonly class CreateOrderRequest
{
    public function __construct(
        #[MinLength(2)]
        public string $customerName,
        #[ListOf(OrderItem::class)]
        public array $items,
    ) {}
}

Every element’s own violations carry the field name, the element’s index as an integer segment, and — for a DTO element — the nested field name, alongside every other violation in the same response:

{
    "errors": [
        {
            "path": ["items", 1, "quantity"],
            "code": "greater_than",
            "message": "must be greater than 0.",
            "parameters": {"threshold": 0}
        }
    ]
}

A scalar or enum element carries the same path without the third segment — ["tags", 0] — since the element is the value that failed. An index stays an integer all the way out, so an element’s position is never confused with a member named 1. Every element is attempted, so one request reports every bad element rather than only the first.

A rule for every element

#[Each] declares a rule that runs against each element of a scalar, backed-enum or uploaded-file list, rather than against the list itself. It is repeatable, and each occurrence names a Constraint class plus the arguments that rule’s own constructor takes — positional or named:

use Kinetis\Validation\Constraints\{MinItems, MinLength, Regex};
use Kinetis\Validation\{Each, ListOf};

final readonly class TagRequest
{
    public function __construct(
        #[MinItems(1)]
        #[ListOf('string')]
        #[Each(MinLength::class, 2)]
        #[Each(Regex::class, pattern: '/^[a-z-]+$/')]
        public array $tags,
    ) {}
}

The two levels of rule describe different things and run in this order:

  1. Each element is resolved to the declared element type. An element of the wrong type gets its type violation and runs none of its rules.

  2. Every resolved element runs every #[Each] rule. Failures aggregate under ["tags", <index>] paths, together with any further path the rule’s own violation carries.

  3. Only once every element has succeeded do the field’s own rules run. #[MinItems] counts a list that was built, so a single bad element leaves it nothing to count and it does not report.

A rule declared with #[Each] sees an element of the declared type — an enum list’s rules receive the resolved case, not its backing value. The rule class is built when it runs, from the arguments as written, so an argument it refuses raises where any other rule’s arguments do, at construction.

In the generated document the two levels stay separate too: the field’s own rules contribute keywords to the array, and its #[Each] rules to items, beside whatever the element type already states there.

{
    "tags": {
        "type": "array",
        "items": { "type": "string", "minLength": 2 },
        "minItems": 1
    }
}

#[ListOf] and #[Each] declare a DTO constructor field. A controller or MCP tool method parameter binds one value each and traverses no elements, so a list whose elements are checked belongs in a #[Body] DTO, or in the DTO an MCP tool takes as its argument.

Object-map properties

A constructor parameter typed array and carrying #[ObjectMap] takes a JSON object of arbitrary keys — the shape a plain array field refuses — and receives it as a plain PHP array:

use Kinetis\Validation\ObjectMap;

final readonly class UpdateProfileRequest
{
    public function __construct(
        public string $username,
        #[ObjectMap]
        public array $preferences,
    ) {}
}
{
    "username": "alon",
    "preferences": { "locale": "en", "digest": { "weekly": true } }
}

$preferences arrives as ['locale' => 'en', 'digest' => ['weekly' => true]] — nested objects unwrapped to plain arrays all the way down, the same value a mixed field would have received. No schema is declared or checked for the keys or the values.

A JSON array, a scalar, or a null for a non-nullable property is a 422 at that property’s own path — ["preferences"], with the message must be a JSON object, not a JSON array. — alongside every other violation in the same response. Inside a nested DTO the parent field is prepended, ["profile", "preferences"].

{} is accepted and hydrates to [], which is the value a plain array field rejects. That pair is decided by provenance, not by shape: a JSON object and a JSON array decode to the same PHP array, so only the object/array distinction the request body’s own decode preserves can tell {} from []. A source that never carried that distinction — a form-encoded body, or a direct Hydrator::hydrate() call with a hand-built PHP array — therefore cannot fill an #[ObjectMap] property at all; a JSON request body and an MCP tool call both can.

#[ObjectMap] is only valid on a parameter typed array, and never on the same parameter as #[ListOf]: one admits a JSON object and the other a JSON array, so a parameter carrying both would accept nothing.

Writing your own constraint

A constraint is any class implementing the two-method Constraint interface. validate() answers what a broken value gets back; schema() answers which JSON Schema keywords state the same rule to a client generating requests from the published document.

use Kinetis\Validation\Constraint;
use Kinetis\Validation\Violation;
use Attribute;

#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
final readonly class Uppercase implements Constraint
{
    public function validate(mixed $value): ?Violation
    {
        if (!is_string($value) || preg_match('/^[A-Z]+$/', $value) !== 1) {
            return new Violation([], 'uppercase', 'must be all uppercase letters.');
        }

        return null;
    }

    public function schema(): array
    {
        return ['pattern' => '^[A-Z]+$'];
    }
}

Returning null from validate() means valid. A Violation carries the stable code a client switches on, the default-English message, and the parameters that message was built from — its path is relative to the value being checked, so it is normally [] and Hydrator prefixes the owning field’s own path. Every built-in rule is written this way; none of them is special-cased anywhere.

schema() returns the JSON Schema 2020-12 keywords for the rule, merged into the schema of whatever it guards — ['pattern' => '^[A-Z]+$'] above, an ECMA-262 expression because that is the dialect JSON Schema uses. A rule no keyword expresses returns [], exactly as #[NotBlank] and #[Regex] do: it still runs, it publishes nothing. Never state a keyword the check does not enforce — one class owning both answers is what keeps the document and the check from disagreeing.

Any attribute implementing Constraint on a parameter is picked up automatically, in validation and in the generated OpenAPI/MCP schema alike — there is no list of known constraints to register into.

A rule is constructed fresh for each validation or schema operation from the literal arguments its attribute was written with, and discarded, so it must be pure: no I/O, no container, no request state, nothing retained between calls. A rule that throws is a programmer error and propagates as an ordinary exception, not a 422.

Rules about the whole DTO

A Constraint sees one value, so it cannot express a rule relating two of them. ObjectConstraint is the class-level counterpart: a rule about the whole DTO, discovered from a class attribute the same way a field rule is discovered from a parameter attribute.

use Kinetis\Validation\ObjectConstraints\{AtLeastOneProvided, SameAs};

#[AtLeastOneProvided('title', 'summary')]
final readonly class UpdateArticleRequest { /* ... */ }

Attribute

Checks

Constructor

Violation path

Violation code

Schema

#[AtLeastOneProvided(...)]

the input supplied at least one of the named fields

string ...$fields

[]

at_least_one_provided

anyOf of one required per field

#[SameAs($field, $other)]

the two fields hold the identical value

string $field, string $other

[$field]

same_as

(none)

#[AtLeastOneProvided] is what makes an empty update a client error rather than a silent no-op write. It asks about presence only, never values: a field sent as null counts as supplied wherever the declaration accepts null, because clearing a field is a change.

#[SameAs] compares only when the input supplied both members — whether either had to be there at all is the declaration’s question, or another rule’s. It reads both values off the constructed object by reflection, so a promoted private field needs no getter, and reports on $field, the one the client is asked to retype. No JSON Schema keyword compares two properties’ values, so it publishes nothing rather than something weaker.

Writing your own is the field-rule contract, one level out:

use Kinetis\Validation\{ObjectConstraint, ValidationContext, Violation};
use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class EndsAfterItStarts implements ObjectConstraint
{
    public function fields(): array
    {
        return ['startsAt', 'endsAt'];
    }

    public function validate(object $value, ValidationContext $context): iterable
    {
        if ($value->endsAt <= $value->startsAt) {
            yield new Violation(['endsAt'], 'ends_after_start', 'must be after startsAt.');
        }
    }

    public function schema(): array
    {
        return [];
    }
}

fields() names every constructor field the rule reads off the object or asks the context about — the names its attribute was configured with, or the ones it has hardcoded, as here. Kinetis checks each of them against the guarded class’s constructor where the hydration plan is compiled and where the schema is generated, so a mistyped name fails as a definition error instead of a rule that silently never matches or an anyOf no request can satisfy. A rule about the object as a whole, naming no field, returns [].

Object rules run once, last: after every field has resolved, passed its own rules, and the DTO has been constructed. If any field failed, no object rule runs at all — the only values available then are the declaration’s own defaults, and a rule reporting on those would be reporting on something the client never sent.

ValidationContext carries the one thing a constructed object cannot be asked: which of its own constructor fields the input supplied. wasSupplied(string): bool is its whole read surface. It holds no request, container, transport or DTO, and is built for one hydration.

Yielded paths are relative to the DTO — [] addresses the object as a whole — and the owner prefixes its own field name or list index when the DTO is a nested one, exactly as it does for a field failure. Like a field rule, an object rule is constructed fresh from the literal arguments its attribute was written with and discarded, so it must be pure. Throwing, or yielding anything other than a Violation, is a programmer error and propagates as an ordinary exception, not a 422.

schema() contributes object-level keywords, merged into the class’s own schema under the same one-owner rule field rules follow: a rule cannot claim type, properties, required or additionalProperties, which the PHP declaration owns, nor a keyword another rule on the same class already contributed. Either is a definition error rather than a silently overwritten bound.

DTO definitions Kinetis rejects

A hydration plan is compiled from a DTO’s constructor by reflection — ahead of time by kinetis build, or on that class’s first hydration otherwise. It supports a finite set of parameter shapes: one of the seven supported builtin types, a backed enum, a single named class (hydrated when it can be instantiated, instance-only when it can’t), an array carrying #[ListOf], an array carrying #[ObjectMap], nullable variants of each, and the presence union T|Absent/T|null|Absent around any of them (see Required, optional, and absent fields).

Anything else is rejected while the plan is compiled, with an UnsupportedDtoDefinitionException naming the class and the parameter — so the definition fails at build time, or on that route’s first request in development, rather than as a TypeError on a live one:

  • An intersection parameter type (Countable&ArrayAccess), or a union other than the two presence forms (int|string). Declare a single named type, or T|Absent/T|null|Absent where the field needs to tell an omitted member from an explicit null.

  • A malformed presence union: Absent with no value type beside it (?Absent), with two of them (int|string|Absent), with no default, or with a default other than Absent::Value. Nothing but that default can produce the marker, so any other spelling names a field that could never hold one.

  • A recursive or mutually recursive class reference — a Comment with a Comment $parent field, or two DTOs naming each other. A plan embeds each nested class’s own plan inline, so a cycle has no finite plan, and nothing Caching & AOT Compilation’s AOT compilation could bake into a cache file through var_export(). Take the nested payload as a plain array field, or model the deeper level as its own request.

  • A class type reflection cannot resolve to a real class: self, parent, static.

  • A builtin type outside the supported set — see Builtin types outside the supported set.

  • #[ListOf] on a parameter that isn’t typed array, or naming an element type outside the four families it admits — another builtin, an empty name, a name no class answers to, an interface other than UploadedFileInterface, an abstract class, a unit enum.

  • A backed enum with no cases, named by a field or by a #[ListOf]. No value can name a case that does not exist, and JSON Schema’s enum may not be empty, so such a field could only publish a schema it rejects every request against.

  • #[Each] on a parameter that declares no #[ListOf], on a list of DTOs — whose elements carry their own fields’ rules — or naming a class that does not implement Constraint. An upload list may carry it: an uploaded file has no fields of its own on which a rule could otherwise be written.

  • #[ObjectMap] on a parameter that isn’t typed array, or on the same parameter as #[ListOf].

  • A #[Body] DTO class that cannot itself be instantiated.

  • An ObjectConstraint class attribute naming a field the constructor does not declare — see Rules about the whole DTO. Schema generation reads the same rules and refuses it identically.

The generated OpenAPI document and MCP tool input schemas hold the same line: a class-typed field whose class cannot be instantiated has no truthful object schema, so schema generation refuses it rather than emitting a bare {"type": "object"} no request could satisfy. UploadedFileInterface is the one such type both sides accept, as a field and as a #[ListOf] element alike — it is described as {"type": "string", "format": "binary"} and supplied by Dispatcher from the request’s normalized uploaded files.

Default values a plan captures

A plan — the hydration plan behind a #[Body] DTO, the binding plan behind a controller method — is derived once and reused: memoized for a persistent worker’s whole lifetime, and written into .kinetis-cache/compiled.php by kinetis build (see Caching & AOT Compilation). The default value it captures is handed to every request that leaves that parameter unfilled, so a default has to be a value PHP would have rebuilt identically on every evaluation: a scalar, null, an array of those, or an enum case. An enum case qualifies because a case is a process-wide singleton — there is no second instance for a plan to hand out in place of the one the declaration names.

Any other object default is rejected where the plan is derived, with an UnsupportedDefaultValueException naming the DTO class or Controller::method() and the parameter: on that DTO’s first hydration or that route’s registration in development, at build time for an AOT build.

enum SortDirection: string
{
    case Ascending = 'asc';
    case Descending = 'desc';
}

final readonly class SearchRequest
{
    public function __construct(
        public string $term,
        // Captured: every request that omits `direction` gets this case.
        public SortDirection $direction = SortDirection::Ascending,
        // A per-request value: `new DateTimeImmutable()` written here
        // is rejected, since a plan holds one moment — the first
        // request's, or the build's — for every request after it.
        public ?DateTimeImmutable $since = null,
    ) {}
}

Where a request-time object is what the parameter wants, declare it nullable with a null default, as $since does above, and build the real value in the constructor body or in the controller. That code runs per request, which a captured default cannot do.

Responses

Response builders

  • HtmlResponse::create(string $html, int $status = 200) sets Content-Type: text/html.

  • PlainTextResponse::create(string $text, int $status = 200) sets Content-Type: text/plain.

  • FileResponse::fromContents(string $contents, string $contentType, int $status = 200, ?string $downloadFilename = null) builds a response around bytes you already hold — a generated CSV, image or PDF — and adds a Content-Disposition: attachment header when $downloadFilename is given.

  • RedirectResponse::to(string $url, int $status = 302) sets a Location header.

  • ErrorResponse::create(int $status, string $message, array $headers = []) builds {"error": "..."} at the given status, the same shape Kinetis’s own 404/405/500 responses use — a 405 (a path matches, but not this method) carries an RFC 9110 Allow header listing every method the path supports, via this same $headers parameter.

All five live in Kinetis\Http\Responses. No response builder takes a filesystem path: reading one synchronously holds the worker for the length of the I/O, and core carries no asynchronous filesystem client.

Download filenames are treated as untrusted

$downloadFilename is usually whatever a user called the file when they uploaded it, so it is escaped rather than dropped into the header. The name is written as an RFC 6266 quoted-string with \ and " escaped as quoted-pairs, so a value like a.pdf"; filename="evil.exe stays one value instead of closing the quoting and appending a second filename parameter that decides what the browser saves.

A name carrying anything outside ASCII is sent twice: an ASCII fallback in filename, with each non-ASCII byte replaced by _, and the real name percent-encoded in filename*=UTF-8''… per RFC 8187, which recipients prefer when they understand it.

attachment; filename="na__ve-r__sum__.pdf"; filename*=UTF-8''na%C3%AFve-r%C3%A9sum%C3%A9.pdf

A control character, or an empty string, raises FileResponseException. PSR-7 refuses a control character in any header value regardless, but as a complaint about the header rather than about the argument that produced it. Pass null for no download name.

Path separators are left alone: RFC 6266 puts stripping them on the recipient, and rewriting the name here would change what the caller asked for. Call basename() yourself if the source is a stored path rather than a name.

#[Response] and the route’s own status

A route attribute’s status is used when the controller returns plain data — an array or a DTO. A returned ResponseInterface passes through Dispatcher untouched, with whatever status, headers and body it carries.

#[Response(int $status, string $description, ?string $body = null, string $mediaType = 'application/json')] is repeatable and purely descriptive: Dispatcher never reads it, only OpenApiGenerator does. Each one adds one entry to that operation’s responses alongside the route’s default, and nothing checks that the method produces the status, the shape or the media type it declares. It documents the statuses besides the route’s own. The generator describes the route’s own status from the method’s return type, response schema included, so an attribute repeating that status is ignored rather than replacing the richer entry with a bare description.

$body names the DTO that status’s payload is shaped like — an error envelope, a problem document — which the method’s declared return type does not describe. It is published as that class’s component schema under $mediaType, through the same deduplication a request body and the default response use, so a DTO named by two statuses, or by a status and a request body, is one components/schemas entry referenced twice:

#[Get('/users/{id}')]
#[Response(404, description: 'User not found.', body: ApiError::class)]
#[Response(422, description: 'Validation failed.', body: ApiError::class, mediaType: 'application/problem+json')]
#[Response(503, description: 'Temporarily unavailable.')]
public function show(int $id): ResponseInterface|UserResponse
{
    "404": {
        "description": "User not found.",
        "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiError"}}}
    },
    "422": {
        "description": "Validation failed.",
        "content": {"application/problem+json": {"schema": {"$ref": "#/components/schemas/ApiError"}}}
    },
    "503": {"description": "Temporarily unavailable."}
}

Without a $body the entry carries a description and nothing else, and $mediaType then describes nothing and is not read at all. A $body naming something that is not a class fails document generation with Exception\JsonSchemaException: publishing a component under a name nothing backs would advertise a response shape no route can produce.

Kinetis adds no status of its own here. A route that should advertise the 422 a validation failure produces declares its own #[Response(422, ...)], with the problem document’s DTO as the body where it has one.

OpenAPI generation

What the document describes

Every route Router has registered is reflected a second time — the same controller-method metadata Dispatcher reads at request time — to build an OpenAPI 3.1 document.

#[Body] DTOs become requestBody schemas, with every constraint mapped onto the matching JSON Schema keyword (format: email, minLength/maxLength, exclusiveMinimum/exclusiveMaximum, minimum/maximum, multipleOf, enum, not, minItems/maxItems, format: uri, format: uuid, format: date, format: date-time, and #[Ip]’s anyOf of the two address formats) — except the rules with no keyword listed in Validation constraints. #[Query] parameters and path parameters become parameters entries, with the identical constraint-to-keyword mapping applied to their own schema; one typed as a backed enum publishes that enum’s backing type and the exact enum of its case values. A controller method’s declared return type becomes the default response’s schema — UserResponse (or ?UserResponse, or a union like ResponseInterface|array where UserResponse is one member) produces a content entry describing it; a bare array/ResponseInterface-only return, with no shape reflection can recover, leaves the response description-only.

A rooted #[Body('article')] publishes the document it reads — {"type": "object", "properties": {"article": {"$ref": ...}}, "required": ["article"], "additionalProperties": false} — under the content types an unrooted #[Body] of the same DTO gets, so a DTO declaring an upload still advertises multipart/form-data alone. The DTO’s own component is the same either way, and an unrooted #[Body] publishes its bare $ref.

Each additional status a #[Response] attribute declares becomes its own entry, with the DTO it names published as a component schema under the attribute’s media type — see #[Response] and the route’s own status” above.

A #[ListOf] field becomes a {"type": "array", "items": ...} schema: items describes the element class the same way any other DTO reference does, or the element’s own scalar type or backed-enum domain, with each #[Each] rule’s keywords merged into it and the field’s own constraint keywords merged beside items. A backed enum field becomes its backing type and the exact enum of its cases. An #[ObjectMap] field becomes {"type": "object", "additionalProperties": true}.

Every DTO object schema says additionalProperties: false, and carries whatever object-level keywords its class-level rules contribute — see Unknown members are rejected for JSON for what the runtime holds each source to.

Components

Every DTO schema — whether reached via a requestBody, a response, or a #[ListOf] element, at any depth — is deduplicated into components/schemas and referenced by $ref, rather than inlined at each point of use:

{
    "requestBody": {
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateOrderRequest" } } }
    },
    "components": {
        "schemas": {
            "CreateOrderRequest": {
                "type": "object",
                "properties": {
                    "customerName": { "type": "string", "minLength": 2 },
                    "shippingAddress": { "$ref": "#/components/schemas/Address" }
                }
            },
            "Address": { "type": "object", "properties": { "..." : "..." } }
        }
    }
}

The component name is the DTO’s short class name (CreateOrderRequest, not its full namespace) when nothing else has claimed it; two distinct classes that share a short name fall back to the fully-qualified name instead of overwriting one schema with the other’s.

How the documentation routes resolve

/openapi.json and /openapi are served by an ordinary controller the framework ships, found by the same route discovery that finds yours — so they appear in kinetis routes:list alongside your own routes, and middleware attached to them behaves like middleware anywhere else. The openapi middleware group runs through the normal pipeline, and Kernel registers OpenApiAccess and OpenApiDocumentProvider on each request scope for the controller’s constructor to be autowired from.

The Swagger UI page at /openapi loads Swagger UI from a CDN and sends its own Content-Security-Policy permitting exactly that — see Appendix: Middleware.

Choosing where the documentation is reachable

OPENAPI_ENVIRONMENTS is a comma-separated list of APP_ENV values, compared ignoring case and surrounding space. It is matched against APP_ENV itself rather than against Kinetis’s own AppEnvironment, which resolves every unfamiliar name to production — so a staging deployment can name itself and mean it. Unset, empty, or naming an environment you are not running in, both paths fall through to routing and 404 — nothing confirms that they would exist somewhere else.

An explicit argument decides outright and ignores the variable, which is what a test or a documentation-only service wants:

new Kinetis\Http\Kernel($app, $router, exposeOpenApi: true);

The check runs per request rather than when routes are registered. Registering the routes conditionally would push the decision into kinetis build, and a production image would then answer according to whichever environment compiled it rather than the one it is running in. The routes always exist — routes:list shows them either way — and a closed one answers exactly as an unregistered path does.

When the document is generated

In development the document is generated per request, so an attribute you change is visible on the next reload.

In production it is generated once per process and held in memory for that process’s lifetime, by a provider the Kernel builds for its own router. The route table cannot change under a running process, and a deployment that changes routes, DTOs, or constraints starts new processes, each with its own router and its own provider. The document a process serves therefore always describes the routes that process dispatches: there is nothing to clear, no expiry to wait out, and no cached entry a previous deployment could leave behind.

See also