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.
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:
A
ServerRequestInterface-typed parameter.An
UploadedFileInterface-typed parameter.#[Body].#[Query].A name matching a
{placeholder}in the route’s path template.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 |
|
|
|
|
|
|
|
an invalid |
that field’s own code at |
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=urgenton anint-backed enum is therefore the ordinarynot_an_integerviolation — the value named no case of anything — while?priority=9is anenum_caseviolation carrying{"choices": [1, 2, 3]}.The lookup is
tryFrom()on that backing value, so matching is exact. Astring-backedasccase is not named byASC.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:
boolaccepts the OpenAPI-documented"true"/"false"spelling too, not just"1"/"0". Once the type check has accepted one,Texttranslates those two literal spellings into real PHP booleans, so the(bool)cast never meets the string"false", which would cast totrue.A numeric string binds an
int/floatparameter, 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 theT|Absentpresence 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.jsonsends, and it is the only form that binds — parsed from the request’s raw query string, since PHP’sparse_str()(whatgetQueryParams()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 istags[], nottags— so it satisfies no#[Query] array $tagsparameter: the value is missing, and the parameter’s own default,null, or anis required.422follows, 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 |
|---|---|
|
|
|
|
|
|
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, soboolaccepts both the"1"/"0"and the"true"/"false"spelling andint/floataccept 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 anintfield. It reaches a list’s own scalar elements, and a nested or#[ListOf]DTO’s own scalar fields, via PHP’s bracket-stylefield[sub]=valueconvention.array/iterableget 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 trackingJsonTreeprovides 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 viamultipart/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|AbsentDTO field bindsAbsent::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 |
|---|---|---|---|---|
|
|
(no arguments) |
|
|
|
not empty or all-whitespace after |
(no arguments) |
|
(none) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(none) |
|
|
|
|
|
|
|
|
|
|
|
a list of at least |
|
|
|
|
a list of at most |
|
|
|
|
|
(no arguments) |
|
|
|
RFC 9562’s UUID string form |
(no arguments) |
|
|
|
|
(no arguments) |
|
|
|
|
(no arguments) |
|
|
|
an RFC 3339 |
(no arguments) |
|
|
|
an uploaded file’s reported size, inclusive on both bounds |
|
|
(none) |
|
an uploaded file’s client filename ends in one of them |
|
|
(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 |
|---|---|---|
|
a JSON |
already-decoded JSON values, each with its own type |
|
|
raw strings only |
|
a direct |
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 withjson_decode(..., associative: false), nottrue, so this distinction survives: every JSON object anywhere in the body is marked beforearray_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. Anarrayfield that is meant to take a JSON object declares it — see Object-map properties.An
iterable-typed field/parameter gets the identical check asarray— decoded input can only ever produce a PHP array, never a realTraversable, so the accepted shape and the wire contract are the same asarray’s.mixedaccepts anything, JSONnullincluded. Its own JSON Schema is the empty schema object ({}), never a bare[].
They differ on the numeric and boolean scalars:
An
intaccepts, underJsonandNative, 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.TextandNativeaccept an integer’s textual spelling: a plain base-10 string ("42","+42","-42"), which underTextis the only spelling there is.Jsonaccepts 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 adoublecannot tell apart from an integer ("1.0000000000000001") are all rejected. So is a fractional, non-finite, or out-of-range number: the result is a422(“must be an integer within the platform integer range.”), never a truncated cast —4.5does not become4. An array or a boolean is rejected under every source.A
floataccepts either JSON number underJsonandNative, and a numeric string —Text’s only spelling — underTextandNative. It rejects any value that isn’t finite ("1e999"overflows toINF). A non-numeric string, an array or a boolean is rejected everywhere.A
boolis the JSON literaltrue/falseunderJsonandNative, andNativeadds1,0,"1","0", which is what aTINYINT(1)column produces depending on the driver.Texthas the four textual spellings OpenAPI documents and only those —"true","false","1","0". UnderJson, nothing but the literal:"true"and1are both a422.
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 withException\UnsupportedDtoDefinitionExceptionwhen 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 withKinetis\Http\Exception\UnresolvableParameterExceptionwhen the route’s binding plan is derived, whichRouter::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 scalar —
string,int,floatorbool— 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 aphotos[]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:
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.
Every resolved element runs every
#[Each]rule. Failures aggregate under["tags", <index>]paths, together with any further path the rule’s own violation carries.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 |
|---|---|---|---|---|---|
|
the input supplied at least one of the named fields |
|
|
|
|
|
the two fields hold the identical value |
|
|
|
(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, orT|Absent/T|null|Absentwhere the field needs to tell an omitted member from an explicitnull.A malformed presence union:
Absentwith no value type beside it (?Absent), with two of them (int|string|Absent), with no default, or with a default other thanAbsent::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
Commentwith aComment $parentfield, 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 throughvar_export(). Take the nested payload as a plainarrayfield, 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 typedarray, 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 thanUploadedFileInterface, 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’senummay 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 implementConstraint. 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 typedarray, or on the same parameter as#[ListOf].A
#[Body]DTO class that cannot itself be instantiated.An
ObjectConstraintclass 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)setsContent-Type: text/html.PlainTextResponse::create(string $text, int $status = 200)setsContent-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 aContent-Disposition: attachmentheader when$downloadFilenameis given.RedirectResponse::to(string $url, int $status = 302)sets aLocationheader.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 9110Allowheader listing every method the path supports, via this same$headersparameter.
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¶
Routing & Validation — the task guide this page supports.
Appendix: Middleware — the pipeline around binding and the validation response.
Runtime Adapters — how a request’s raw bytes reach the middleware that fills the uploaded-files bag.
Caching & AOT Compilation — how route, binding and validation plans are compiled ahead of time in production.
Model Context Protocol (MCP) — the same
Hydratorand schema rules applied to MCP tool arguments.