Routing & Validation¶
Kinetis’s routing and validation are both attribute-driven — routes, parameter binding, and constraint validation are declared directly on your controller classes and DTOs, with no separate YAML/XML/array configuration file to keep in sync.
Route attributes¶
use Kinetis\Http\Attributes\{Get, Post, Put, Patch, Delete};
final readonly class UserController
{
#[Get('/users')]
public function index(): array { /* ... */ }
#[Post('/users', status: 201)]
public function store(): array { /* ... */ }
#[Put('/users/{id}')]
public function replace(int $id): array { /* ... */ }
#[Patch('/users/{id}/status')]
public function updateStatus(int $id): array { /* ... */ }
#[Delete('/users/{id}')]
public function destroy(int $id): array { /* ... */ }
}
All five implement a shared RouteAttribute interface (httpMethod(),
path(), status()) — so adding a sixth verb, if you ever needed one,
is a matter of implementing that interface, not touching Router itself.
Routes are discovered automatically: any class anywhere under one of your
own PSR-4 roots is registered the moment a route attribute appears on one
of its methods, with no required directory or namespace convention and
nothing to register by hand — see CLI
(including how to restrict the scan for a large application, and how
installed packages contribute discovered classes through their own
extra.kinetis scan roots). Methods
without a route attribute are silently skipped, so a controller can
freely 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.
Matching is first-match-wins in registration order, so two routes may
overlap — /users/{id:\d+} alongside /users/{id}, or /users/self
alongside /users/{id} — and the earlier registration takes the requests
both match. 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.
Constraining a placeholder’s shape¶
A plain {id} matches any run of characters up to the next /. Add an
optional :pattern suffix — a raw regex fragment, no delimiters, no
anchors — to constrain it further:
#[Get('/orders/{id:\d+}')]
public function show(int $id): array { /* ... */ }
#[Get('/files/{hash:[0-9a-f]{40}}')]
public function download(string $hash): array { /* ... */ }
A path segment that doesn’t match the constraint never matches the route
at all — GET /orders/abc against the first example above 404s the same
way a completely unregistered path would, rather than reaching the
controller with a value that would go on to fail a #[Query]/#[Body]
constraint check instead. {id:\d+} and a #[GreaterThan(0)] on the
same int $id parameter are complementary, not redundant: the route
constraint decides whether this path matches this route at all (versus
falling through to a 404, or to a different route registered for the
same literal segment shape); a parameter constraint decides whether an
already-matched value is valid (versus a 422). A fixed-length
constraint like the SHA-1 example above needs its own {n}/{n,m}
repetition quantifier, which is handled correctly even though it
contains braces of its own — nothing about the placeholder syntax gets
confused by a {...} inside the constraint.
A pattern is regex text Kinetis inserts rather than rewrites, and the
brace scanner that finds where the placeholder ends reads enough PCRE to
know where a } is not that end. All of these parse and match
correctly:
constraint |
matches |
|---|---|
|
a literal |
|
a literal |
|
a letter or a literal |
|
a literal |
|
a literal |
|
|
`{value:[#~!%@ |
+-=]+}` |
The delimiter is ~, and a literal occurrence of it in a pattern is
escaped rather than dodged by picking a different one. Inside a \Q...\E
span that escape needs a rewrite rather than a plain backslash — a
backslash is literal text there, so \~ would match two characters
instead of one — so the span is closed and reopened around it. That
happens automatically; the pattern you write is the pattern that runs.
Warning
The scanner is a bounded reader of those constructs, not a full PCRE parser, and the supported constraint grammar is exactly what it can read faithfully. Two things fall outside it, and both are rejected at registration with an error naming them rather than mis-scanned:
Extended mode — the x flag, via (?x), (?x:...), or x among a
set that enables it like (?imx:...). In extended mode an unescaped #
starts a comment running to the end of the line, so a } after one would
stop closing the placeholder; unlike every construct in the table above,
whether the mode is on at a given point is flag scope rather than
something with a fixed opener and closer. A route constraint is a single
fragment, with no real need for the whitespace and comments extended mode
exists to allow. Only flags a run actually enables count — everything
after a - is being switched off, so (?-x:...) and (?im-sx:...)
register and match normally.
Control verbs — anything spelled (*...), such as (*MARK:name) or
(*atomic:...). Their shape varies by verb: some end at their first )
while others hold a whole nested sub-pattern, so a } inside one can’t
be told apart from the brace closing the placeholder. Use (?>...) for
atomic grouping; the backtracking verbs have no meaning in a
single-fragment constraint.
Both exclusions are about a construct being active, not about the
characters that spell it. The constructs in the table above compose with
them exactly as you’d expect: {value:[(*]} is a character class
matching ( or *, {value:\Q(?x)\E} matches that literal text, and
{value:(?#(*)a} is a comment followed by a — none of them turns
anything on, and all three register and match.
This is purely a routing-time detail: /orders/{id} and /orders/{id:\d+}
are indistinguishable to a client, to #[Query]/#[Body] binding, and
in the generated OpenAPI document — the constraint moves into the path
parameter’s own schema.pattern there, and the path key itself always
reads as plain {id}, since OpenAPI’s own path-templating syntax has no
concept of an inline regex.
Parameter binding¶
A controller method’s parameters are resolved from six possible sources, checked in this order:
#[Body]¶
A parameter attributed #[Body] is bound to the decoded JSON request
body. Its declared type must be a class — that class is the DTO
Hydrator builds and validates (see Validation
below) before the controller method ever runs.
#[Post('/users')]
public function store(#[Body] CreateUserRequest $data): UserResponse
#[Query]¶
A parameter attributed #[Query] is bound to a query-string value of the
same name, cast to the parameter’s declared scalar type. A missing value
falls back to the parameter’s default; without one, a nullable parameter
receives null, and a non-nullable one is a 422 (is required.),
joining the route’s other binding errors in the same response. A value
whose shape doesn’t match the declared type (an array where a scalar is
expected, a non-numeric string for int/float) is also a 422,
not a silently wrong cast — see Scalar type checking
below.
Constraint attributes (#[GreaterThan], #[In], …) work here too, the
same as on a #[Body] DTO field:
#[Get('/users')]
public function index(
#[Query] #[GreaterThan(0)] int $page = 1,
#[Query] #[In(['asc', 'desc'])] string $sort = 'asc',
)
Path parameters¶
A parameter with no attribute at all is matched by name against a
{placeholder} in the route’s path template, if one exists with the same
name, and cast to the parameter’s scalar type — with the identical
type-mismatch check and Constraint-attribute support #[Query] above
describes; a path segment that doesn’t match the declared type or fails
its own constraint is a 422, not a value silently coerced to something
like 0.
#[Get('/users/{id}')]
public function show(int $id)
ServerRequestInterface¶
A parameter typed ServerRequestInterface receives the raw PSR-7 request
directly — no attribute needed, checked ahead of the others. Bypasses
#[Body]’s decoding assumptions entirely, for anything that needs the
request itself: a raw body stream, headers, a different content type.
use Psr\Http\Message\ServerRequestInterface;
#[Post('/webhooks')]
public function receive(ServerRequestInterface $request): array
UploadedFileInterface¶
A parameter typed UploadedFileInterface — no attribute needed, checked
alongside ServerRequestInterface — is resolved directly from the
request’s uploaded-files bag by parameter name. See
Multipart/form-data & file uploads
below.
use Psr\Http\Message\UploadedFileInterface;
#[Post('/files')]
public function receiveFile(UploadedFileInterface $file): array
A request without the expected file 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.
Class-typed parameters: services and request context¶
A class-typed parameter matching none of the above is resolved from the
request container — checked last, so it can never shadow #[Body],
#[Query], or a path placeholder.
This is what lets one controller serve both a public route and a guarded one. A constructor is shared by every route on its class, so naming a middleware-registered value there would demand it on routes that never run that middleware; a method signature is per route:
final readonly class ReportController
{
#[Get('/reports/public')]
public function teaser(): array
{
return ['sample' => true];
}
#[Get('/reports/private')]
#[Middleware(BearerAuthMiddleware::class)]
public function full(CurrentUserInterface $user): array
{
return ['userId' => $user->id()];
}
}
Anything the container can supply works the same way — a repository, a
MailerInterface, whatever a package bootstrap bound — which also means
a dependency only one route needs is only built for that route, instead
of on every request to the class.
If the container cannot supply it, the failure surfaces: a route that forgot the middleware meant to register the value fails loudly rather than handing the controller something disconnected. Give the parameter a default to say that absence is acceptable instead:
#[Get('/reports/maybe')]
public function maybe(?CurrentUserInterface $user = null): array
{
return ['signedIn' => $user !== null];
}
A default has to be written out even when the type is nullable — unlike
#[Query] and path parameters, where a nullable type alone is enough.
Absence means something different here: for those, a missing value is
ordinary input variation, while a value the container cannot supply is
usually a route missing its middleware. Writing the default is how you
say which of the two you meant.
The default covers genuine absence only. A service that was registered
and then failed to construct, or a dependency cycle, is a defect rather
than an absent value, so it is reported rather than quietly arriving as
null.
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).
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 error naming every source it
could have come from, rather than passing null silently.
Multipart/form-data & file uploads¶
#[Body] isn’t limited to JSON. Dispatcher picks how to read the body
from the request’s Content-Type:
Content-Type |
Read from |
|---|---|
|
|
|
|
|
|
A #[Body] DTO can mix ordinary fields with an UploadedFileInterface-typed
constructor parameter — no special handling needed in the DTO itself:
use Psr\Http\Message\UploadedFileInterface;
final readonly class AvatarUploadRequest
{
public function __construct(
public string $name,
public UploadedFileInterface $avatar,
) {}
}
#[Post('/avatars')]
public function upload(#[Body] AvatarUploadRequest $data): array
{
return [
'filename' => $data->avatar->getClientFilename(),
'contents' => (string) $data->avatar->getStream(),
];
}
Validation constraints (#[MinLength], #[Regex], …) work identically on
a multipart-bound DTO’s ordinary fields as on a JSON one — Hydrator never
knows or cares which content type produced the data it’s validating.
An UploadedFileInterface-typed parameter doesn’t have to sit inside a
#[Body] DTO — a top-level controller parameter of that type, with no
attribute, is resolved directly from the request’s uploaded-files bag by
parameter name:
use Psr\Http\Message\UploadedFileInterface;
#[Post('/files')]
public function receiveFile(UploadedFileInterface $file): array
{
return ['filename' => $file->getClientFilename()];
}
Note
This works the same way regardless of which RuntimeAdapterInterface is
driving the request — FrankenPhpAdapter/FpmAdapter populate the
uploaded-files bag via PHP 8.4’s request_parse_body() for PUT/PATCH
(PHP’s SAPI only does this automatically for POST), and
kinetis/bref-adapter’s BrefLambdaAdapter parses it from the Lambda
event body directly. See Runtime Adapters for what differs
underneath each one.
Returning a status other than the route’s default¶
#[Get('/users/{id}')]’s status argument (default 200) is only the
status used when the controller returns plain data — an array or a DTO.
Return a PSR-7 ResponseInterface directly instead, and Dispatcher passes
it through untouched, with whatever status/headers/body you gave it:
use Kinetis\Http\Attributes\Get;
use Kinetis\Http\Attributes\Response;
use Kinetis\Http\Responses\ErrorResponse;
use Psr\Http\Message\ResponseInterface;
final readonly class UserController
{
public function __construct(
private UserRepository $users,
) {}
#[Get('/users/{id}')]
#[Response(404, description: 'User not found.')]
public function show(int $id): ResponseInterface|array
{
$user = $this->users->find($id);
if ($user === null) {
return ErrorResponse::create(404, "User {$id} not found.");
}
return $user;
}
}
Two different things are happening here, and they don’t depend on each other:
The
return ErrorResponse::create(...)is what actually produces the 404 at request time —Dispatchersees aResponseInterfaceand passes it through untouched instead of wrapping it in the route’s default status.The
#[Response(404, description: ...)]attribute only documents that possible outcome for/openapi.json— see Zero-config OpenAPI & Swagger UI below.Dispatchernever reads it; onlyOpenApiGeneratordoes. Nothing enforces that the two agree — you could return a 404 without declaring it, or declare a status the method never actually returns.
It documents the statuses besides the route’s own. The route attribute
already declares that one — 200 unless you set status: — and the
generator describes it from the method’s return type, response schema
included. An attribute repeating that status is ignored rather than
overwriting the richer entry with a bare description, so there is no way
to accidentally strip a route’s own response schema out of the document.
Returning HTML, files, and redirects¶
Any route can return something other than JSON, using the same
ResponseInterface passthrough — Kinetis ships a few response builders
for the common cases:
use Kinetis\Http\Attributes\Get;
use Kinetis\Http\Responses\FileResponse;
use Kinetis\Http\Responses\HtmlResponse;
use Kinetis\Http\Responses\PlainTextResponse;
use Kinetis\Http\Responses\RedirectResponse;
use Psr\Http\Message\ResponseInterface;
final readonly class PagesController
{
#[Get('/welcome')]
public function welcome(): ResponseInterface
{
return HtmlResponse::create('<h1>Welcome</h1>');
}
#[Get('/avatars/{id}')]
public function avatar(int $id): ResponseInterface
{
return FileResponse::fromPath("/storage/avatars/{$id}.png");
}
#[Get('/old-url')]
public function oldUrl(): ResponseInterface
{
return RedirectResponse::to('/new-url', 301);
}
#[Get('/robots.txt')]
public function robots(): ResponseInterface
{
return PlainTextResponse::create("User-agent: *\nDisallow:\n");
}
}
HtmlResponse::create(string $html, int $status = 200)setsContent-Type: text/html.PlainTextResponse::create(string $text, int $status = 200)setsContent-Type: text/plain.FileResponse::fromPath(string $path, int $status = 200, ?string $contentType = null, ?string $downloadFilename = null)reads a file from disk and detects its content type automatically when$contentTypeis omitted.FileResponse::fromContents(string $contents, string $contentType, int $status = 200, ?string $downloadFilename = null)does the same for data you already have in memory — a generated image or PDF, for instance. Either one adds aContent-Disposition: attachmentheader when$downloadFilenameis given — see below.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 already use — a real 405 (a path matches, but not this method) carries a real RFC 9110Allowheader listing every method the path does support, via this same$headersparameter.
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 quietly change what the
caller asked for. Call basename() yourself if the source is a stored
path rather than a name.
Validation constraints¶
DTOs referenced by a #[Body] parameter declare their validation rules as
attributes directly on constructor-promoted properties:
use Kinetis\Validation\Constraints\{Email, MinLength, GreaterThan, Regex};
final readonly class CreateProductRequest
{
public function __construct(
#[Regex('/^[A-Z]{3}\d{3}$/')]
public string $sku,
#[GreaterThan(0)]
public float $price,
) {}
}
Attribute |
Checks |
Constructor |
|---|---|---|
|
|
(no arguments) |
|
not empty or all-whitespace after |
(no arguments) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(no arguments) |
|
matches an RFC 4122 UUID |
(no arguments) |
#[MinLength]/#[MaxLength] and #[GreaterThan]/#[LessThan] compose on
the same field for a length or numeric range — Hydrator runs every
Constraint-implementing attribute on a parameter, not just the first
one:
#[GreaterThan(0)]
#[LessThan(100)]
public int $percentage,
Hydrator::hydrate() checks every constrained field before
constructing the DTO — a request with three invalid fields gets all three
errors back in one response, not just the first one it happened to
encounter:
{
"errors": {
"name": ["must be at least 3 characters."],
"email": ["must be a valid email address."]
}
}
A failed validation short-circuits straight to a 422 — the controller
method is never invoked at all.
An empty body is treated as no data at all, so a DTO with only optional
fields hydrates from its own defaults. A body that isn’t valid JSON, or
that decodes to something other than a JSON object (null, a bare
string, a number, a boolean), is a 400 instead, before any field-level
validation runs:
{
"error": "Request body is not valid JSON."
}
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:
A
string-typed field/parameter must actually be a string. An array, object, number, or boolean is rejected.An
int/float-typed field/parameter accepts a real number or a numeric string ("42"for anintfield is fine) — but rejects a non-numeric string, an array, or a boolean.A
bool-typed field/parameter accepts exactlytrue,false,1,0,"1", or"0"— nothing else.
A mismatch is a 422 with a message under that field’s key, in the same
errors structure a failed constraint produces — not a value silently
coerced into something that happens to look plausible (an array becoming
the literal string "Array", a non-numeric string becoming 0).
Missing and explicitly-null values get the same treatment, whether or not
the field carries any constraint attributes: a #[Body] DTO field whose
key is absent from the request is is required. unless the constructor
parameter has a default, and a field sent as JSON null whose declared
type doesn’t allow null is must not be null. — both under the field’s
key in the same 422, never a raw TypeError from the constructor.
Asymmetric-visibility properties¶
Because both Hydrator and Dispatcher reason about a DTO purely through
constructor-parameter reflection, PHP 8.4’s asymmetric visibility works
with zero special-casing:
use Kinetis\Validation\Constraints\MinLength;
final class UpdateStatusRequest
{
public function __construct(
#[MinLength(3)]
public private(set) string $status,
) {}
}
The property’s visibility declaration is simply irrelevant to how it’s bound and validated — the constructor parameter is what both classes actually inspect.
Writing your own constraint¶
A constraint is any class implementing the one-method Constraint
interface:
use Kinetis\Validation\Constraint;
use Attribute;
#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
final readonly class Uppercase implements Constraint
{
public function validate(mixed $value): ?string
{
if (!is_string($value) || $value !== strtoupper($value)) {
return 'must be all uppercase.';
}
return null;
}
}
Returning null means valid; any non-null string becomes that field’s
error message. Any attribute implementing Constraint on a parameter is
picked up automatically — there is no fixed list of “known” constraints to
register your own class into.
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,
) {}
}
{
"customerName": "John Doe",
"shippingAddress": { "street": "1 Infinite Loop", "city": "Cupertino" }
}
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 error surfaces under a dotted key (shippingAddress.street) rather
than only reporting the outer field name:
{
"errors": {
"shippingAddress.street": ["must be at least 3 characters."]
}
}
This is a data-driven distinction, not a type-driven one: nesting only
happens when the incoming value for that field is actually an array. A
class-typed field holding anything else — most notably an
UploadedFileInterface merged in for a multipart
field — passes through completely unchanged, exactly like it always has.
Note
A self-referencing (or mutually referencing) DTO stops nesting the moment a
class repeats in the chain, rather than recursing forever — not just a
safety net, but a requirement of Caching & AOT Compilation’s AOT compilation, which
bakes a DTO’s hydration plan into a cache file via var_export() and has
no way to represent a genuinely circular array as re-parseable PHP. A
self-referencing field simply receives its raw array unhydrated one level
deep in that case.
Collections of nested DTOs¶
A constructor parameter typed array and carrying
#[ListOf(SomeClass::class)] is hydrated as a list of nested DTOs — each
array-shaped element is hydrated the same way a single nested DTO field is:
use Kinetis\Validation\Constraints\GreaterThan;
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,
) {}
}
{
"customerName": "John Doe",
"items": [
{ "product": "Widget", "quantity": 2 },
{ "product": "Gadget", "quantity": 5 }
]
}
Each element’s own validation errors surface under a dotted
field.index.nestedField key, alongside every other error in the same
response:
{
"errors": {
"items.1.quantity": ["must be greater than 0."]
}
}
A list element that isn’t itself an array — most notably an already-constructed instance — passes through completely unchanged, the same tolerance a single nested DTO field gives a non-array value.
Note
The same self-reference guard described above covers a #[ListOf] pointing
back at its own class: nesting stops the moment the class repeats in the
chain, and that list’s elements receive their raw array unhydrated one
level deep.
Zero-config OpenAPI & Swagger UI¶
Every route Router has registered is reflected a second time — the same
controller-method metadata Dispatcher already reads at request time — to
build a complete OpenAPI 3.1 document, with no annotations beyond the
attributes already shown on this page:
GET /openapi.json— the generated document.GET /openapi— a Swagger UI shell rendering it. It loads Swagger UI from a CDN and sends its ownContent-Security-Policypermitting exactly that, so it keeps working under an application-wide policy that would otherwise block it — see Middleware.
Both 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 any middleware you
attach to them behaves like middleware anywhere else.
#[Body] DTOs become requestBody schemas, with every constraint from the
table above mapped onto the matching JSON Schema keyword (format: email,
minLength/maxLength, exclusiveMinimum/exclusiveMaximum, pattern,
enum, format: uri, format: uuid) — except #[NotBlank], which has no
distinct JSON Schema keyword of its own. #[Query] parameters and path
parameters become parameters entries, with the identical constraint-to-
keyword mapping applied to their own schema when they carry one. A
controller method’s declared return type becomes the default response’s
schema too — 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 #[ListOf] field becomes a {"type": "array", "items": ...} schema, with items describing the element class
the same way any other DTO reference does.
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 happen to share a short name fall back to the fully-qualified name
instead of silently overwriting one schema with the other’s.
A route attribute’s status only describes the default response — it has
no way to know a method might also return a ResponseInterface directly
with a different status. The repeatable #[Response(status, description)]
attribute documents those additional statuses manually — see
UserController::show() above
for a complete example. It’s purely descriptive and has no effect on
runtime behavior: Dispatcher never reads it, only OpenApiGenerator
does. Each one adds one entry to that operation’s responses alongside the
route’s default — nothing checks that the method actually produces the
status it declares, the same trust already placed in the route attribute’s
own default.
Both are served ahead of the routing pipeline — they read Router’s
already-registered routes, not application state, so they need no
RequestScope at all.
Choosing where the documentation is reachable¶
Both paths are off until you name the environments they belong in. Together they describe your whole route table, which is reconnaissance handed over for free rather than a vulnerability by itself, and there is no version of publishing it that you chose:
APP_ENV=development
OPENAPI_ENVIRONMENTS=development,staging
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 deliberately documentation-only service wants:
new Kinetis\Http\Kernel($app, $router, exposeOpenApi: true);
The check runs per request rather than when routes are registered. That
is deliberate: 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.
Clearing the cached document¶
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 and cached in whatever CacheInterface the application has bound,
with no expiry: the route table cannot change without a deployment, and
a document that expired on a timer would spend that window describing an
API the deployment no longer serves.
The consequence is that a deployment which changes routes, DTOs, or constraints has to drop it:
php vendor/bin/kinetis openapi:clear
Run it alongside kinetis build. It is safe when nothing is cached, and
in development, where nothing ever is. With no cache configured — the
default NullSimpleCache — nothing is stored and every request
regenerates, which is correct but slower for a large route table.
Hiding a route from the document¶
#[Hidden] excludes a route from /openapi.json and /openapi — the route
itself keeps working exactly as before, only its documentation is
suppressed. Useful for a route that isn’t really part of the API surface,
like an HTML page served alongside a JSON API:
use Kinetis\Http\Attributes\Get;
use Kinetis\Http\Attributes\Hidden;
use Kinetis\Http\Responses\HtmlResponse;
use Psr\Http\Message\ResponseInterface;
final readonly class DashboardController
{
#[Get('/')]
#[Hidden]
public function index(): ResponseInterface
{
return HtmlResponse::create('<h1>Dashboard</h1>');
}
}
#[Hidden] on a controller class hides every route on it at once, for a
controller that shouldn’t appear in the document at all:
#[Hidden]
final readonly class InternalController
{
#[Get('/internal/status')]
public function status(): array { /* ... */ }
}
See also¶
Container — how a controller’s own constructor dependencies get resolved.
Middleware —
#[Middleware], the same attribute-driven pattern applied to wrapping a route rather than binding its parameters.Caching & AOT Compilation — how route/binding/validation metadata gets precomputed ahead of time in production, and exactly what that does and doesn’t change about the behavior described on this page.
Runtime Adapters — how each runtime gets a request’s multipart body into the uploaded-files bag
#[Body]/UploadedFileInterfaceread from here.