Appendix: System Layout

A reference map of what exists in core, by namespace. For the optional satellite packages (kinetis/auth, kinetis/queue, kinetis/storage, and so on), see Appendix: Satellite Packages.

Kinetis\Container

  • AppScope — the persistent, worker-lifetime container. bind()/instance()/middleware() before boot(); locked after. Only an explicit registration creates a singleton: get() on an unregistered class autowires a fresh instance per call, never cached — the same “never promoted” guarantee RequestScope makes, applied to the parent scope’s own API. boot() registers defaults if not already set: Kinetis\Runtime\AppEnvironment → the detected environment, Psr\Log\LoggerInterface → Kinetis\Logging\ErrorLogLogger in development / NullLogger in production, Kinetis\Config\Config → Config::fromEnvironment(), Http\Form\FormLimits and Http\TrustedProxies → fromConfig() on that Config (an entry point registers its own before the bootstrap chain runs, so a bootstrap can replace either and boot() leaves what it finds), Psr\SimpleCache\CacheInterface → Kinetis\SimpleCache\RedisSimpleCache::fromConfig() (class_exists()-gated against the optional kinetis/cache-redis package) when Redis is configured, else NullSimpleCache — configured but not installed binds UnavailableSimpleCache, which throws on use rather than at boot; a default cache implementing SimpleCache\DisposableCacheInterface has its dispose() registered on onDispose(), and a cache the application bound itself is never registered. Resolving RequestScope::class through AppScope — directly, or as a constructor dependency of anything else AppScope resolves — throws Exception\DisconnectedRequestScopeException instead of autowiring a disconnected, unbooted RequestScope; there is no single worker-lifetime RequestScope to hand back, since a fresh one exists per request. onDispose(callable(): void) registers a callback for the end of this scope’s lifetime — allowed before and after boot(), unlike every other registration, because an app-scoped factory is lazy and a pool that opens on first resolution can only register its own close then; refused once the scope is disposed. dispose() runs every one of them in registration order, releases every binding, instance and registration list whether or not one failed, and only then rethrows the first failure; a second dispose() returns, and binding, resolution, boot(), request-scope creation and further onDispose() registration are all refused afterwards. See Appendix: Container Lifecycle.

  • RequestScope — the per-request container, created by AppScope::createRequestScope(), which also registers the scope onto itself (RequestScope::class resolves to that exact instance). dispose() runs every registered callback (even if an earlier one throws), wipes bindings and marks the scope disposed either way, then rethrows only the first callback failure to its direct caller. Kernel — and every other owner that creates one for a unit of work (kinetis/queue’s QueueWorker/SyncQueue, kinetis/mcp’s ScopedMessageHandler, bin/kinetis) — disposes it outside any finally that could let that rethrown failure silently replace an already-decided outcome; see each owner’s own docs page for its exact precedence rule. Delegates to AppScope for explicitly registered ids only; autowires anything else, discarded on dispose(). appScope() exposes the parent, for a worker-loop command that mints its own per-job scopes.

  • PackageBootstrapInterface — register(AppScope $app, Config $config): void, the one method an installed package’s extra.kinetis bootstrap class implements (see Kinetis\Cache’s DiscoveryContext/RoutesFile::loadBootstrap() below). Runs before the application’s own bootstrap.php, which wins on any shared binding; an implementation should stay inert when its configuration is absent — wiring, not side effects.

  • AppScope::onRequestScopeCreated(callable(RequestScope): void $initializer): void — registers an initializer createRequestScope() runs on every scope it creates, in registration order, before returning it; allowed only before boot() (Exception\ContainerException after). If one throws, the scope is disposed — running what earlier initializers registered on it — and that failure propagates. Kernel, bin/kinetis, kinetis/mcp’s ScopedMessageHandler, and kinetis/queue’s QueueWorker/SyncQueue all take their scopes from createRequestScope(), so every package initializer runs on each; a #[Command(bootstrap: false)] command runs no package bootstrap and gets none. kinetis/database-bridge binds TransactionGuard, and with kinetis/orm installed EntityManager, through one — see Appendix: Container Lifecycle’s “Request-scope initializers”.

  • Autowire — reflection-based constructor injection, used by both scopes. isAvailable(ContainerInterface, string): bool is the internal predicate that separates an absent dependency from a broken one; Http\Dispatcher asks the same one, so a dependency behaves identically in a constructor and in a controller method signature. See Appendix: Container Lifecycle for the rule.

Kinetis\Http

  • Kernel — the runtime-agnostic entry point. handle(ServerRequestInterface): ResponseInterface. Resolves an OpenApi\OpenApiAccess (from exposeOpenApi, else OPENAPI_ENVIRONMENTS against APP_ENV) and builds an OpenApi\OpenApiDocumentProvider for its own Router, registering both — plus that Router — on each request’s scope for Http\OpenApi\DocumentationController — it serves no endpoint of its own: it creates a RequestScope, matches a route, dispatches. That scope is disposed before handle() returns unless the response streams its own body, in which case it is released when that stream is settled — emitted, abandoned by an owner that will never write it, or displaced from the response handle() ends up answering with — see the request lifecycle below, and Appendix: Container Lifecycle. /openapi.json//openapi are ordinary routes on Http\OpenApi\DocumentationController, and /mcp is an ordinary route kinetis/mcp contributes — see Appendix: Satellite Packages.

  • Dispatcher — binds each parameter of a matched route’s controller method, resolves the controller from the container, invokes it. Binding runs first, so a request refused with a 400 or 415, or by validation, never constructs the controller. A binding failure leaves this class as the Validation\Exception\ValidationException it is — route and application middleware get to catch it, and whatever reaches Middleware\ExceptionHandlerMiddleware is rendered there; a 400 and a 415 stay here, since neither carries per-field structure. Six sources, checked in order: a parameter typed ServerRequestInterface/UploadedFileInterface (the raw request/an uploaded file), #[Body] DTO (json_decode() on the raw body for application/json and any application/*+json subtype, getParsedBody() for multipart/form-data/application/x-www-form-urlencoded; a nonblank body under any other media type, or under none, is a 415 raised before hydration as Exception\UnsupportedBodyMediaTypeException and mapped here, never echoing the header received), #[Query] value, same-named path parameter, then any remaining class-typed parameter from the request container (plan source container) — last, so it shadows none of the others. That last source is what lets one controller serve a public route and a middleware-guarded one, since a constructor is shared by every route on its class while a method signature is not; a parameter’s default value, or its nullable type, stands in for an absent dependency under the same rule constructor autowiring uses (see Appendix: Container Lifecycle), while everything else propagates — a registered service that failed to construct and a Container\Exception\CircularDependencyException included. An absent dependency with neither a default nor a nullable type is reported against the parameter, keeping the container’s own account as previous. Anything left over uses its default or throws Exception\UnresolvableParameterException, whose message names every source. #[Body('root')] hydrates the DTO from one top-level member of the decoded, upload-merged document (plan field bodyRoot) through Validation\Hydrator::resolveDtoValue(); derivePlan() rejects an empty root and a second #[Body] parameter — see Appendix: Routing & Validation’s “Reading the DTO from one top-level member”. This class owns the request’s uploaded files as transport: normalizeUploads() runs once per dispatch, dropping every UPLOAD_ERR_NO_FILE leaf (a browser’s empty file control, which binding therefore reads as ordinary omission), dropping a branch it leaves empty — the whole branch, not an empty list — and closing a pruned flat file list’s indices back up with array_values(), so photos[] sent as file, empty, file binds two files at 0 and 1. A list-shaped branch that named a sub-branch at all — whether or not that sub-branch survived pruning — keeps its positions, since there an index is the one the parsed text names too and reindexing would merge a later file into an earlier element; a map keeps its keys. Both file-reading sources read that one tree: an UploadedFileInterface parameter by its own top-level name, and a form-encoded #[Body] DTO through mergeUploads(), which folds files into the parsed text recursively — two arrays merge, anything else leaves the text in place, a file-only key is added — so profile[name] and profile[avatar] hydrate one nested DTO. A JSON body never consumes the files. What each file then is — arrived, failed, or no file at all — is Validation\Hydrator::resolveUploadedFile()’s answer, so a direct parameter’s own Constraint attributes run exactly where a DTO field’s do. The PSR-7 request itself is untouched; normalization is what binding reads. derivePlan() also rejects, at Routing\Router::register() time, a #[Query]/path parameter declaring a union or intersection type — one request value has one shape, and the T|Absent presence union a #[Body] DTO field may declare needs a member that is either present or absent, which a query key has not. A class-typed #[Query]/path parameter is admitted only for a backed enum, whose cases are written as the scalar a query string or a path segment can carry: the plan records it as enumClass with the backing type as scalarType, and resolveScalarFromPlan() binds it through Validation\Hydrator::resolveEnumValue() — the same method a DTO’s enum field takes — so malformed text is the ordinary type violation and text naming no case an enum_case one, never a TypeError at invocation. Every other class there is Exception\UnresolvableParameterException::forUnsupportedClassType() at that same registration boundary. See Appendix: Routing & Validation’s “Enum path and query parameters”.

  • MediaType — of(string): string, isFormEncoded(string): bool, isMultipartFormData(string): bool, isJson(string): bool (exactly application/json, or an application/ subtype carrying RFC 6839’s +json suffix — so text/json and application/+json are neither), isMultipart(string): bool (any multipart/*, which is what a part may never itself declare — see Http\Form\MultipartEnvelope), and the FORM_URLENCODED/MULTIPART_FORM_DATA/JSON constants naming those literals. The one place a Content-Type header value is classified: Dispatcher, Http\Form\FormBody and Testing\TestClient all read it here rather than comparing the header themselves. of() is the single parse behind every predicate — the bare type/subtype, lowercased, with any ; parameter and surrounding whitespace removed. The parameters after that ; are not this class’s to read: Http\Form\MultipartEnvelope holds the whole section to one grammar and takes the boundary from it, because a delimiter two parsers resolve differently is a body with two shapes. See Request bodies for the accepted-spelling contract itself.

  • CurrentUserInterface — one method, id(): string|int. Nothing implements or registers it by default; a middleware registers a concrete implementation on the current RequestScope (see Kinetis\Container above), and any class downstream — a controller, another package — depends on this interface rather than a specific implementation.

  • Auth\AuthorizationToken68Parser — parse(ServerRequestInterface, string $expectedScheme): ?string / parseValue(string, string $expectedScheme): ?string, the one place the RFC 9110/RFC 6750 Authorization: <scheme> <token68> grammar is implemented — used by both kinetis/auth’s BearerAuthMiddleware and kinetis/auth-jwt’s JwtAuthMiddleware with 'Bearer' (see The Authorization header for the exact accepted wire grammar) rather than each maintaining its own copy, and by an API spelling the same credential Token. parse() reads getHeader('Authorization') (never getHeaderLine(), which comma-joins multiple header lines into one ambiguous string) and requires exactly one value before handing it to parseValue(). The wire scheme is compared case-insensitively. Returns null, never throws, for any parse failure — a caller treats null identically to an unknown/invalid token. $expectedScheme is trusted configuration rather than input: it must be an RFC 9110 auth-scheme token, checked before the header is read, and throws InvalidArgumentException otherwise.

  • MiddlewarePipeline / CallableRequestHandler — PSR-15 (Psr\Http\Server\MiddlewareInterface/RequestHandlerInterface) composition. Kernel builds two: a global one (from AppScope::middlewares() plus $discoveredGlobalMiddleware, wraps the whole request) and a per-route one (from a matched Route’s #[Middleware] attributes, wraps just Dispatcher::dispatch()). Attributes\Middleware::expandGroups() replaces each @name reference in a route’s list with that group’s members in place, so a group occupies exactly the position its reference was declared at, and is the one expansion both dispatch and OpenApi\OpenApiGenerator read; assertMiddlewareGroupsExist() validates every reference across every registered route once, in the constructor, throwing Middleware\Exception\UnknownMiddlewareGroupException for an undeclared group rather than failing on whichever request first hits that route.

  • Middleware\ExceptionHandlerMiddleware — always global middleware, inside SecurityHeadersMiddleware and a registered CorsMiddleware. A single catch (Throwable $e) — not a sibling catch (HttpStatusExceptionInterface) {} catch (Throwable) {} pair, since PHP never lets a later sibling catch see an exception thrown from inside an earlier one’s own body. One implementing Exception\HttpStatusExceptionInterface is mapped via tryHttpStatusResponse(), itself wrapped so a broken implementation — httpStatus() throwing, or returning something outside the required 400-599 range — can never escape this method: it falls through to the exact same generic-500 path any other uncaught Throwable takes, logged with the original exception plus a Middleware\Exception\HttpStatusMappingException describing the mapping failure as extra context. A valid HttpStatusExceptionInterface (a declared 4xx or 5xx alike) becomes the status and message it declares — not logged, since it’s a well-formed declared HTTP error a satellite package’s own exception raised from inside a controller, not a framework bug — everything else (including a broken HttpStatusExceptionInterface) becomes a 500, logged via the container’s LoggerInterface through Logging\SafeLogger (see below): a generic body in production, the exception’s class/message/file:line alongside it in development, JSON-encoded with JSON_INVALID_UTF8_SUBSTITUTE so a message that is not valid UTF-8 still encodes rather than throwing from inside this same catch handler. Constructor-injects AppEnvironment (defaulting to Production, so a directly-constructed instance never leaks detail by accident). A Validation\Exception\ValidationException is recognized before that, inside the same single catch, and rendered by the ValidationExceptionRendererInterface the constructor takes — an immutable default instance, so an application binding the interface before Container\AppScope::boot() wins through Container\Autowire with nothing registered either way. tryValidationResponse() wraps the call the same way tryHttpStatusResponse() wraps its own: a renderer that throws falls through to the identical generic-500 path, logged with the original validation failure plus that Throwable under renderFailure. Nothing about a returned response is inspected — status, content type and body are all the renderer’s — and a validation failure rendered normally is not logged, since it reports a client mistake rather than a framework fault. This is the boundary that guarantees a 500 for anything else in the pipeline, so observability cannot defeat it: a consumer-supplied LoggerInterface that itself throws never replaces or escapes the response.

  • ValidationExceptionRendererInterface — one method, render(Validation\Exception\ValidationException $exception, ServerRequestInterface $request): ResponseInterface, and the seam an application replaces to answer a validation failure its own way — a redirect, an HTML page, a domain-specific body, another status. Every status is legal: the boundary imposes no range. Resolved from AppScope and shared by every request, so an implementation takes worker-safe constructor dependencies only and retains neither argument; the request scope is already disposed by the time it runs, so a workflow needing a live request-scoped service (flash errors before a redirect) catches ValidationException in route middleware instead. See Middleware.

  • ProblemDetailsValidationExceptionRenderer — the default implementation, stateless and immutable: RFC 9457 problem details at 422 with Content-Type: application/problem+json, carrying type: about:blank (Kinetis owns no resolvable problem-type URI, and 422 already supplies the semantics), IANA’s registered title: Unprocessable Content, the matching status, a fixed detail, and an ordered errors extension of serialized Validation\Violation objects. Encoded with JSON_INVALID_UTF8_SUBSTITUTE, so a message or parameter carrying invalid bytes degrades rather than defeating the response.

  • Exception\HttpStatusExceptionInterface — one method, httpStatus(): int, which must return 400 through 599 inclusive and must not throw — enforced by ExceptionHandlerMiddleware’s own runtime behavior, not just documented: any violation is treated as a broken implementation, never a 1xx/2xx/3xx response and never an escaping exception. Does not extend Throwable itself — nothing about implementing this interface requires it, only reaching ExceptionHandlerMiddleware by actually being thrown does, and this interface has no say over that; ExceptionHandlerMiddleware expresses the local Throwable&HttpStatusExceptionInterface intersection where it actually needs it instead. The seam a satellite package’s own exception implements to declare its HTTP status without ExceptionHandlerMiddleware ever needing to catch it by name — core can never depend on a satellite’s exception class, so this is what lets one reach a specific status instead of the generic 500 every other uncaught Throwable gets. kinetis/authorization’s AuthorizationException implements it — see Appendix: Satellite Packages.

  • Middleware\Exception\HttpStatusMappingException — a broken HttpStatusExceptionInterface implementation’s own failure, as logged context alongside the original exception, never returned to a client. invalidStatus() for a status outside 400-599; threw() for httpStatus()/response construction itself throwing, chaining the real cause via getPrevious().

  • Middleware\SecurityHeadersMiddleware — always the outermost global middleware, outside ExceptionHandlerMiddleware, so its headers reach the 500 that handler produces. Constructor-injects Config and reads every value once, stripping CR/LF (which would both throw inside withHeader() and be a header injection), so no configured value can make process() fail. Sends X-Content-Type-Options: nosniff always, plus X-Frame-Options (default DENY) and Referrer-Policy (default strict-origin-when-cross-origin), each overridable or disabled with off via SECURITY_FRAME_OPTIONS/SECURITY_REFERRER_POLICY. Content-Security-Policy (SECURITY_CSP), Permissions-Policy (SECURITY_PERMISSIONS_POLICY), HSTS (SECURITY_HSTS_MAX_AGE plus SECURITY_HSTS_INCLUDE_SUBDOMAINS/SECURITY_HSTS_PRELOAD), and the three cross-origin policies — Cross-Origin-Opener-Policy (SECURITY_COOP), Cross-Origin-Resource-Policy (SECURITY_CORP), Cross-Origin-Embedder-Policy (SECURITY_COEP) — are sent only when configured, since a wrong value for any of them breaks a working application: COOP cuts the window.opener link an OAuth popup reports back through, CORP stops other origins embedding responses they embed today, and COEP blocks every cross-origin subresource that has not opted in. HSTS is sent regardless of the request scheme — a browser must ignore it over a non-secure transport, and a scheme check would suppress it behind a TLS-terminating proxy. Each literal {nonce} in SECURITY_CSP becomes one fresh base64 nonce per request, passed downstream as the request attribute NONCE_ATTRIBUTE; see Appendix: Middleware’s “The CSP nonce”. A header already on the response is never replaced, so a single route can set its own.

  • Middleware\RequestBodyMiddleware — always global middleware, right after ExceptionHandlerMiddleware (see GlobalMiddlewareOrder::resolve() below for the exact position), and the single place a request body becomes something a handler can use. Runtime adapters deliver raw bytes and nothing else, so this is where the whole body contract lives, identically under every runtime. Constructor-injects the one Http\Form\FormLimits instance the entry point bound. Rejects a request whose declared Content-Length exceeds the byte ceiling with a 413 before the body is read at all; then stages the body — read once, incrementally, counted, into a seekable temporary stream via Http\Form\StagedRequestBody — rewinds it, and for the two form media types parses it into getParsedBody()/getUploadedFiles() through Http\Form\FormBody under the same instance’s complexity ceilings. Over any ceiling is a 413 naming the limit and the handler never runs; a body that cannot be parsed is a 400 carrying the fixed Runtime\RuntimeAdapterInterface::MALFORMED_BODY_MESSAGE, with the category logged and the parser’s own text discarded. Every ceiling is settled before the handler runs, so the staged stream is complete, seekable and replayable: staging and size enforcement are finished, and no later read re-runs either. read()/getContents() answer from the current cursor, and a plain (string) cast rewinds first, which is what a consumer needing the whole body uses. A raw or binary body reaches the handler untouched apart from being staged.

  • Middleware\GlobalMiddlewareDiscovery::discover(Cache\DiscoveryContext $context, ?array $paths = null): list<class-string> — finds every #[AsGlobalMiddleware]-attributed class anywhere under a project’s own PSR-4 root(s), plus Kinetis\Http itself, sorted by priority (descending, ties broken by class name). $paths, or MIDDLEWARE_DISCOVERY_PATHS when omitted, restricts the project-side scan. discoverAll(Cache\DiscoveryContext $context, ?array $paths = null): array{global: list<class-string>, openApi: list<class-string>, groups: array<string, list<class-string>>} reads one candidate list from the context and buckets by which of #[AsGlobalMiddleware]/#[AsOpenApiMiddleware]/#[AsMiddlewareGroup] a class carries, each sorted independently. The first two are flat lists (one pipeline each); groups is a map of group name to that group’s own priority-sorted members, since #[AsMiddlewareGroup] is repeatable and a project can declare any number of independent groups. It always contains GlobalMiddlewareDiscovery::OPENAPI_GROUP (openapi), holding the openApi bucket, empty included: DocumentationController references it unconditionally and a route naming a missing group is a startup error — discover() is a thin wrapper returning just ['global'], kept for any caller that only ever wanted that one list.

  • Middleware\GlobalMiddlewareOrder::resolve(array $explicit, array $discovered): list<class-string> — computes the global-middleware order: SecurityHeadersMiddleware first, then CorsMiddleware once when $explicit names it, then ExceptionHandlerMiddleware, RequestBodyMiddleware, and merge($explicit, $discovered) without CorsMiddleware. merge(array $explicit, array $discovered): list<class-string> is the plain explicit-then-discovered precedence rule with no fixed prepended classes, factored out so Kernel’s openapi group folding can reuse the identical rule without inheriting SecurityHeadersMiddleware/ExceptionHandlerMiddleware/RequestBodyMiddleware, which neither needs. Kernel and Console\RoutesListCommand each map either method’s result through their own container afterward — both return plain class-strings.

  • Middleware\RateLimitMiddleware — opt-in, global or route. Fixed-window counter keyed by client IP, raised through the cache’s own SimpleCache\AtomicCounterInterface, which construction requires it to implement — NullSimpleCache and any other non-atomic cache both throw Middleware\Exception\RateLimitUnavailableException, since a cache that can only count by reading then writing back lets the limit be exceeded by every request that arrives concurrently. Construction also takes a non-empty policyId, and that ID alone is the policy’s identity: it is sha256-hashed once into the prefix of every cache and dedup key the policy owns, so two instances sharing an ID share one budget and two policies that must not are given different IDs. Neither the class nor the limits take part, so reconfiguring a policy leaves its running counters where they are. The subject is hashed the same way — PSR-16 forbids {}()/\@: in a key, and an IPv6 address is full of colons. The count is raised before the decision, so a rejected request counts too — harmless, since the key belongs to one window and the next uses another. identifierFor() only consults X-Forwarded-For when REMOTE_ADDR matches one of the constructor’s trustedProxies CIDRs (empty by default — REMOTE_ADDR always used otherwise), walking the chain from the end backward past any further trusted hops. Construction rejects a blank policyId, a maxAttempts or windowSeconds below 1, and any trustedProxies entry that is not an address or CIDR range with a prefix length in its family’s range, via Middleware\Exception\InvalidRateLimitConfigException — a zero window divides by zero, a negative one expires the counter on write so no limit is enforced, and an unparseable range decides who may set X-Forwarded-For. Not final: since #[Middleware] carries only a class-string, the policy an application registers is a thin subclass supplying its own ID and limits. identifierFor() is protected, not private, specifically so a subclass’s override actually takes effect. process() records its decision (attempt count and window) as a PSR-7 request attribute keyed by policy and subject, excluding the window itself, so the same policy checking the same subject later in the same request’s pipeline (typically registered both globally and, redundantly, on the matched route) reuses that decision — including for Retry-After — instead of incrementing twice or resolving a different window if a slow intervening middleware crossed a real window boundary in between; X-RateLimit-Limit/X-RateLimit-Remaining are likewise only ever set on a response that doesn’t already carry them, so an outer policy within its own budget never overwrites a more specific inner policy’s real numbers. An optional last constructor parameter, ?\Closure $clock, substitutes for time() — for deterministic tests only, never a real caller.

  • Middleware\AuthenticatedRateLimitMiddleware — extends RateLimitMiddleware, taking the same policyId and overriding identifierFor() to key by CurrentUserInterface::id() when one is already resolved on the current RequestScope, falling back to IP otherwise. Route middleware only, registered after the auth middleware that resolves CurrentUserInterface — never global, and never bound directly on AppScope (the same Container\Exception\DisconnectedRequestScopeException hazard JwtAuthMiddleware documents in JWT Authentication).

  • Middleware\CorsMiddleware — opt-in, global only (a route-level registration would never see a preflight to an unmatched route). GlobalMiddlewareOrder runs a registered instance once, directly inside SecurityHeadersMiddleware and outside ExceptionHandlerMiddleware and RequestBodyMiddleware, so framework-built error and body-limit responses carry its headers; the constructor validates allowedMethods/allowedHeaders/exposedHeaders as header values (InvalidArgumentException) so process() cannot throw outside the exception boundary. allowedOrigins/allowedMethods/allowedHeaders/exposedHeaders/allowCredentials/maxAge/allowedOriginPatterns constructor config; allowedHeaders: ['*'] reflects the preflight’s requested headers, allowedOriginPatterns matches origins by PCRE pattern, compiled at construction (an uncompilable one throws InvalidArgumentException) and required to match the whole Origin — a partial match is refused, so an unanchored pattern cannot widen what is allowed. Inspecting the pattern for ^/$ instead would not be sound: #^https://good\.com$|evil\.com$# carries both and is unanchored on its second branch. Echoes the specific origin (never a literal *) whenever credentials are allowed, per spec. originVaries() reports whether the middleware is configured to allow anything at all (allowedOrigins/allowedOriginPatterns non-empty) — true even for a literal allowedOrigins: ['*'], whose header value never changes but whose presence still depends on whether Origin was sent, which is what Vary: Origin marks on both the allowed and the disallowed/absent branch. isPreflight()-true responses additionally carry Vary: Access-Control-Request-Method (partitioning a preflight from the ordinary OPTIONS response routing itself may produce at the same URI), plus Vary: Access-Control-Request-Headers exactly when allowedHeaders === ['*'] reflects the requested headers back. withVary() is the one canonical merge every branch calls through: existing tokens (across comma-separated values and repeated header lines) are parsed, case-insensitively deduplicated against each other and against the tokens being added, and folded into one header line — an existing Vary: * is left untouched rather than appended to. See Appendix: Middleware’s “Response caching and Vary” section for the full per-branch breakdown.

  • Routing\Router / Routing\Route — #[Get]/#[Post]/#[Put]/#[Patch]/#[Delete] discovery, path-template compilation, toArray()/fromArray() for the AOT cache. register() goes through Kinetis\Reflection\AttributeScope first: the controller must be a concrete class, and each routed method must be declared by it (a trait method counts, an inherited one does not). register(string $controllerClass, list<string> $globalMiddleware = [])’s second parameter is the project’s own already-sorted #[AsGlobalMiddleware] list, passed straight through from RouteDiscovery::discover()/Kinetis\Cache\Compiler::compileProject() — a route’s stored pathTemplate composes outer to inner from every #[RoutePrefix]-carrying source at once (Attributes\RoutePrefix::declaredOn()/joinAll(), see below): the global-middleware chain, then the route’s own #[Middleware(...)] chain (class-level before method-level), then the controller’s own class-level #[RoutePrefix], then the route’s own declared path — so the stored path is always the finished one and nothing downstream needs to know a prefix was involved, or how many sources contributed to it. register() rejects a path or any prefix source that doesn’t start with / (Routing\Exception\InvalidRoutePathException) — a route path is absolute, and the empty string would resolve to / and claim the root route. Route then normalizes every path it is given to one canonical form — leading slash, no trailing one, / unchanged — in its own constructor, so it holds for fromArray() too; /users and /users/ are therefore the same route and declaring both is a duplicate. matchPath() normalizes the request path through the same rule, so /users/ reaches a route registered as /users. register() also rejects a route claiming exactly the same requests as an already-registered one (Routing\Exception\DuplicateRouteException), compared via Route::conflictKey() — method plus path shape with placeholder names and route constraints normalized away, so /items/{id} constrained to \d+ and /items/{slug} constrained to [a-z]+ collide. Overlapping-but-distinct routes stay legal; fromArray() re-checks the identical conflict rule rather than trusting the artifact’s own content, unlike most of its other validation. Route’s own constructor rejects three further path mistakes at registration time, before any of them could surface only as a confusing 404 or 500 on the route’s first real request: the same placeholder name declared twice in one path ({id} appearing more than once), a {...} expression that isn’t a plain placeholder name — {id:\d+}, {not a name}, or an unclosed {id — and two placeholders written directly against each other ({first}{second}), which have no boundary between them and would split a segment at an arbitrary point (Routing\Exception\InvalidRoutePathException for all three). There is no inline constraint syntax; a route narrows a placeholder through its where map instead (below). This reaches fromArray() too, since it runs through the identical constructor.

    Route constraints: Route::__construct() takes a final array $where = [] (placeholder name => delimiterless PCRE2 fragment) and exposes the validated map as public readonly array<string,string> $where, assigned after reordering into placeholder order — never the caller’s map or order. Each key must be a real string naming a placeholder in the finished, prefix-composed template (so a route may constrain a placeholder a #[RoutePrefix] contributes); each value must be a non-empty string with no literal control byte (escaped text such as \n is ordinary regex syntax). Failures are InvalidRoutePathException::invalidConstraint() (a non-string key, or a value that is not a non-empty control-free string), unknownConstraintPlaceholder(), uncontainedConstraint(), acceptingConstraint() and uncompilableConstraint(). Each fragment is proven self-contained by PCRE itself, probed against the empty subject: uncontainedConstraint() when it fails compiled bare (an unmatched ), an unclosed group, class or comment) or compiled as (?:FRAGMENT) followed by an empty named group for every placeholder (an unterminated \Q or # comment swallowing that ), or a (?J) group reusing a placeholder’s name); acceptingConstraint() when it contains (*ACCEPT and stops compiling once every occurrence becomes the unknown verb (*XACCEPT, which leaves literal occurrences valid. uncompilableConstraint() comes from one construction-time probe of the whole route, @preg_match($pattern, '') === false, carrying the template, every name: fragment and preg_last_error_msg(); it catches fragments that compile alone but not together. An unconstrained placeholder compiles to (?P<name>[^/]+), a constrained one to (?P<name>(?:FRAGMENT)) with the fragment embedded verbatim; the whole route is \A...\z (a final newline is not admitted) between byte 0x01 delimiters, which neither a template nor a fragment can contain. matchPath() returns the captures on 1 and null on 0; false, any PCRE error rather than a result, throws Routing\Exception\RouteMatchingException naming the template and preg_last_error_msg() but not the request path, which Router::match() propagates rather than reporting a miss, and Kernel answers as a 500. PHP’s pcre.backtrack_limit, pcre.recursion_limit and JIT stack are the only bounds. A captured value may contain / — .* captures a/b — but is still one string, which is why an array-typed path parameter stays unsatisfiable (Http\Exception\UnresolvableParameterException::forImpossiblePathArray()). The request path is normalized first, so /files/ never gives /files/{path} an empty tail, and the capture is raw routing text that may hold // or ... Constraints are admission only: a mismatch is a route miss (404, or 405 when another method’s route admits the path), while a binding or validation failure after the match stays a 422. See Appendix: Routing & Validation’s “Route constraints”.

    register()’s own reflection loop walks every RouteAttribute-implementing attribute a method carries, not just the first — #[Get('/x')] and #[Post('/x')] on one method register two independent routes sharing that method and its middleware. Registration is atomic per controller: every candidate route across every method is reflected, instantiated, and conflict-checked (against both already-committed routes and every other candidate the same call has staged so far, so two methods on one controller can conflict with each other too) into local state before anything is committed to the router — a later method’s bad definition, or any conflict, leaves zero of that controller’s routes installed, not just the ones reflected before the failure. A failed registration attempt is never marked as registered, so retrying the same still-invalid class fails again, every time.

    A controllerClass already registered is a safe no-op on a later call only when its $globalMiddleware context matches — tracked via Router::$registrationContexts (array<class-string, string|null>), keyed by a canonical, order-preserving signature of the middleware list (implode("\0", $globalMiddleware)). This is the invariant RouteDiscovery relies on instead of keeping its own external dedup set, since a class can legitimately surface from more than one scan pass (the project’s own scan overlapping a package’s extra.kinetis root, or the framework root and project root being the same repository when developing Kinetis itself) — every such pass shares one project-wide $globalMiddleware list, so their signatures always agree. A repeat call carrying a different context throws Exception\ConflictingRegistrationContextException instead of silently keeping the routes built under the first one: Router is public API, and a second call with a different context is a real mistake, not a harmless rescan. fromArray() records null for every class it reconstructs — a compiled route’s own data carries no memory of the $globalMiddleware list its controller was registered under, so there is nothing to compare a later live register() call against, and null can never equal a live call’s own signature (always a real string, even for an empty list) — a class loaded from a compiled artifact therefore always throws on a later live registration, rather than risk trusting an unknowable match.

    Route’s constructor also validates every one of its own scalar fields — httpMethod (one or more of RFC 9110’s own tchar set — a digit, an uppercase ASCII letter, or one of !#$%&'*+-.^_\|~— restricted to uppercase letters only, the deliberate normalization rule this class has always enforced; a plainA-Zcheck alone would wrongly reject real extension/WebDAV method tokens likeM-SEARCHorVERSION-CONTROL), status(100–599),controllerClass/controllerMethod(class-string/identifier shapes), eachmiddlewareentry (a class-string or an@namegroup reference), andpathTemplate(no control characters, including a NUL byte) — viaRoute::assertValidDefinition(), throwing Routing\Exception\InvalidRouteDefinitionException(orInvalidRoutePathException::forControlCharacters()for the path check). None of this is reachable from a genuine route attribute in source code, since every realRouteAttributeimplementation already returns a normalized method token and a real int status — it exists for data replayed from a compiled cache artifact, which carries no such guarantee, and this constructor is the one place bothregister()andfromArray()` funnel through.

    Router::match()’s first-match-wins scan orders $routes by Route::compareForMatching() — a stable, purely content-based comparator, re-applied after every commit in both register() and fromArray(), so live discovery and a compiled-cache round trip always produce the identical match order for the identical set of routes regardless of registration, reflection, or scan order. It compares real /-delimited URL path segments position by position, each segment ranked into one of three tiers via Route::urlSegmentSpecificity(): fully literal (no placeholder token in the segment at all) beats a mixed segment (at least one literal token alongside at least one placeholder token, e.g. report-{id}.pdf), which beats a pure placeholder segment (no literal token at all). Once every shared segment ties, more segments wins (a deeper, more concrete path); a route that still ties on every segment falls back to a fully content-based tiebreak (httpMethod, then pathTemplate, then controllerClass/controllerMethod), so two distinct routes never compare equal. Constraints take no part: a constrained placeholder, /-spanning or not, ranks exactly like an unconstrained one, so /files/readme and /files/{id}/meta both outrank a .* catch-all /files/{path}. Segments themselves come from Route::urlSegmentGroups(), which regroups Route::parse()’s own token list back into real URL segments: only a / found inside a literal token’s own text is ever a segment boundary, and a placeholder token is always appended whole to whichever segment it belongs to.

  • Routing\RouteDiscovery — builds a Router from every class found anywhere under a project’s own PSR-4 root(s), plus Kinetis\Http itself, mirroring Kinetis\Console\CommandDiscovery/Kinetis\Mcp\McpDiscovery. discover(Cache\DiscoveryContext $context, ?array $paths = null, array $globalMiddleware = []) — $paths, or the ROUTE_DISCOVERY_PATHS env var when omitted, restricts the project-side scan to one or more sub-paths relative to each PSR-4 base directory.

  • Attributes\{Get,Post,Put,Patch,Delete,Body,Query,Middleware,Response,Hidden,RoutePrefix,PaginatedItem,OpenApiSecurity} — the route/binding/middleware/OpenAPI-documentation attributes. Each verb attribute takes (string $path, int $status = 200, array<string,string> $where = []) and implements Attributes\RouteAttribute — httpMethod(), path(), status(), and where(): array<string,string>, returning the constraint map as declared; Routing\Route validates and reorders it. Hidden (class- or method-level) excludes a route from the generated OpenAPI document without affecting routing/dispatch; the class-level form is read from the controller the route is registered on. RoutePrefix(string $prefix) (TARGET_CLASS) prepends a path segment to every route on the controller, resolved at registration; it must start with / like any declared path, join() leaves stray trailing slashes to Route’s own normalization, and a route declaring / sits at the prefix itself. It is what makes a trait of shared route methods mountable at a different path per controller — see Routing & Validation. Also readable from a middleware class, not just a controller: declaredOn(list<string> $classes): array<class-string, self> reads #[RoutePrefix] off every class in the given list that carries one (a @name group reference is silently skipped — class_exists() already returns false for one, needing no special case), and joinAll(string $path, self ...$prefixesOuterToInner): string folds them around a path, the first prefix contributing the leftmost segment. Neither method validates rootedness itself — that stays in Router::rootedPrefixes(), which is what lets declaredOn() remain a pure attribute reader with no dependency on Http\Routing’s own exception type. PaginatedItem(class-string $itemClass) (TARGET_METHOD) names the item class held in the data list of whatever response wrapper the route returns, purely for OpenApiGenerator::paginatedResponseSchema() — see below. OpenApiSecurity(class-string<OpenApi\SecurityDescriberInterface> ...$providers) (TARGET_CLASS|TARGET_METHOD) states one operation’s published security instead of the one its middleware would describe: providers are combined as AND, a method declaration replaces a class one, and no provider at all publishes security: []. Like every other attribute here it is read from the controller the route was registered on, never from a parent. It changes the published document only — the middleware runs unchanged, so a no-argument declaration describes a route whose pipeline already admits an unauthenticated request rather than making a guarded one reachable — see Routing & Validation. Attributes\AsGlobalMiddleware/AsOpenApiMiddleware (each {priority: int = 50}, bounded 0-100, throwing InvalidArgumentException outside that range) are the opposite direction from Middleware — they live on the middleware class itself, not on a controller referencing one — and are what Middleware\GlobalMiddlewareDiscovery looks for. Attributes\AsMiddlewareGroup ({name: string, priority: int = 50}, IS_REPEATABLE, same bounds plus a non-empty-name check) declares group membership on the same side; a route references the group via #[Middleware('@name')], Middleware::GROUP_PREFIX being that @. Group membership alone never makes a class run — only a route referencing the group does. AsOpenApiMiddleware reaches /openapi.json+/openapi through the group mechanism — those are ordinary routes on Http\OpenApi\DocumentationController, so its classes are published as the built-in openapi middleware group that controller references — see Middleware. AppScope::openApiMiddleware() is its explicit-registration counterpart, mirroring AppScope::middleware(). /mcp is covered by an ordinary mcp group kinetis/mcp’s controller references — join it with #[AsMiddlewareGroup('mcp')], and register a CurrentUserInterface, which that group’s own final guard requires before anything is dispatched (see Model Context Protocol (MCP)). POST /broadcasting/auth is covered the same way by a broadcasting group kinetis/broadcasting’s controller references; a CurrentUserInterface registered there is what a channel authorizer declaring that parameter receives, and nothing on that endpoint requires one (see Broadcasting).

  • Responses\HtmlResponse / PlainTextResponse / JsonResponse / FileResponse / RedirectResponse / ErrorResponse — static factories over Nyholm\Psr7\Response, not distinct ResponseInterface implementations (unlike StreamedResponse); each builds a plain response with the right headers/body already set. JsonResponse::create(mixed $data, int $status = 200, array $headers = []) uses JSON_THROW_ON_ERROR, fixes Content-Type: application/json case-insensitively after adding caller headers, and lets encoding failures propagate before a response exists. FileResponse::fromContents() takes the bytes and their content type; no builder reads a filesystem path, so a response never performs I/O of its own. $downloadFilename is treated as untrusted: written as an RFC 6266 quoted-string with \ and " escaped, so a name cannot close the quoting and append a second filename parameter; a non-ASCII name also travels as RFC 8187 filename*=UTF-8''… beside an underscore-substituted ASCII fallback; a control character or an empty string throws Exception\FileResponseException. Path separators are left to the recipient to strip, per RFC 6266. Kernel::error()/Middleware\ExceptionHandlerMiddleware’s own 404/405/500 responses are built through ErrorResponse::create() too, the same helper a controller uses. ErrorResponse::create(int $status, string $message, array $headers = [])’s $message is not necessarily framework-controlled — a satellite package’s own HttpStatusExceptionInterface message, say — so its JSON encoding carries JSON_INVALID_UTF8_SUBSTITUTE alongside JSON_THROW_ON_ERROR: malformed UTF-8 in $message degrades to a substitute character rather than an uncaught JsonException, and the given $status is unaffected either way.

Kinetis\Http\Form

The request-body contract every runtime adapter answers to — see “Request bodies: one contract under every runtime” in Runtime Adapters.

  • FormLimits — six structural ceilings as constants (MAX_INPUT_VARS 512, MAX_FILE_PARTS 16, MAX_NESTING_DEPTH 8, MAX_MULTIPART_PARTS 512, MAX_PART_HEADERS 16, MAX_PART_HEADER_BYTES 8192) plus one per-application byte ceiling. A final readonly value object, not a static holder: fromConfig(#[SensitiveParameter] Config) reads MAX_BODY_SIZE (default DEFAULT_MAX_BODY_BYTES, 2 MiB) once and the constructor rejects anything below 1, so there is no live getenv() read anywhere and one instance is built at the entry point and registered on AppScope, where Middleware\RequestBodyMiddleware autowires it. The counts sit below PHP’s own max_input_vars/max_file_uploads defaults, so the contract is the edge a client meets. assertBodyWithinLimit(int $actualBytes, ?int $declaredBytes) checks both sizes; assertRawPairCount(), assertMultipartPartCount(), assertPartHeaderCount() and assertPartHeaderLength() are the pre-parse counts taken from raw bytes; assertNamesParseable(list<string> $names) is the preflight run immediately before every FormPairs::parse() call in this namespace — it holds the raw names to MAX_INPUT_VARS and, through FormFieldName::depth(), to MAX_NESTING_DEPTH, and then to this runtime’s own max_input_vars/max_input_nesting_level when either sits below the contract, throwing sapiMayHaveTruncated() and naming the setting. That order matters because parse_str() reports neither refusal: a list past max_input_vars comes back shorter, and a name nested past max_input_nesting_level is dropped in silence. assertFormWithinLimits() walks the two built PSR-7 structures once for leaf counts and nesting depth, as defense in depth behind them. Everything throws Exception\FormLimitExceededException (or Http\Middleware\Exception\BodyTooLargeException for bytes), answered with a 413 whose message names a configured ceiling and nothing from the request.

  • MultipartEnvelope::parts() — one bounded scan of a raw multipart body, and the multipart/form-data contract every runtime holds a body to. It counts what a parsed result cannot show: every part, including an unnamed one that builds neither a field nor a file, and header lines rather than distinct names, since a part repeating one header has one map entry and as many lines as it sent. Beyond the counts it settles what the body means, because parsers disagree: the root Content-Type’s parameter section is read whole, under the same grammar a part’s own headers meet, and has to name boundary exactly once (boundary=A; boundary=B and boundary="A"junk are ambiguousMultipartBoundary(), a header naming none at all noMultipartBoundary()); a delimiter is CRLF--boundary followed by CRLF, or by -- and then CRLF or the end of the body, and nothing else is one (a line whose boundary token is only a prefix, or that carries transport padding, is payload kept byte for byte; a line a \n-splitting parser would take as a delimiter while this one does not is ambiguousDelimiter()); a part’s bytes are the wire’s, so Content-Transfer-Encoding may only be 7bit or binary; its metadata is the wire’s text, so no RFC 2047 encoded word, no RFC 5987 extended parameter, no escape/space/semicolon inside a quoted value, each parameter named once and in lowercase; a part may not carry a nested multipart/* body; and its header lines must be complete lines — no obs-fold, no nameless line, no control character, and at most one each of Content-Disposition, Content-Type and Content-Transfer-Encoding. Refuses at the first part or line past a ceiling, and answers anything outside the contract — that, plus a delimiter appearing nowhere, headers that never end, a last part never closed — with Exception\UnparseableFormBodyException.

  • MultipartPart — one scanned part: its header lines in arrival order, the Content-Disposition name/filename, its Content-Type, and its raw body. name is null for an unnamed part; filename distinguishes absent (null, a field) from empty ('', a file control the user left alone).

  • Exception\UnparseableFormBodyException — noMultipartBoundary(), noParts(), unreadableMultipart(), undecodablePart(), nestedMultipart(), ambiguousDelimiter(), each a fixed sentence with a matching category slug, and never a previous chain. A parser’s own message is assembled from the input that failed, so it quotes header names, part names, charset labels and body fragments; adapters log the category and nothing else. Answered with a 400 carrying Runtime\RuntimeAdapterInterface::MALFORMED_BODY_MESSAGE.

  • Exception\FormStagingException — couldNotOpenTempStream(), bodyReadStalled(), bodyWriteFailed(), couldNotCloseTempStream(): this worker failing rather than the client, so never a 400/413; inside the Kernel it reaches ExceptionHandlerMiddleware, and in an adapter its own worker-level failure path.

  • StagedRequestBody::stage(StreamInterface $body, FormLimits $limits, ?int $declaredBytes, ?Closure $openStream = null) — reads a request body once, incrementally, into a rewound seekable php://memory stream, stopping one chunk past the ceiling rather than reading an endless body to its end. Every write is looped to completion; a zero or failed write, and a read that yields nothing while the stream still claims more, are errors rather than a shorter body. The temporary handle is closed on every failure path and the primary failure always wins over a close that also failed. $openStream is the seam that lets tests drive a stream that short-writes, refuses to write, or fails to close — none of which php://memory can be made to do.

  • MultipartFormBuilder — addField()/addFile()/build(), constructed with the FormLimits its result is held to. Builds getParsedBody() and getUploadedFiles() through FormPairs — parse_str() itself — so nesting (user[address][city]), appending (tags[]) and replacement (a repeated plain name) follow PHP’s own rules rather than a restatement of them; files are registered by position and each position swapped for its UploadedFile, so one rule shapes both structures. An empty file control — an empty part with an empty filename — is reported the way PHP’s $_FILES reports it: present, UPLOAD_ERR_NO_FILE, no name, no type, size 0, so upload validation written against PHP behaves identically under every adapter.

  • FormFieldName::encode(string $name): string / ::depth(string $name): int — encode() turns a part’s client-controlled name into a form parse_str() can register: each bracket segment percent-encoded so a name containing &, = or a newline stays one name, the brackets themselves left literal because they are the nesting. A name whose brackets don’t close becomes one flat, fully-encoded key rather than inventing structure the client never wrote — and depth() reads the same structure the encoding writes (email 1, tags[] 2, user[address][city] 3), so the depth a name is refused for and the depth it would have built are one number.

  • FormPairs::parse(string $pairs): array / ::SEPARATOR — the one place a form body reaches parse_str(), and the & contract it reaches it under. parse_str() splits on arg_separator.input, not on &, while everything around it here counts, splits and joins on &: a runtime naming ; reads a whole body as one field, and one naming the set &; lets a=1;b=2;… past a count that saw one pair and then truncates it at that runtime’s own max_input_vars. So anything but exactly & is Exception\FormParserConfigurationException before a body is parsed at all. The setting is PHP_INI_PERDIR, so the value read immediately before the parse is the one the parse runs under.

  • Exception\FormParserConfigurationException — unownedInputSeparator(): this PHP would read a form differently from the way it was counted. A deployment problem, so it takes the same route Exception\FormStagingException does rather than becoming a 400/413.

  • UrlEncodedForm::parse(string $body, FormLimits $limits): array — counts separators and reads every name out of the raw body before the parse runs, which is the only point the real numbers exist (a=1 a thousand times is a thousand pairs and one leaf; a name nested too deep is one pair and nothing at all), then applies the contract’s own ceilings to the result. Names are decoded the way parse_str() decodes them, so a%5Bb%5D is measured as the two levels it builds.

  • FormBody::apply(ServerRequestInterface $request, FormLimits $limits) — the one entry point that turns a staged body into getParsedBody()/getUploadedFiles(), called by Http\Middleware\RequestBodyMiddleware and by nothing else: a request whose content type is neither form media type is returned untouched, url-encoded bodies go through UrlEncodedForm, and multipart bodies through MultipartEnvelope’s parts, built by MultipartFormBuilder. Reads the body once and rewinds it, so a handler still sees the complete bytes.

Kinetis\Http\TrustedProxies

  • TrustedProxies — which peers may speak for a client, and the one policy every runtime consults before a forwarded header decides anything. fromConfig(#[SensitiveParameter] Config) reads TRUSTED_PROXIES (comma-separated addresses and CIDR ranges; absent or blank means an empty policy that trusts nobody), fromList(list<string>) takes them directly, and either refuses an entry that is not an address or range with Exception\InvalidTrustedProxyException — a range that silently never matched looks exactly like a correct one that is never reached. trusts(?string $ip) and the static matches(string $ip, string $range) do inet_pton()-based binary comparison, so IPv4 and IPv6 are handled uniformly; Middleware\RateLimitMiddleware validates its own $trustedProxies through unusableReason() — reporting under its own configuration exception — and then builds one of these from them, so the framework has one CIDR grammar and one chain-walking rule rather than a copy per consumer. forwardedScheme(?string $remoteAddr, string $forwardedProto) answers null — meaning “keep the scheme this environment serves”, never “assume http” — when there is no policy, the peer is untrusted, or the header is absent, and throws Exception\UntrustedForwardedHeaderException when a trusted peer sends anything but exactly http or https, folded or repeated values included: there is no rule that picks the right answer out of two. clientAddress(?string $remoteAddr, string $forwardedFor) walks an X-Forwarded-For chain back from its nearest hop past every trusted entry, falling back to the chain’s oldest entry when every hop is trusted and to the peer itself when there is no policy, no chain, or no trust — it is what Middleware\RateLimitMiddleware keys a bucket on. Neither method rewrites REMOTE_ADDR: the transport peer stays what actually connected.

Kinetis\Events

  • EventDispatcher — implements Psr\EventDispatcher\EventDispatcherInterface. Never explicitly registered; autowired fresh per request through RequestScope, constructor-injecting RequestScope, EventListenerRegistry, and ListenerInvokerInterface. dispatch() stops at a listener once Psr\EventDispatcher\StoppableEventInterface::isPropagationStopped() returns true. Checks the registry’s own queued flag for each matched listener before resolving anything: a non-queued listener resolves through RequestScope and is called directly; a queued one is never constructed here at all — its class-string, method, and the event go straight to ListenerInvokerInterface::invoke(), so only the invoker (not EventDispatcher) ever decides whether/when to construct it.

  • Listener — a TARGET_METHOD attribute, {priority: int = 50} (bounded 0-100, throwing InvalidArgumentException outside that range); the event class is inferred from the method’s own single parameter type.

  • EventListenerRegistry — reflects every public #[Listener] method on a registered class, the same shape as Router/McpRegistry. Exact event-class matching only. Each event’s own list is re-sorted (priority descending, ties broken alphabetically by class then method name) on every register() call that adds to it. listenersFor(class-string): list<array{class, method, priority, queued}> — queued computed once via is_a($class, ShouldQueue::class, true) at registration time, never re-derived from a live instance. register() is idempotent per class-string, tracked internally: registering the same class again is a safe no-op rather than a duplicate — the invariant every discovery source relies on instead of keeping its own deduplication set — and atomic: every attributed method on a class is validated before any is appended or the class is marked registered, so a class with one invalid #[Listener] method registers none of its methods, and a repeated register() call for that same still-invalid class throws again rather than silently returning. toArray()/fromArray() for the AOT cache — fromArray() fully validates the given shape rather than trusting it: every event key must be a real string shaped like a class-string, every event’s own value must be a dense list, every entry must have exactly the four required fields correctly typed with class/method further shaped like a valid class-string/identifier, and no two entries for the same event may name the same {class, method} pair — identical or conflicting, since a compiled artifact is either this shape or not read at all. A violation of any of these throws Exception\InvalidListenerException; nothing in the production cache-loading pipeline currently catches it, so a malformed compiled artifact is a hard failure at boot, not a silent fallback to live recompilation. Unlike Router/McpRegistry/CommandRegistry, which every transport hands directly to whatever dispatches them, EventDispatcher constructor-injects this and is itself autowired through the container — so an instance has to be $app->instance()’d explicitly before AppScope::boot() locks bindings. Every framework-managed entry point does this via BootSequence::run() (below), which is what actually decides when — see that entry for the precedence this depends on.

  • EventListenerDiscovery::discover(Cache\DiscoveryContext $context, ?array $paths = null): EventListenerRegistry — builds a registry from every class found anywhere under a project’s own PSR-4 root(s), plus Kinetis\Events itself, rather than an explicit bootstrap.php registration. $paths, or LISTENER_DISCOVERY_PATHS when omitted, restricts the project-side scan. A class surfacing from more than one scan pass (the project root and the framework root being the same repository, for instance) is registered only once, relying on EventListenerRegistry::register()’s own idempotency rather than a separate deduplication set here.

  • ShouldQueue — a marker interface a listener implements to be invoked through ListenerInvokerInterface instead of directly.

  • ListenerInvokerInterface / SynchronousListenerInvoker — the seam a ShouldQueue listener’s invocation is routed through, taking the listener by class-string plus the dispatching RequestScope, never a resolved instance — construction is entirely the invoker’s own decision. SynchronousListenerInvoker resolves it from the given scope and calls it inline; AppScope::boot() registers this as the default, only where nothing is bound yet. kinetis/queue’s QueuedListenerInvoker (see Appendix: Satellite Packages) implements this to actually defer invocation, without ever resolving the listener itself, and that package’s own bootstrap binds it whenever the queue connection it binds has its selector set (QUEUE_CONNECTION for the default connection) — package bootstraps run before boot(), so the synchronous default applies exactly when no queue is configured.

Kinetis\Runtime

  • HttpStartup::run(string $entryPointDir) — the whole HTTP startup program, and everything a public/index.php contains beyond the Composer autoloader. Resolves the project root from the calling directory via ProjectRoot, loads .env before AppEnvironment::detect(), builds Config and binds it, then either discovers routes, middleware, listeners, plugin sections and package bootstraps live through one Cache\DiscoveryContext (development) or resolves them through Cache\BootSequence::resolveHttp() (production — the published artifact, or one fresh compile published as the artifact kinetis build produces). Registers Http\Form\FormLimits and Http\TrustedProxies before the package/application bootstrap chain, so a bootstrap can replace either; runs BootSequence::run() and AppScope::boot(); reports the bootstrap.env/bootstrap.discovery/bootstrap.services phases to whatever telemetry backend that chain installed; reads the settled TrustedProxies back out of the booted container for RuntimeDetector::detect(); and constructs the Kernel with the adapter’s own isPersistent() and every discovered or cached middleware list. All of it sits outside the adapter’s request loop: it executes once per FrankenPHP worker thread, and once for each request under a boot-per-request SAPI, which re-enters the script every time. assemble(string $projectRoot, ?callable $detectAdapter = null): self is the same program stopping short of the adapter’s request loop, exposing the booted app, the kernel and the adapter; serve() enters that loop and disposes the application once the loop returns — a worker shutting down, or the end of the one request a boot-per-request SAPI served. A loop that throws instead ends the worker with that exception, uncaught: the failure that ended it is what the runtime has to see. Everything assemble() does past constructing the scope is contained, so an assembly failure disposes the scope before propagating and a package bootstrap’s already-open resource is closed rather than abandoned.

  • RuntimeAdapterInterface + RuntimeDetector::detect(TrustedProxies, ...) — the policy comes first and is required: an adapter whose request arrives over a socket some peer connected to settles that request’s scheme and client address before the Kernel or its container exist, so it cannot resolve the policy and must not invent one. It reaches FrankenPhpAdapter, FpmAdapter and RoadRunnerAdapter; BrefLambdaAdapter is constructed from the Runtime API endpoint and takes no policy, since an invocation has no connecting peer and its scheme is the platform’s own fact (see Kinetis\BrefAdapter\LambdaRequestIdentity in Appendix: Satellite Packages). The request body needs no argument here — an adapter hands it on raw, and Http\Middleware\RequestBodyMiddleware bounds and parses it inside the Kernel. Picks FrankenPhpAdapter or FpmAdapter based on function_exists('frankenphp_handle_request'); picks Kinetis\BrefAdapter\BrefLambdaAdapter (separate kinetis/bref-adapter package — class_exists()-gated, not a hard reference) when getenv('AWS_LAMBDA_RUNTIME_API') is set and that package is installed; picks Kinetis\RoadRunnerAdapter\RoadRunnerAdapter (separate kinetis/roadrunner-adapter package, same class_exists()-gated pattern) when getenv('RR_MODE') === 'http'; either missing-package case throws RuntimeUnavailableException::missingAdapterPackage(). All three detection signals are also accepted as optional parameters so tests can exercise every branch without faking global PHP/process state.

  • Adapters\FrankenPhpAdapter — run() is a do/while loop calling frankenphp_handle_request() repeatedly for as long as it returns true; isPersistent(): true.

  • Adapters\FpmAdapter — run() handles exactly one request from superglobals, calling fastcgi_finish_request() when available so the response flushes before any post-response cleanup; isPersistent(): false.

  • AppEnvironment — Development/Production enum. detect() reads APP_ENV: the exact name development, ignoring case, → Development; unset or any other name, a deployment’s own staging included, → Production. A deployment that needs its own environment names told apart matches the raw APP_ENV string instead, the way OpenApi\OpenApiAccess does.

  • ProjectRoot — detect(string $callerDir, ?string $composerBinDir = null): string resolves the consumer project root, accounting for Composer’s vendor/bin/kinetis proxy. An instance is an immutable value whose path is the root an entry point passed to Cache\BootSequence::run(), which binds it on AppScope.

  • StreamableResponseInterface — the one addition to PSR-7 an adapter has to know about: a response that writes its own body incrementally instead of being read through getBody(). getEmitter(): Closure returns the closure that writes those bytes, and abandon(): void releases what the response holds for a body that will never be written. Whoever ends up holding the response settles it exactly once, either way, and both are safe to reach more than once — the release happens on the first of them. A stream Http\Kernel hands back holds that request’s own Container\RequestScope open for the emitter to resolve from, so an adapter that cannot stream calls abandon() rather than dropping the response. The contract lives in this namespace, not Kinetis\Http, so an adapter never needs to know Http\StreamedResponse exists.

  • SuperglobalsBridge — PSR-7 ⇄ superglobal conversion and response emission, shared by FrankenPhpAdapter/FpmAdapter, the only two adapters whose request arrives as superglobals plus a php://input stream. This class does not parse the request body, and PHP must not either: assertCapabilities() refuses to serve anything unless enable_post_data_reading is off — see “Request bodies: one contract under every runtime” in Runtime Adapters for why that setting decides which of the two reads the body. With it off php://input carries the whole body for every method including POST, and this hands that stream on unread as the body of a raw PSR-7 request; Http\Middleware\RequestBodyMiddleware stages, bounds and parses it inside the Kernel, the same way it does for every other adapter. withClientIdentity() takes the URI’s host and port from the Host header the client sent and collapses that header to one value: PSR-7’s own server-request creation takes the host from HTTP_HOST but the port from SERVER_PORT, and adds a second Host header derived from the URI, so a request to example.com served on port 8080 would otherwise carry two disagreeing answers to where it was addressed. It also rebuilds the scheme from what this environment serves and moves it only where Http\TrustedProxies says the connecting peer is an edge, since PSR-7’s own creation applies X-Forwarded-Proto unconditionally. handle() answers an unreadable forwarded scheme from a trusted edge — the one failure that has to be settled while the request is still being built, and so before any middleware exists — with a 400 carrying RuntimeAdapterInterface::MALFORMED_BODY_MESSAGE, the real reason logged and never returned. emit() sends the status and headers, then either echoes the body or, for a StreamableResponseInterface, invokes getEmitter() — and that one call is the only throwable this class contains rather than propagates: status, headers and some body bytes are already on the wire, so there is no replacement response, and an escaping throwable would leave FrankenPHP’s request callback and end the worker, discarding the warm state every later request on that thread would have used. Contained, reported to the SAPI error log as the exception’s class, message and file:line, and the loop serves the next request; the client keeps the truncated body. The containment lives here and not in Http\Kernel, whose streamed wrapper still re-raises an emitter failure after releasing the request scope.

  • Exception\RuntimeUnavailableException — missingFunction(), missingEnvironmentVariable(), missingAdapterPackage().

Kinetis\Config

  • Config — typed environment access: get(), string(), int(), intOrNull(), float(), bool(), required(). An unset or empty value falls back to the given default (or null for intOrNull()); anything else that doesn’t parse throws Exception\InvalidConfigValueException — see Configuration for the exact rules.

  • Config::scopedKey(string $key, string $connection = 'default'): string — the named-connection convention every technology-specific connection builder shares. 'default' returns $key unchanged; any other name inserts itself, uppercased, after the key’s own prefix (REDIS_HOST + cache2 → REDIS_CACHE2_HOST).

  • EnvFile::safeLoad(string $projectRoot) — loads .env via vlucas/phpdotenv, called unconditionally in Runtime\HttpStartup and bin/kinetis, before AppEnvironment::detect().

Kinetis\Logging

  • ErrorLogLogger — a minimal PSR-3 logger writing through error_log(), with {placeholder} context interpolation and the class/file:line of a Throwable under the exception context key appended. AppScope::boot()’s default LoggerInterface binding in development; a consumer-registered logger wins in every environment.

  • SafeLogger::log(LoggerInterface $logger, string $level, string $message, array $context = []): void — logs through a given logger inside a try/catch (Throwable) that discards any failure. For the handful of call sites that are themselves a terminal fallback — Middleware\ExceptionHandlerMiddleware’s catch-all — where logging is diagnostic, not load-bearing, and a throwing LoggerInterface must never turn an observability problem into the very failure the surrounding code exists to recover from. logFrom(callable(): LoggerInterface, ...) is the variant for a call site that must also contain resolving the logger; every RequestScope owner reporting a disposal failure uses it, see Logging. Ordinary logging elsewhere calls $logger->error(...)/warning(...) directly, as PSR-3 intends.

Kinetis\Async

  • Socket — non-blocking TCP, Fiber-suspending connect()/read()/write().

  • Timer::delay() — Fiber-suspending delay.

  • concurrently(array $tasks) — runs each task in its own Fiber drawn from FiberPool, collects results in task order, and rethrows the first failure (in task order) once every task has finished. Nested calls are supported. A task that suspends with nothing registered to resume it surfaces as Exception\DeadlockException naming the task’s index.

  • FiberPool — resident worker Fibers that park between jobs instead of terminating, so a job served from the idle list allocates no Fiber stack. Per PHP thread, retains at most 64 idle residents; a Fiber suspended mid-job is never returned to service. @internal — only concurrently() submits jobs.

  • ConcurrentBatch — one concurrently() call’s coordination state: records each task’s result or failure, parks the caller on a Revolt suspension the last task resumes, diagnoses deadlocks by first unfinished index, and assembles results. @internal.

Kinetis\SimpleCache

A PSR-16 (Psr\SimpleCache\CacheInterface) cache — distinct from Kinetis\Cache below despite the shared word; that one is build-time AOT compilation, this one is a general-purpose runtime cache. Core itself ships only the interface’s always-available default and its exception types — RedisSimpleCache and RedisConnectionFactory live in the separate kinetis/cache-redis package (see Appendix: Satellite Packages), over the standalone kinetis/redis transport, since they’re the only classes in this namespace with a real Redis dependency.

  • AtomicCounterInterface — increment(string $key, int $ttlSeconds): int and count(string $key): int, implemented alongside CacheInterface by a backend that can count without reading first. PSR-16 has no such operation, and building one from get() then set() is not safe across processes. A counter is stored in whatever form the backend increments natively — a Redis INCR counter holds a bare integer where the cache otherwise stores serialized values — so it is read through count(), never get().

  • AtomicConsumeInterface — consume(string $key, mixed $default = null): mixed, implemented alongside CacheInterface by a backend that can read and delete a key in one operation. PSR-16 has no such operation either, and a get() then a separate delete() is not safe across processes: two concurrent callers can both read the value before either deletes it. Required by Kinetis\AuthJwt\RefreshTokenStore, which refuses a cache without it — unlike the counter above, there’s no safe read-then-write fallback for “consume once,” so this one has no soft-degrade path.

  • DisposableCacheInterface — dispose(): void, implemented by a cache that can own the connection it was built with. Ownership travels with construction: a factory that opened the connection hands over its close, and a cache constructed around a caller’s connection closes nothing. dispose() is idempotent and opens nothing. AppScope::boot() registers it only for the default cache it builds; see Who closes the connection.

  • NullSimpleCache — the default when Redis isn’t configured (see UnavailableSimpleCache below for the configured-but-not-installed case). Always misses, never stores.

  • Exception\CacheException / Exception\InvalidArgumentException — implement the matching Psr\SimpleCache\* exception interfaces; reused by kinetis/cache-redis’s own classes, not redeclared there.

  • UnavailableSimpleCache — bound when Redis is configured (REDIS_HOST/REDIS_URL/REDIS_CLUSTER) but kinetis/cache-redis isn’t installed. Every operation throws Exception\SimpleCacheUnavailableException naming the package; nothing is silently discarded, and nothing fails until the cache is actually used, so a leftover REDIS_* in a .env leaves an application that never touches the cache unaffected. It implements AtomicCounterInterface/AtomicConsumeInterface too, so a counter or RefreshTokenStore built on it fails on first use naming the package to install rather than being rejected at construction for lacking the interface. The same usage-time-over-configuration-time trade RateLimitMiddleware/RevocationStore make by rejecting NullSimpleCache at construction rather than at boot.

  • Exception\SimpleCacheUnavailableException — thrown by every UnavailableSimpleCache operation, naming kinetis/cache-redis.

Kinetis\Security

  • AttemptThrottle — an identifier-keyed failure counter for anything failure-prone (a password check, a 2FA code, an invite redemption), not middleware: whether an attempt failed is only known once application code runs it, so recordFailure()/clear() are called directly. tooManyAttempts()/availableInSeconds() read the current lockout; each failure refreshes the window to decaySeconds from that failure, so repeated attempts keep extending it. The cache key folds a policy identity (maxAttempts, decaySeconds, an optional namespace) together with the identifier itself, each field hashed on its own before being joined and the joined result hashed once more — never a plain delimited concatenation, which has no safe delimiter (a namespace or identifier containing : can otherwise shift a field boundary and collide two different policies). Counts through SimpleCache\AtomicCounterInterface, and requires the cache to implement it — NullSimpleCache and any other non-atomic cache are both refused at construction with Exception\AttemptThrottleUnavailableException, since a cache that can’t count atomically lets failures arriving together — how a password is actually attacked — register as one. maxAttempts/decaySeconds below 1 are rejected too, with Exception\InvalidAttemptThrottleConfigException.

Kinetis\Cache

  • Compiler::compile() / compileProject(DiscoveryContext $context) — walks a Router/CommandRegistry/EventListenerRegistry, derives binding/validation plans, produces a CompiledCache. compileProject() builds them via GlobalMiddlewareDiscovery/RouteDiscovery/CommandDiscovery/EventListenerDiscovery/PluginDiscovery, all through the one context it is given, which also supplies the package-bootstrap list. kinetis build and the production fallback compile each pass a fresh context. Compiler itself never walks MCP tool/resource definitions directly — but PluginDiscovery::discover() compiles kinetis/mcp’s own McpRegistry into PluginCache too, the same as any other installed package’s declared CacheableDiscoveryInterface class (see below), so MCP tool/resource definitions are part of the compiled cache once kinetis/mcp is installed and a build has run. Only with no cache published yet (development, or production before the first build) does anything discover MCP tools/resources live.

  • HttpCache / CommandCache / EventCache / PluginCache — the four sections, always published together as one artifact (see CacheStore below) even though a given entry point only ever reads the sections it actually consumes, not all four — an HTTP boot reads HttpCache/EventCache/PluginCache, the CLI reads CommandCache/EventCache/PluginCache. CompiledCache::$packageBootstraps carries the extra.kinetis bootstrap-class list once, beside the format version — production reads it from the artifact its entry point already loads whole instead of re-reading vendor/composer/installed.json. HttpCache::$middlewareGroups carries the #[AsMiddlewareGroup] map; a route’s own middleware list stores raw references (a class-string or a @name) and is never expanded at compile time, so a group’s membership can change without recompiling every route that references it. PluginCache::$data is a class-string => array map, one entry per installed package’s own extra.kinetis discovery class (see CacheableDiscoveryInterface below). Each one’s own fromArray() validates every top-level field’s presence and type via Exception\ArtifactValidation (@internal) — including, via ArtifactValidation::exactKeys(), that no extra field is present either, matching toArray()’s own shape exactly rather than merely tolerating a superset of it. HttpCache/CommandCache additionally validate each route/command entry’s own required fields (again exact-keyed) and reject a duplicate route/command the same way Router/CommandRegistry’s own fromArray() do (see below) — every one of these throws Exception\InvalidCacheArtifactException for anything missing, extra, or wrong-typed, rather than a raw missing-array-key warning surfacing several calls deeper as a TypeError once the resulting null reaches a non-nullable typed constructor parameter. globalMiddleware/openApiMiddleware are validated as list<string> (ArtifactValidation::listOfStrings()) and middlewareGroups as array<string, list<string>> (ArtifactValidation::mapOfListOfStrings()), not merely “an array”. A route entry is exactly httpMethod, pathTemplate, controllerClass, controllerMethod, status, middleware and where — the last always serialized, [] included, and validated as array<string, string> by ArtifactValidation::mapOfStrings(), which rejects a numeric key and a non-string value; a constraint Route’s own constructor then rejects is reclassified as InvalidCacheArtifactException by Router::fromArray(); CompiledCache::fromArray() validates its own packageBootstraps as list<string> the same way, one level up. HttpCache::fromArray() delegates httpBindingPlans/hydrationPlans validation to Http\Dispatcher::validateBindingPlans()/Validation\Hydrator::validatePlans() (below) — the abstractions that actually own those two plan shapes — rather than re-deriving their own recursive validation rules a second time here.

  • Http\Dispatcher::validateBindingPlans(array $plans): void / Validation\Hydrator::validatePlans(array $plans): void — the one place HttpBindingPlan’s and HydrationPlan’s own shapes are ever validated, called by HttpCache::fromArray() rather than that class re-deriving either abstraction’s own rules. Both validate every top-level key is a real string and every entry has exactly its documented fields, correctly typed — defaultValue excepted, an arbitrary PHP value with no single type to check. Every constraint-descriptor list ({class, args}) is validated via the shared Exception\ArtifactValidation::listOfConstraintDescriptors(), args’ own contents left unchecked beyond “an array” — the specific constraint class’s own constructor is what actually gives them meaning, and this helper can’t know about a constraint class a consumer application defines itself. Hydrator::validatePlan() (private) recurses into every plan nested inside one — a parameter’s own non-null nestedPlan, and the one a listItem descriptor carries — themselves the identical shape one level deeper, exactly as compilePlan() embeds them; the descriptor’s own fields (scalarType, enumClass, dtoClass, constraints) are exact-keyed and type-checked like any other. Naturally bounded, since compilePlan() rejects a recursive definition outright, so a circular plan is not producible in the first place.

  • Exception\CacheArtifactExceptionInterface — implemented by any exception meaning “this data, read directly from a compiled cache artifact, does not represent a valid instance of the type being reconstructed” — never a genuine programming defect. Exception\InvalidCacheArtifactException (this namespace’s own general-purpose implementation, used by HttpCache/CommandCache/EventCache/PluginCache/Http\Routing\Router/Console\CommandRegistry) and Events\Exception\InvalidListenerException both implement it. BootSequence’s cache-bundle loaders (below) catch exactly this interface — never a bare Throwable — around every reconstruction call working purely from data just read off disk, so a real bug (an undefined method inside a plugin’s own fromArray(), say) still propagates instead of being silently relabelled “corrupt cache” and retried as a fresh compile. A CacheableDiscoveryInterface::fromArray() implementation is expected to throw something implementing it for malformed $data — see that interface’s own contract, below.

  • CacheStore — the one artifact, read and written: write(CompiledCache $cache) renders all four sections, the format version and the package-bootstrap list as one var_export()ed PHP file at .kinetis-cache/compiled.php, and load(): ?CompiledCache reads it back. A publish writes to a uniquely-named temporary file in the same directory, verifies the whole write landed and that requireing the result reconstructs the identical array, and only then rename()s it over the live path and calls opcache_invalidate() — so a reader either sees the previous artifact or the complete new one, never a partial file. That invalidation covers the calling process’s own OPcache and no other — enough for this same process to stop seeing a stale or rejected artifact it already required from that path, and nothing a CLI kinetis build can give a separate serving pool; see Caching & AOT Compilation. A failed publish removes its own temporary file before the exception propagates. An object anywhere in a section fails the write with Exception\UnexportableArtifactException naming the path to it — var_export() would render it as a ::__set_state() call the reload can’t replay — and that is a compile defect, not a persistence failure, so it is not the Exception\CacheWriteException a degraded boot absorbs. An enum case is the exception, and the only one: var_export() writes it as a literal that reloads into that same process-wide singleton, so a plan’s captured enum default survives the round trip identically. A parameter default that constructs any other object is refused earlier, by Reflection\ParameterDefault where the plan is derived. load() wraps the artifact’s own require narrowly, by type only, for ParseError: a syntax-corrupt file (a truncated write, disk corruption, manual tampering; never a shape write() itself produces) is treated identically to a missing one, returning null rather than letting the error escape this class. A format version that is not exactly CacheFormat::VERSION is also null — a compiled artifact is read whole or not at all. path() is the artifact’s absolute path, for tooling and for the one warning a degraded boot logs.

  • DiscoveryContext — the state one discovery operation shares: a development HTTP boot, a development CLI invocation, a Testing\TestApplication boot, Compiler::compileProject() (for kinetis build and the production fallback compile), or Console\RoutesListCommand’s own pass. new DiscoveryContext(string $projectRoot); the entry point hands it to every discoverer and section in that operation and drops it afterward — it is never static, bound in the container, or kept by a worker. projectClasses(array $paths = []) returns the classes under the project’s own composer.json autoload.psr-4 roots, at any depth, with no directory/namespace convention required; $paths restricts the walk to sub-paths relative to each PSR-4 base directory. frameworkClasses(string $segment) walks one segment (“Console”, “Http”, “Events”) under Kinetis’s own package root. packageClasses() walks the prefix/directory pairs installed packages declare as extra.kinetis scan, each resolved against that package’s own PSR-4 map. All three reduce to prefix/directory pairs, and each pair is enumerated, token-filtered and reflected once per context — a later request for the same pair returns the first result, so the discoverers of one boot share one walk of a root; a different $paths restriction is a different pair. A file is skipped entirely (no class_exists() autoload) unless it contains at least one PHP attribute, found via a cheap token scan rather than a full parse; abstract classes and enums are skipped. A project with no autoload.psr-4 map is reported once per context via error_log(). vendor/composer/installed.json is parsed once per context: packageBootstraps() lists the declared bootstrap classes and discoverySections() the declared discovery classes implementing CacheableDiscoveryInterface, both in Composer’s recorded order. A scan prefix outside the package’s own roots, a missing bootstrap class, or a discovery class not implementing the interface is logged via error_log() once and skipped. Deduplicating a class found through both the project and the framework segment (developing Kinetis itself makes the two roots the same repository) is each Discovery class’s own responsibility. compiled(string $section): array is how a discovery section reads another — see CacheableDiscoveryInterface below.

  • CacheableDiscoveryInterface / PluginDiscovery — the pluggable half of the AOT cache: a package declares one class (its extra.kinetis discovery key) implementing compile(DiscoveryContext $context): array (live discovery, reduced to plain data) and fromArray(array $data): static (reconstruction) — required to throw something implementing Exception\CacheArtifactExceptionInterface (above) when $data doesn’t represent a valid instance; anything else fromArray() throws (a genuine defect, not a data-shape problem) is expected to propagate uncaught. A section is compiled only through DiscoveryContext::compiled(), once per context: the first read compiles it and every later read returns the same plain array. A section that consumes another calls $context->compiled(Upstream::class) from its own compile(), which compiles the upstream section first — the reads themselves are the dependency order, with no separate declaration — and never hands back a reconstructed instance. compiled() throws Exception\DiscoverySectionException for a section no installed package declares, naming the requested section and the requester (and the package, when its declaration was skipped as malformed), and for a cycle, naming the compile chain. PluginDiscovery::discover(DiscoveryContext $context) walks the installed sections in Composer order through compiled() and returns them keyed in that order — the same method Compiler::compileProject() calls to build PluginCache, so there is exactly one algorithm, not two. See CLI for a dependent-section example. PluginDiscovery::reconstruct(array $data): array<class-string, object> validates the map itself before any dynamic dispatch — every key a real string (PHP silently coerces a numeric-looking array key to int) naming a class that both exists and implements CacheableDiscoveryInterface, every entry an array — throwing Exception\InvalidCacheArtifactException for a violation, then calls each entry’s own fromArray() and returns the live instances, propagating whatever it throws unchanged — it has no fallback to offer; the caller decides what a failure means. PluginDiscovery::bindInstances(AppScope $app, array $instances): void binds already-reconstructed instances into AppScope directly, before the bootstrap chain runs, with zero reconstruction of its own, so fromArray() — object construction, not a guaranteed pure validator — runs once per boot. A package’s own PackageBootstrapInterface::register() never touches this data at all — by the time it runs, the framework has already bound it. kinetis/broadcasting’s BroadcastChannelRegistry, kinetis/database-bridge’s OrmMetadata and kinetis/mcp’s McpRegistry are the real consumers.

  • RoutesFile::loadBootstrap(string $projectRoot, array $packageBootstraps) — composes the bootstrap chain run with (AppScope, Config) before boot() locks bindings: each package’s PackageBootstrapInterface class first (the list the entry point resolved — from the artifact, or DiscoveryContext::packageBootstraps()), then the consumer’s own bootstrap.php — last, so an application binding wins over a package’s for the same id. Routes, commands, global middleware, and event listeners are all found by namespace instead (RouteDiscovery/CommandDiscovery/GlobalMiddlewareDiscovery/EventListenerDiscovery); pluggable package data is bound separately, by PluginDiscovery::bindInstances().

  • BootSequence::run(AppScope $app, string $projectRoot, Config $config, EventListenerRegistry $listenerRegistry, array $pluginInstances, array $packageBootstraps, bool $runBootstrap = true) — the one piece of assembly every framework-managed entry point delegates to instead of repeating inline: Runtime\HttpStartup, bin/kinetis, and Kinetis\Testing\TestApplication all call it, so none of them can drift on this ordering. Binds new Runtime\ProjectRoot($projectRoot) first, whatever $runBootstrap is, then calls PluginDiscovery::bindInstances() with $pluginInstances (already-reconstructed class-string => object, from the artifact or the entry point’s own live discovery), binds the given $listenerRegistry, then runs RoutesFile::loadBootstrap() with $packageBootstraps unless $runBootstrap is false (the one bin/kinetis #[Command(bootstrap: false)] needs — the discovered/cached registries still bind, but the whole package-then-application bootstrap chain is skipped, every installed package’s own PackageBootstrapInterface::register() as well as the project’s bootstrap.php, not just the latter). Stops short of calling $app->boot() itself: TestApplication needs one more step, its own $beforeBoot callback, to run after the bootstrap chain and before the container locks, so every caller calls boot() right after its own final pre-boot seam — immediately for HttpStartup/bin/kinetis, or after $beforeBoot for TestApplication.

    BootSequence::resolveHttp(CacheStore $store, callable $compile): array{httpCache: HttpCache, router: Http\Routing\Router, listenerRegistry: EventListenerRegistry, pluginInstances: array, packageBootstraps: list<class-string>} and resolveCli(CacheStore $store, callable $compile): array{registry: Console\CommandRegistry, listenerRegistry: EventListenerRegistry, pluginInstances: array, packageBootstraps: list<class-string>} are the entire “use the cache, or compile fresh” decision a production entry point calls, in one place: loadHttpFromCache()/loadCliFromCache() (below) first; on a miss, $compile (a callable(): CompiledCache, injected so this whole decision is testable without a real project root — a test can count invocations or make it throw) runs exactly once and every runtime object is reconstructed from that same in-memory CompiledCache, never by reading back through $store. Publishing that result is the last step and the only one that may fail without changing what this boot serves: a CacheWriteException is caught and reported as one error_log() line naming the artifact, and the boot proceeds on the value it already compiled, so an unwritable cache directory costs every boot a compile instead of refusing to serve. A $compile failure, or a reconstruction failure against its freshly-compiled data, propagates exactly like any other genuine bug, since there is no cache artifact left to blame it on.

    BootSequence::loadHttpFromCache(CacheStore $store): ?array{httpCache: HttpCache, router: Http\Routing\Router, listenerRegistry: EventListenerRegistry, pluginInstances: array, packageBootstraps: list<class-string>} and loadCliFromCache(CacheStore $store): ?array{registry: Console\CommandRegistry, listenerRegistry: EventListenerRegistry, pluginInstances: array, packageBootstraps: list<class-string>} are the cache-hit half resolveHttp()/resolveCli() build on — the whole “is there a usable compiled artifact” question, in one place: the artifact is read once and every section a boot needs is reconstructed into the live runtime object (Http\Routing\Router/Console\CommandRegistry included, not just the raw DTOs) — or null the instant the artifact is absent, a stale format, or fails to reconstruct for any reason, structural or otherwise. Never a hybrid of some cached, some live-empty sections, and never a bundle accepted here that then fails immediately outside this method once the caller starts using it. The catch inside is scoped narrowly two ways: by what code sits inside the try (the CacheStore::load() call and every reconstruction that follows, nothing else — no live discovery, no bootstrap.php registration), and by exception type (Exception\CacheArtifactExceptionInterface only, above) — a plugin’s own fromArray() throwing anything else propagates uncaught rather than being silently relabelled “corrupt cache.” On null, a caller compiles fresh (via resolveHttp()/resolveCli(), or by hand) and uses that CompiledCache’s own in-memory sections directly for the boot.

    BootSequence::assertReconstructable(CompiledCache $compiled): void is the whole-artifact form the same reconstructions compose into: both entry points’ registries — Http\Routing\Router and Console\CommandRegistry — plus the event and plugin sections they share, each reconstructed exactly once, throwing whatever a section’s own fromArray() throws. Console\BuildCommand publishes through this, so an explicit build applies the identical semantic contracts a boot does, across the whole artifact rather than one entry point’s half of it: a CacheableDiscoveryInterface implementation whose compile() output its own fromArray() rejects fails the build instead of being published for every worker to reject and recompile. The reconstructed objects are discarded — a build has no boot to hand them to, and what it publishes is the compiled data itself. Once each because fromArray() is construction rather than a pure validator. Reconstructing a whole artifact stays out of the per-entry-point path: a cache-hit HTTP boot builds no Console\CommandRegistry, and the reverse for the CLI. The package-bootstrap class list is carried, not reconstructed — Cache\RoutesFile::loadBootstrap() resolves those names at boot and skips one whose package has since been removed.

  • Not part of this cache: Kinetis\Config (.env/environment values) — the cache is rebuilt from source via bin/kinetis build; environment configuration isn’t.

Kinetis\Validation / Kinetis\OpenApi

  • Hydrator — builds and validates a #[Body]-bound DTO from constructor-parameter reflection and Constraint-implementing attributes (#[Email], #[NotBlank], #[MinLength], #[MaxLength], #[GreaterThan], #[LessThan], #[GreaterThanOrEqual], #[LessThanOrEqual], #[MultipleOf], #[Regex], #[In], #[NotIn], #[MinItems], #[MaxItems], #[Url], #[Uuid], #[Ip], #[Date], #[DateTime], #[FileSize], #[FileExtension]); #[MinItems]/#[MaxItems] bound a list-shaped array field, and #[FileSize]/#[FileExtension] describe an uploaded file. A rule’s own constructor refuses a definition with no truthful violation parameter or schema keyword — a negative length or item bound, a non-finite numeric-bound threshold, a #[MultipleOf] divisor below 1, an empty/keyed/non-scalar/non-finite #[In]/#[NotIn] set, a #[FileSize] with a negative bound or a minimum above its maximum, a #[FileExtension] set that is empty, keyed, or holds anything but an alphanumeric suffix written without a leading dot, an uncompilable #[Regex] pattern — with an InvalidArgumentException where the constraint is instantiated. A missing key on a defaultless parameter is is required. (code required); an explicitly-null value for a parameter whose declared type doesn’t allow null is must not be null. (code null_not_allowed) — both validation failures, never a raw TypeError from the constructor. A class-typed constructor parameter accepts an object-shaped value hydrated into that class, or a value already an instance of it. UploadedFileInterface is the one such type with a check in front of it: resolveUploadedFile(array $path, mixed $value, array $constraints = []): array{0: mixed, 1: list<Violation>} is the shared upload path every typed route enters — a DTO field, a #[ListOf] element, and Dispatcher’s own direct parameter — answering the ordinary not_an_instance violation for a value that is no file, one upload_failed (['error' => <the raw UPLOAD_ERR_* status>], message could not be uploaded.) for a file whose status is not UPLOAD_ERR_OK, and otherwise the field’s own rules. The status gate runs first, so no rule — and no stream read behind one — ever touches a file that did not arrive; UPLOAD_ERR_NO_FILE never reaches it, Dispatcher having already normalized an empty control into omission. A parameter typed as a backed enum is the one class-typed field a wire value can produce: the public resolveEnumValue() resolves the value through resolveScalar() as the enum’s own backing type and then hands it to tryFrom(), so a wrong primitive is an ordinary type_mismatch and a correctly typed value naming no case is one enum_case violation carrying ['choices' => <backing values>], never a TypeError; Http\Dispatcher binds a #[Query]/path enum parameter through that same method, so an HTTP parameter and a DTO field resolve a case by one set of rules rather than two; the plan records enumClass plus the backing type as scalarType, and rules run against the resolved case. backedEnumScalarType() asks enum_exists() before the BackedEnum relationship, so the interface itself — which satisfies is_a() against itself while inheriting an abstract cases() — stays the instance-only class shape rather than reaching an engine Error. #[ListOf(string $type)] (itemType()) declares a list’s element type — string, int, float, bool, a backed enum, an instantiable class, or exactly UploadedFileInterface, the one interface admitted because a repeated file control is a real multipart shape — compiled into one plain-data listItem descriptor (scalarType, enumClass, dtoClass, nestedPlan, constraints) by the public listItem(), which JsonSchema reads too so a published items and a hydrated element cannot disagree. Every element resolves under a ['field', index] path through the same path its own family would take as a field. An upload element carries dtoClass with a null nestedPlan, exactly as a single upload field does: compileNesting() compiles an item’s nested plan only for an instantiable item class. The repeatable #[Each(string $constraint, mixed ...$arguments)] declares a Constraint every element of a scalar, backed-enum or uploaded-file list runs, compiled to the same literal {class, args} descriptors a field rule gets, with named arguments kept as written; only a resolved element runs them, and the field’s own rules run only once every element succeeded. An array parameter carrying #[ListOf]’s counterpart #[ObjectMap] admits a JSON object of arbitrary keys instead, handing the constructor its plain array form; admission is keyed on the JsonObject provenance marker rather than the decoded shape, since {} and [] are the same PHP array, so a form-encoded body or a hand-built array cannot fill one. A plan stores it as a boolean objectMap field per parameter, retaining no attribute instance. The public objectMap() decides that boolean and refuses both invalid #[ObjectMap] declarations listed below; JsonSchema and McpDispatcher::derivePlan() read it too. The public resolveObjectMap(array $path, mixed $value, bool $allowsNull, array $constraints = []) is the one resolution path, shared by a DTO field and an MCP tool argument: null handling, the JsonObject provenance check (not_a_json_object for a JSON array), the recursive unwrap and the field’s own rules, returning [value, list<Violation>]. A parameter may also declare the presence union T|Absent/T|null|Absent defaulted to exactly Absent::Value (see Absent below); absentUnion() resolves it to T plus whether null was named, the plan records the declaration as a boolean absent field, and the rest of the parameter is compiled from T exactly as T alone would be. compilePlan() embeds each nested class’s own plan inline (nestedPlan, and a DTO list item’s own nestedPlan inside its listItem), collects the class’s own ObjectConstraint attributes into the plan root’s objectRules as the same literal {class, args} descriptors (collectObjectRules(), which builds each rule transiently to check every name its fields() returns against the constructor’s own parameter names before discarding it, so an object rule naming a field the class has not declared fails here and in schema generation alike), and throws Exception\UnsupportedDtoDefinitionException for a definition with no finite plan — an intersection type or a union that is not one of the two presence forms, a malformed presence union (no value type, two of them, no default, or a default other than Absent::Value), a recursive or mutually recursive class reference, an unresolvable self/parent/static, #[ListOf] on a non-array parameter or naming an element type outside the admitted set, a backed enum with no cases (no value can name a case that does not exist, and JSON Schema’s enum may not be empty), #[Each] without a #[ListOf], on a DTO list (an upload list may carry it, an uploaded file having no fields of its own to write a rule on), or naming a class that does not implement Constraint, #[ObjectMap] on a non-array parameter or on the same parameter as #[ListOf], or a DTO class that cannot itself be instantiated. A parameter typed as a non-instantiable class that is not a backed enum — an interface, an abstract class, a unit enum — keeps its class name with a null nestedPlan: it accepts an existing instance and nothing else. See Appendix: Routing & Validation’s “DTO definitions Kinetis rejects”. SUPPORTED_BUILTIN_TYPES is the closed set a request value may be bound to (string, int, float, bool, array, iterable, mixed); compilePlan() rejects any other builtin, Dispatcher::derivePlan() rejects it for a #[Query]/path parameter, and JsonSchema describes exactly that set. resolveScalar(InputSource $source, array $path, mixed $value, ?string $scalarType, bool $allowsNull, array $constraints = []) is the one raw-scalar path every source enters — a #[Body] field here, a #[Query]/path parameter via Dispatcher, an MCP tool argument via McpDispatcher — applying null handling, the declared-type check for that source, the source’s own normalization, the cast and the field’s own rules in that order and returning [value, list<Violation>]. resolveDtoValue(InputSource $source, array $path, mixed $value, string $class, ?array $compiledPlan = null) is its DTO-typed counterpart for one member of a larger document — Dispatcher’s rooted #[Body] — answering exactly what a nested DTO field answers under that path, compiling and memoizing the plan when none is given. Presence is not part of it: what an absent value means is knowable only where it was read, so each caller resolves absence first (requiredViolation() and objectExpectedViolation() stay public for the failures detected before there is a scalar to resolve). An accepted null is the value and skips the field’s rules. Under InputSource::Json the object is closed: every member of $data naming no constructor parameter is an is not expected. violation (code unexpected_field) on its own path, in the input’s own order, combined with every other field failure in the same ValidationException — at every nesting level, and for a constructor-less DTO too; Text and Native stay open, since a form body legitimately carries CSRF/submit/honeypot members and a Native caller hands over values it already holds, often wider than the DTO reading them. unexpectedFieldViolation() is public so McpDispatcher closes a tool call’s own arguments object with the identical code and sentence. Once every field has resolved and the DTO is constructed, the plan’s objectRules run against it with a ValidationContext naming the supplied fields; after any field failure they do not run at all. hydrate()’s own $source defaults to InputSource::Native. See Appendix: Routing & Validation’s “Scalar type checking”, “Unknown members are rejected for JSON”, and “Rules about the whole DTO”.

  • Violation — one immutable failure: a segmented list<string|int> $path (a member name is a string, a list index an int, so a dot inside a name and a position stay distinguishable, and no source-specific spelling like a JSON Pointer is baked in), a stable $code, a default-English $message, and array<string, scalar|list<scalar>|null> $parameters the message was built from. Every one of those rules is checked at construction and a breach is an InvalidArgumentException, since application code builds violations too. under(string|int ...$segments) prepends segments for a nested field or list element and returns a new instance. Implements JsonSerializable, and that one encoding is the wire shape both transports use — the HTTP errors extension and the MCP tool-error envelope alike — with parameters cast to an object so an empty set stays {}.

  • Exception\ValidationException — a non-empty ordered list<Violation> and nothing about any transport. fromViolations() is the only factory and refuses an empty list, a keyed map, or a non-Violation element. grouped(): array<string,list<string>> projects the paths onto dotted keys with their messages, an explicitly lossy convenience for a form-oriented renderer (codes, parameters, and the dot/separator boundary do not survive it); a violation addressing the payload as a whole is keyed $. Http\Middleware\ExceptionHandlerMiddleware renders it through Http\ValidationExceptionRendererInterface, and Kinetis\Mcp\KinetisMcpApplication puts the same violations in its own isError envelope.

  • InputSource — the enum naming which wire vocabulary a raw value was written in, and therefore which spellings of a declared scalar type bind: Json (a JSON #[Body], an MCP tool argument — JSON primitives only, so "42" is a string), Text (#[Query], path segments, form-encoded and multipart bodies — the canonical textual spelling of each type, "true"/"false" for bool included), and Native (a direct Hydrator::hydrate() call over PHP values the caller already holds — both spellings of a number, and 1/0/"1"/"0" for bool). Dispatcher picks Json or Text per request from the body’s own media type and always Text for #[Query]/path; McpDispatcher picks Json. See Appendix: Routing & Validation’s “Scalar type checking”.

  • Constraint — the two-method contract every rule implements: validate(mixed $value): ?Violation returns the rule’s own code, message and parameters at a path relative to the value (normally [], prefixed by Hydrator), and schema(): array returns the JSON Schema 2020-12 keywords stating the same rule, or [] for a runtime-only one. A rule is constructed per operation from its literal {class, args} descriptor and discarded, so it holds no state across requests; one that throws is a programmer error, not a 422. Constraints\ ships #[Email] (email), #[NotBlank] (not_blank), #[MinLength]/#[MaxLength] (min_length/max_length), #[GreaterThan]/#[LessThan] and #[GreaterThanOrEqual]/#[LessThanOrEqual] (greater_than/less_than, greater_than_or_equal/less_than_or_equal, not_a_number for a non-number), #[MultipleOf] (multiple_of, not_an_integer for anything but an int), #[Regex] (regex), #[In]/#[NotIn] (in/not_in), #[MinItems]/#[MaxItems] (min_items/max_items, not_a_list for a non-list), #[Url] (url), #[Uuid] (uuid, RFC 9562’s 8-4-4-4-12 hexadecimal string form in either case, with no version or variant policy), #[Ip] (ip, either family, published as an anyOf of format: ipv4 and format: ipv6), #[Date] (date, YYYY-MM-DD in years 0001-9999), #[DateTime] (date_time, RFC 3339’s date-time without leap seconds and without the space separator), #[FileSize(int $maxBytes, int $minBytes = 0)] (file_too_large/file_too_small with {max|min, size}, file_size_unknown when PSR-7 reports no size, not_a_file for a non-upload) and #[FileExtension(array $extensions)] (file_extension with {choices}, not_a_file for a non-upload). The string rules fold a non-string into their own code rather than a shared one. The two file rules read the part’s own metadata and never its stream: #[FileSize] reads the reported size once, and #[FileExtension] matches the client-supplied filename’s final suffix with str_ends_with() against . plus each declared suffix, ASCII-case-insensitively — a filename policy over an untrusted label, not content or MIME validation, which is why neither publishes a JSON Schema keyword and why the filename never reaches the violation.

  • ObjectConstraint — the class-attribute counterpart of Constraint, for a rule relating two fields rather than checking one value: validate(object $value, ValidationContext $context): iterable yields Violations at paths relative to the DTO ([] addresses the object itself), fields(): array names the constructor fields the rule relates ([] for a whole-object rule), and schema(): array returns object-level JSON Schema keywords or []. Hydrator::collectObjectRules() checks every fields() name against the guarded class’s constructor, so a mistyped one is an Exception\UnsupportedDtoDefinitionException where the plan is compiled and where the schema is generated, never a rule that silently never matches. Discovered with ReflectionAttribute::IS_INSTANCEOF like a field rule, stored as a {class, args} descriptor at the hydration plan’s root, and constructed fresh per hydration, so it is subject to the identical purity contract; throwing, or yielding a non-Violation, is a definition failure (Exception\UnsupportedDtoDefinitionException), never a client response. ObjectConstraints\ ships #[AtLeastOneProvided(string ...$fields)] — presence only, failing at [] with code at_least_one_provided and publishing an anyOf of one required clause per field — and #[SameAs($field, $other)], which reads both values by direct reflection (a promoted private field included) and compares them only when the input supplied both, fails at [$field] with code same_as, and publishes nothing, no JSON Schema keyword comparing two properties’ values. Both read before the presence check, so a field the object holds no readable value for is a definition failure whichever members the request carried. Each refuses arguments it could not state — an empty, blank-named or repeated field list; a SameAs naming one field twice — with an InvalidArgumentException, both return their configured names from fields(), and a SameAs field it cannot read off the constructed object is a definition failure.

  • ValidationContext — the immutable per-hydration value an ObjectConstraint receives beside the DTO, carrying only the constructor field names the input supplied, with wasSupplied(string): bool as its whole read surface. It is the one thing a constructed object cannot be asked — an omitted field and a field sent with exactly its default look identical — and holds no request, container, transport or DTO.

  • Absent — the one-case enum (Absent::Value) a DTO constructor field uses as its third state: T|Absent or T|null|Absent, defaulted to exactly Absent::Value, distinguishes an omitted member from an explicit null from a supplied value. Bound only from that default — no input can produce it, and an Absent in hydrated data is refused as the wrong type for the field — and omitted from generated schemas, which describe T (widened with null where declared) and leave the member out of required. DTO constructor fields only: Dispatcher::derivePlan(), MCP tool schema generation and McpDispatcher::derivePlan() still reject every union on a method parameter. See Appendix: Routing & Validation’s “Required, optional, and absent fields”.

  • JsonSchema — the type/constraint → JSON Schema mapping shared by OpenApiGenerator and MCP tool input schemas. Two entry points share one object-schema builder: forClass() describes a DTO’s constructor, where a T|Absent presence union is legal (published as T, widened with null only where the union names it, and left out of required because the declaration carries a default) and where the class’s own ObjectConstraint::schema() keywords are merged in; forParameters() describes a method’s parameters, where any composite type is an Exception\JsonSchemaException — so an MCP tool whose schema cannot be stated truthfully fails at registration. Every object either produces says additionalProperties: false, including one with no properties at all, matching what Hydrator enforces for JSON input and McpDispatcher for a tool’s arguments. An optional $classSchema callback lets a caller substitute something other than inlining for a nested class-typed parameter’s schema; null (every MCP call site) keeps inlining. Nullability and required presence are independent: the required array is driven purely by !isDefaultValueAvailable(), matching Hydrator::hydrateFromPlan()/McpDispatcher::resolveFromPlan()’s identical rule — a defaultless nullable parameter stays required, since both only ever exempt an explicitly-null value from their type check, never an absent key from their presence check. A nullable type is reflected in the property’s own schema instead: a builtin scalar’s type is widened into a two-element array (['string', 'null']) once every rule’s keywords are already merged in, so a rule contributing a second closed domain (#[In]’s enum) widens alongside it rather than being merged onto an already-widened type; a nullable class-typed/#[ListOf] parameter gets the identical widening when inlined, or — since $ref only combines with sibling keywords as an intersection in JSON Schema 2020-12, never a union — is wrapped in anyOf: [<the $ref>, {type: null}] when $classSchema produces one. OpenApiGenerator’s own #[Query]/path parameter required (computed independently, never through forParameters()) already followed the default-driven rule and needed no change. An #[ObjectMap] parameter is described as {type: object, additionalProperties: true} — the one array-typed parameter whose schema is an object, decided by Hydrator::objectMap(), so a declaration it refuses fails schema generation too — and schemaForListOf() merges a #[ListOf] parameter’s own constraint keywords (minItems/maxItems) beside its items, through the same withConstraintSchema() helper schemaForScalar() uses. items itself comes from Hydrator::listItem(): an element class’s expanded object schema, an uploaded file’s {type: string, format: binary}, an element scalar’s JSON type, or a backed enum’s backing type plus the exact enum of its cases, with each #[Each] rule’s own keywords merged onto it. A backed-enum field publishes that same pair through schemaForEnum(), with its own rules’ keywords merged onto it by the same helper, and only inside a DTO’s own constructor — a method parameter typed as an enum still reaches schemaForClassTyped() through forParameters() and is refused, so an MCP tool declaring one fails registration. The public schemaForScalar(), which OpenApiGenerator calls directly for a #[Query]/path parameter, answers a backed enum with that same pair instead, matching what Dispatcher binds such a parameter against; any other class type there is Exception\JsonSchemaException, which Dispatcher::derivePlan() has already refused at route registration. withNullableSchema() widens an enum alongside type, both domains a nullable value has to satisfy. withConstraintSchema() takes the keywords the PHP declaration owns and merges each rule’s own Constraint::schema() onto them, reading the rules through Hydrator::collectConstraints() — the same {class, args} descriptors validation runs — so an application constraint describes itself with nothing registered. A keyword has exactly one owner: two rules on one parameter contributing the same keyword throw Exception\JsonSchemaException rather than letting declaration order pick a bound, and so does a rule contributing a keyword the declaration itself states (type, or a #[ListOf]’s items), which would publish a shape the request is not checked against. One private withRuleKeywords() enforces that for all three rule owners — a field or parameter, a DTO class, and one list element. withObjectRuleSchema() applies the identical rule one level out, for the object-level keywords a class’s own ObjectConstraints contribute, against each other and against the four the PHP declaration owns (type, properties, required, additionalProperties). schemaForClassTyped() throws Exception\JsonSchemaException for a class-typed parameter whose class cannot be instantiated — Hydrator accepts only an existing instance there, so an expanded object schema would describe input it rejects; UploadedFileInterface never reaches it, being described as {type: string, format: binary} directly. forType() throws the same exception for any builtin outside Hydrator::SUPPORTED_BUILTIN_TYPES (null, true, false, object, callable), which no request value could satisfy. schemaForScalar() merges each rule’s keywords onto that same non-null type schema and only then widens nullability once over the result — forType() itself stays a separate, eagerly-widened probe of one declared type’s own schema, with no rule of its own to merge — and is the one place mixed’s (and an untyped parameter’s) schema is cast to a real (object) [] once every constraint attribute has already been merged and nullability resolved, so it JSON-encodes as {} rather than the invalid [] a bare empty array would.

  • OpenApi\OpenApiAccess — whether this process serves /openapi.json//openapi. fromConfig() matches OPENAPI_ENVIRONMENTS (comma-separated, case- and space-insensitive) against the raw APP_ENV, treating an absent one as production; enabled()/disabled() decide outright. Resolved per request rather than at route registration, so a compiled cache cannot bake the decision.

  • OpenApi\OpenApiDocumentProvider — document(): array, the one source of the served document. Kernel constructs it for its own Router — with the effective global middleware order and the group map it dispatches against, both as class-strings — and registers it on every request scope alongside Router and OpenApiAccess. Development generates on every call; production generates once and holds it for the provider’s lifetime. A Kernel belongs to one worker thread, so the memo is per-thread state that dies with it, and a deployment — a new process, a new Router, a new provider — is the only invalidation there is: no cache entry, no expiry, no command.

  • Http\OpenApi\DocumentationController — serves both paths as ordinary discovered routes, #[Hidden] so they stay out of the document and #[Middleware('@openapi')] so #[AsOpenApiMiddleware] applies. Takes the document from OpenApi\OpenApiDocumentProvider and encodes it; the Swagger UI page comes from OpenApi\SwaggerUiPage. A disabled path answers with Kernel’s own unmatched-path 404, byte for byte.

  • OpenApiGenerator::generate() — builds the OpenAPI 3.1 document from a Router’s registered routes, deduplicating every DTO schema (request body, response, or nested at any depth) into components/schemas with $ref, and deriving the default response’s schema from the controller method’s declared return type. describeRequestBody() publishes a #[Body] DTO under application/json, application/x-www-form-urlencoded and multipart/form-data alike, since Dispatcher hydrates the same class whichever the client sent — unless the DTO’s compiled hydration plan names UploadedFileInterface anywhere (a field, a list element, or either inside a nested plan at any depth), in which case multipart/form-data is published alone: neither of the other two can carry a file. An UploadedFileInterface parameter is not request-body input and none is synthesized for it, so a route binding one publishes no requestBody at all. Repeatable #[Response] attributes add the other statuses a method can produce; one repeating the route’s own status is ignored, so it can never replace that schema-carrying entry with a bare description. describeAdditionalResponse() publishes each one’s description, plus — when the attribute names a body — that DTO’s component schema under the attribute’s mediaType (application/json by default), through the same schemaRefFor() deduplication a request body and the default response use; an attribute with no body stays description-only and its media type is not read, and a body naming something that is not a class throws Validation\Exception\JsonSchemaException rather than registering a component nothing backs. A #[Query]/path parameter’s own schema comes from Validation\JsonSchema::schemaForScalar(), which publishes a backed enum’s backing type plus the exact enum of its case values. A path parameter whose placeholder carries a route constraint stays a normal required path parameter with that same schema — PCRE is never mapped onto JSON Schema pattern — and the Parameter Object itself gains x-kinetis-route-constraint: {dialect: "pcre2", fragment: <exact fragment>}; an unconstrained one carries no extension, and nothing is inferred from the fragment (no catch-all or slash flag). paginatedResponseSchema() describes a class return whose method carries #[PaginatedItem] — any response wrapper whose data is the item list — inline: the wrapper’s own JsonSchema::forClass() schema, nested classes still deduplicated, with data replaced by an array of the named class’s deduplicated schema. The wrapper itself never goes through schemaRefFor(), since one shared wrapper component would collapse two different routes’ different item types into one. Without the attribute the wrapper is an ordinary component, its bare array $data described as {type: array}. Security is composed from the same route table: the global middleware order becomes the document’s root security, a route’s own #[Middleware] list — @name references expanded through Http\Attributes\Middleware::expandGroups() — is composed onto it as AND, and an operation publishes its own security only where that differs from the root. #[OpenApiSecurity] on the method, else on the controller class, replaces that inference outright. A hidden route is discarded before any of it, so it contributes neither an operation nor a scheme.

  • OpenApi\SecurityDescriberInterface — openApiSecurity(): SecurityDescription, the one thing that makes a middleware class security-bearing, and the supported way to describe security. Static and pure: it is read without the middleware being constructed, and a subclass inherits it, which is how a thin #[AsMiddlewareGroup] member describes itself. OpenApi\SecurityDescription carries the raw scheme definitions and the requirements in disjunctive normal form — the outer list OR, the schemes inside one requirement object AND, each value that scheme’s scopes — with scheme() building the usual one-scheme description and TYPES naming the five type values OpenAPI 3.1 defines.

  • OpenApi\SecurityComposition — @internal, the generator’s own composition machinery rather than an extension point. describersIn() filters a pipeline to the describing classes in pipeline order; compose() is the Cartesian product of their alternatives, unioning the scopes of a scheme two of them name, canonicalizing every requirement object (scheme names sorted, scopes sorted and deduplicated) and collapsing alternatives that state the same requirement; schemes() is what reaches components/securitySchemes, beside components/schemas rather than in place of it. publish() hands back a requirement object with no string key — the anonymous alternative among them — as a stdClass, so it encodes as {} rather than as the [] a PHP array would produce. It validates the PHPDoc-only container shapes it iterates before use: OpenApi\Exception\OpenApiSecurityException refuses a provider that does not implement the interface, a nameless scheme or a definition that is not an array, a type outside OpenAPI 3.1’s five, requirements that are not a non-empty list of requirement objects, a requirement naming an undeclared scheme or holding anything but scope strings, and two providers declaring one scheme name differently.

Kinetis\Console

  • Attributes\Command — TARGET_METHOD, {name, description, bootstrap}. Discovered by CommandRegistry::register() the same way Router/McpRegistry discover their own attributes. bootstrap: false makes bin/kinetis skip the whole package-then-application bootstrap chain (every installed package’s own PackageBootstrapInterface::register() as well as the project’s bootstrap.php) and the transaction-guard hook before dispatch — for commands that only operate on the project’s static shape and must run without the configuration any of those registrations might demand. The command itself is always looked up from the discovered/cached command registry regardless (bin/kinetis needs the command’s own definition to read this flag off it before it can act on it at all), and the discovered/cached EventListenerRegistry and plugin-discovery data always bind to AppScope; bin/kinetis never constructs or binds a Router at all.

  • CommandRegistry — validates each #[Command] method’s signature at registration time (zero parameters, or exactly one parameter typed CommandArguments; anything else throws Exception\InvalidCommandException), and rejects a duplicate command name across two different registrations. commands(): list<CommandDefinition>, findCommand(string $name): ?CommandDefinition, toArray()/fromArray() for the AOT cache — fromArray() validates each entry’s exact six required fields via Cache\Exception\ArtifactValidation and re-checks the identical duplicate-name invariant register() enforces, throwing Cache\Exception\InvalidCacheArtifactException for anything missing, extra, wrong-typed, or a repeated name — never trusting that a hand-edited or otherwise corrupt artifact still holds an invariant toArray() itself would always have preserved. Http\Routing\Router::fromArray() does the equivalent for routes (its own $routesByConflictKey populated and checked the same way register() populates it), plus reclassifying any other failure Route’s own constructor raises while reconstructing one (an unrooted path, most commonly — a real application bug when it comes from source-declared attributes, but a corrupt cache artifact when it comes from replaying compiled data) as the same exception type.

  • CommandDiscovery::discover(Cache\DiscoveryContext $context, ?array $paths = null): CommandRegistry — builds a registry from every class found anywhere under a project’s own PSR-4 root(s), plus Kinetis\Console itself (read through the context), rather than an explicit registration file. $paths, or COMMAND_DISCOVERY_PATHS when omitted, restricts the project-side scan.

  • CommandArguments — injected by type into a command method, the same by-type special-casing ProgressReporter already gets for MCP tools. parse(array $argv) splits into positional values (get(int), all()) and --key=value/bare---flag options (option(string, ?string), hasOption(string)).

  • CommandDispatcher::run(CommandDefinition, list<string> $arguments): int — resolves the controller through the container and invokes it; no per-call reflection, since CommandRegistry::register() already validated the signature. The method’s own return value becomes the exit code (int used directly, void/null means 0).

  • BuildCommand — #[Command('build', bootstrap: false)] — cache pre-warming needs no application configuration (no database credentials in a CI pipeline). The one command that has to be found before it can be used to build anything, via bin/kinetis’s own lazy-generate-on-first-run bootstrap. Compiles the project, validates the whole result through Cache\BootSequence::assertReconstructable() (below), and only then calls CacheStore::write() — never consulting whatever artifact is already there, so a rebuild never depends on the state of the file it replaces. A failed compile, a section that will not reconstruct, or a failed write leaves that file exactly as it was and reports no success.

  • RoutesListCommand — #[Command('routes:list')]. A read-only introspection tool, not a caching mechanism: runs RouteDiscovery/GlobalMiddlewareDiscovery live (regardless of APP_ENV) and prints the result — never touches .kinetis-cache/. Columns are Method, Path, Where, Status, Controller, Middleware; Where holds one name: fragment line per constraint in placeholder order and Middleware one entry per line, each — when empty, and a route spans as many lines as its tallest list-valued cell. Cells pad by UTF-8 character count, computed with PCRE because the framework does not require ext-mbstring, or by byte count for text that is not valid UTF-8. Declares bootstrap: false and constructs its own throwaway AppScope running bootstrap.php once to read AppScope::middlewares(), since AppScope itself is never registered onto the RequestScope a command is dispatched through. $output (a resource, defaulting to STDOUT) is an appended constructor parameter for testability against php://memory — the same reason mcp:serve’s input/output streams are injectable — since a #[Command] method itself must stay parameter-free or take exactly one CommandArguments.

  • bin/kinetis — has no hardcoded verbs at all. In production, loads CommandCache (auto-generating it, via a full Compiler::compileProject(), on the first invocation that finds none); in development, builds the registry, listeners, plugin sections and package bootstrap list live through one Cache\DiscoveryContext, released before the command runs. Every name — the built-in build/routes:list, anything a package contributes (mcp:serve, queue:work), and the application’s own — is looked up in that same registry. One fresh RequestScope per invocation from AppScope::createRequestScope(), so every package request-scope initializer runs on it, as on every HTTP request’s — none for a #[Command(bootstrap: false)] command, which runs no package bootstrap. An uncaught exception is logged through the container’s LoggerInterface, dispatches Events\CommandFailed, and produces exit code 1 — decided before the dispatch, whose own failure is logged and contained; a missing or unknown command name lists every available command, one per line, and also exits 1. $scope->dispose() runs outside that try/catch, in its own contained block, and $app->dispose() after it in a second one — so the application scope is disposed even when the request scope’s disposal failed, closing whatever a package opened once for this process. A disposal failure never replaces an already-decided exit code — logged separately instead, or — for the application scope, whose logger the disposal has already released — written to STDERR as fixed text naming the command and the thrown class, never the throwable’s own message, which a released service’s destructor can fill with a broker URI and its credentials — except when the command itself completed with exit code 0, where either disposal failing on its own produces exit code 70 (EX_SOFTWARE) rather than a misleading plain success; see CLI.

  • Events\CommandFailed — {commandName, exception}, dispatched by bin/kinetis’s own top-level catch. See Events for the full catalog of framework/package-dispatched events.

Kinetis\Instrumentation

  • TelemetryInterface — the framework’s instrumentation vocabulary: started/ended hook pairs joined by an opaque token (the request through Kernel::handle(), route match, middleware, hydration, controller, response encoding, queries with a server-started pool boundary, transactions, concurrently() batches and tasks, events/listeners, MCP calls, queue push and jobs), plus phase() for pre-container lifecycle phases reported with explicit timestamps; jobPushMetadata() returns opaque string metadata a queue backend stores with the job and hands back through jobStarted() — the propagation channel that joins producer and consumer spans into one trace across processes. Internal to the framework and not a consumer extension point — Telemetry, NullTelemetry and kinetis/telemetry’s backend are its only implementors, and the hook set is a minor-release surface.

  • NullTelemetry — the no-op default backend.

  • Telemetry — the swappable holder every call site talks to, with a per-process global() accessor (a documented NoStaticPropertiesRule exemption, the FiberPool class of worker-lifetime infrastructure). AppScope::boot() binds it as the TelemetryInterface default so app code can inject it; kinetis/telemetry’s package bootstrap swap()s in the OTel backend. Measured no-op cost: ~90ns per hook pair, one to two microseconds per fully hooked dispatch. Also the framework’s one no-throw observer boundary: every hook call into the installed backend is caught — a void hook completes normally on failure, a token-returning hook falls back to null, jobPushMetadata() falls back to [], swap() is never guarded — so a broken backend can never replace a real controller/task/job outcome or duplicate a producer’s already-durable send. A caught failure is reported once via error_log(), naming only the hook, the backend’s class, and the exception’s class — never the exception’s message or the hook’s own arguments, either of which can carry SQL text, job metadata, or a credential. See Telemetry’s “A failing backend never changes what the application does” section.

Kinetis\Reflection

  • AttributeScope — the one place that decides where attributes are read from, shared by every registry that reflects a class for them: Http\Routing\Router, Console\CommandRegistry, Mcp\McpRegistry, Events\EventListenerRegistry, and (via Cache\DiscoveryContext’s class scan) Http\Middleware\GlobalMiddlewareDiscovery. reflect(class-string): ReflectionClass rejects an abstract class, interface, trait or enum with Exception\AttributeScopeException::notRegistrable(). declares(ReflectionMethod, class-string): bool and assertDeclares() answer whether the registered class declares a method itself — a trait method does, since PHP reports its declaring class as the using class; an inherited one does not, and registering it throws AttributeScopeException::inheritedMethod() naming both classes. isRegistrable(string): bool is the silent counterpart NamespaceScanner uses, so discovery skips what registration rejects rather than failing the application over an abstract base under a scanned namespace.

  • ParameterDefault — the one rule for what a derived plan may capture as a parameter’s default value, shared by Validation\Hydrator’s hydration plan and Http\Dispatcher’s HTTP binding plan. capture(ReflectionParameter, string $owner): mixed returns the declared default, or null when there is none (the pairing every plan writes alongside its own hasDefault). A plan is derived once and reused — memoized for a persistent worker’s lifetime, written verbatim into .kinetis-cache/compiled.php by kinetis build — so a captured default must be a value PHP would have rebuilt identically on every evaluation: scalars, null, arrays of those, and enum cases, which are process-wide singletons. Any other object, at the top level or nested inside an array default, throws Exception\UnsupportedDefaultValueException naming $owner (the DTO class, or Controller::method()) and the parameter. Enforced where the plan is derived, so a first live hydration, a route’s registration and an AOT build all refuse the same declaration; Cache\CacheStore’s own artifact-wide check keeps covering the sections a parameter default never reaches.

Kinetis\Linting

  • NoStaticPropertiesRule — a PHPStan rule flagging static property declarations, shipped under the main autoload for consumer projects to add to their own phpstan.neon.

  • NoBlockingIoRule — a PHPStan rule, identifier kinetis.blockingCall, flagging blocking sleep, socket, curl-wait, database-connection and child-process calls, and HTTP client construction or discovery that is not guaranteed a Revolt-backed transport. Registered by kinetis/skeleton’s phpstan.neon; categories, replacements and limitations in Concurrency.

Kinetis\Testing

  • TestClient — wraps a Kernel. Five request-building modes, each honest about the bytes it actually sends: get()/post()/put()/patch()/delete() build a PSR-7 request and dispatch it, with request(string $method, string $uri, array $body = [], array $headers = [], array $query = []) the general form the array-body verbs call — a body array is JSON-encoded with Content-Type: application/json set unless a JSON-shaped override (application/json, or an application/*+json structured suffix, parameters like ; charset=... allowed — MediaType::isJson() decides, the same classifier Dispatcher reads a typed body through) is given, and any other explicit Content-Type paired with an array $body throws InvalidArgumentException rather than silently sending JSON bytes under a mismatched header; query (on get() or request()) is merged onto the request URI’s own query component via UriInterface::withQuery() — never raw string concatenation, which would corrupt a URI already carrying a #fragment — with getQueryParams() parsed back out of that same, now-authoritative query string so the two always agree. Every Content-Type this class inspects or sets is found under any letter-case of the header name and classified through MediaType above; two differently-cased header keys naming Content-Type with two different values throw rather than silently picking one, and two agreeing on the same value are collapsed to exactly one canonical entry rather than left as two — Nyholm’s own header storage treats two differently-cased keys as one repeated header and combines their values into a single comma-joined field, so leaving both in place would corrupt even a same-value “harmless” duplicate. This resolution runs regardless of whether the current call has a body, so a header-only conflict on get()/delete() (or request() with an empty array $body) is caught the same way a body-carrying call’s is. postForm(string $uri, array $form, array $headers = [])/putForm()/patchForm() send a genuine application/x-www-form-urlencoded body — the raw bytes are exactly http_build_query($form), and getParsedBody() is that same string parsed back with parse_str(), not $form itself (every scalar becomes a string, a null value is omitted entirely) — the actual shape a real form post arrives with, unlike the JSON verbs, which leave getParsedBody() null; an explicit Content-Type override must itself be form-urlencoded-shaped, or it throws the same way request()’s own override validation does. raw(string $method, string $uri, string $body, array $headers = []) sends a plain string body exactly as given, with no encoding inferred from it and getParsedBody() left null — for a webhook payload, binary content, or anything none of the other modes cover. A Cookie header given to any of the methods above is parsed into getCookieParams() as well, so a request built here carries its cookies in both places — what every runtime adapter delivers, and the only place a consumer reading cookies looks. send(ServerRequestInterface $request) is the direct escape hatch every other method here is, underneath, a convenience for — dispatches a fully hand-built PSR-7 request as given, the only route to a multipart/uploaded-file request, since this class never guesses a multipart boundary from a plain array. Every method returns a TestResponse.

  • TestResponse — the response with assertions attached (assertStatus/assertOk/assertJson/assertJsonPath/assertValidationError, …), each returning $this for chaining. Implements ResponseInterface itself and delegates, so it passes anywhere plain PSR-7 is expected. Failure messages include the response body; body() rewinds before reading, so the body can be read repeatedly.

  • TestApplication — boots a real application from a project root: live discovery through one Cache\DiscoveryContext per boot (routes, middleware, listeners, plugin sections, package bootstraps), the package-then-app bootstrap chain, a booted AppScope, a real Kernel. boot(string $projectRoot, array $configOverrides = [], ?callable $beforeBoot = null) merges overrides over the environment — including APP_ENV, registered as the container’s AppEnvironment from the merged config, since AppScope::boot()’s own default reads getenv() and would never see the override. Delegates to BootSequence::run() (below) for the plugin/listener bind and the bootstrap chain, the same seam every other framework-managed entry point uses, so a test can never see different precedence than production does. $beforeBoot runs after that — after the application’s own bootstrap.php and before boot() locks the container — the only window in which a test double replaces a binding the application made, EventListenerRegistry included. withRouter() builds from an explicit route table instead of discovery. A failure anywhere past the scope’s construction — discovery, a package bootstrap, $beforeBoot, boot() itself — disposes the scope before propagating, so a resource an earlier step opened is closed rather than abandoned; the disposal’s own failure is swallowed, the boot failure being the outcome. dispose() disposes the booted application, and is idempotent because AppScope::dispose() is. No PHPUnit dependency.

  • ApplicationTestCase — the PHPUnit base class over TestApplication: boots per test via #[Before] (so it runs ahead of any trait-declared hook in the concrete class, kinetis/persistence’s isolation traits included), exposing $client/$app/$application. Override projectRoot() (required), configOverrides(), and registerTestDoubles(AppScope $app, Config $config) for services a test should not reach. A #[After] hook disposes that test’s application, guarded by isset() so a boot failure is reported as itself rather than as an uninitialized-property error during teardown — see Testing.

  • FreePort::reserve() — a TCP port nothing is listening on, from the kernel (bind to 0, read back, release), for a test that spawns its own fixture server instead of hard-coding a port two suites can collide on.

  • LoopLiveness::turnedDuring(callable $operation, float $sentinelSeconds = 0.02): bool — whether a Timer::delay() sentinel run beside $operation in concurrently() resumed while the operation was in flight; throws Exception\LoopLivenessInconclusiveException when the operation finished inside the interval. Semantics in Testing.

Kinetis\Testing\Runtime

The runtime adapter conformance suite — see Testing.

  • RuntimeAdapterConformanceTestCase — abstract PHPUnit base class holding every behavior all adapters must agree on, one final test method each: request line and query (including a numeric parameter name’s int-key coercion, pinned as the shared outcome, and repeated parameters read the way parse_str() reads them); request identity — the URI authority matching the Host header the client sent, with and without a port, the scheme the environment serves, X-Forwarded-Proto deciding it only from a trusted edge and ignored entirely from a directly reachable client (an environment no plaintext request can reach refuses a forwarded http outright instead, before the handler), the origin-form request target byte for byte, the protocol version; a single and a repeated header (the repeat arrives comma-joined everywhere), a numeric header name (survived or dropped, per the driver, never reshaped), cookies into both the Cookie header and getCookieParams() (order asserted where the environment keeps it), REMOTE_ADDR, url-encoded and multipart bodies (POST, PUT and PATCH alike), nested and repeated multipart fields and files nesting the way PHP nests them, a parsed url-encoded or multipart body’s raw bytes left whole, JSON left unparsed, the declared Content-Length delivered, a 1 MiB raw body whole, binary and empty and "0" bodies; the form-complexity contract — one input variable, one nesting level, one file and one part past Http\Form\FormLimits, each with a security-significant field placed beyond the edge, answered with a 413 and no handler run; response status/headers, a comma inside one header value, two Set-Cookie as two cookies, a binary response body, streaming delivered — timed on the wire, so a proxy that holds the stream until the end fails it — or refused, per the driver’s declaration, and a body the environment can’t parse answered with a 400 carrying RuntimeAdapterInterface::MALFORMED_BODY_MESSAGE and no handler run. driver() is the one abstract method; assertMalformedBodyResponse(WireResponse) and assertOverLimitFormResponse(WireResponse) are public and static, so an adapter’s own tests hold environment-specific inputs to the same 400/413 contracts.

  • RuntimeAdapterDriver — what an adapter provides: dispatch(WireRequest, ResponseSpec): Outcome, plus the environment-decided facts expectedClientIp(), supportsStreaming(), expectedScheme(), preservesNumericHeaderNames(), preservesCookieOrder(), trustsTheConnectingClient(), supportsPlaintextRequests() — every one declared by the driver and asserted in both directions, never used to skip. A malformed or over-limit body needs no declaration: every runtime delivers its raw bytes to the one Http\Middleware\RequestBodyMiddleware, so the refusal and the ceilings are identical everywhere and the suite builds those requests itself. Neither does a parsed form body’s raw bytes: parsing reads a copy of the staged body, so the suite asserts getBody() is the request byte for byte on every adapter.

  • WireRequest — method, path, query string, headers as a list of pairs (repeats preserved), cookies as name=value strings, raw body. json() is the JSON-body shorthand.

  • ResponseSpec — what the handler answers with, as data: status, headers, Set-Cookie values, body, or streamChunks for a StreamedResponse with streamDelayMs between them (the emitter closes any output buffer first, then writes and flushes each chunk). The constructor takes all six with no defaults — a spec is compared field by field across a process boundary, and a default is a value the test never wrote — with of(), streaming() and json() as the named ways to build one. toResponse() is the one place a spec becomes PSR-7, shared by every driver; asHandler() wraps it as a callable that also captures the observed request; toArray()/fromArray() carry it across a process boundary. fromArray() requires every field, checks every type, and refuses invalid base64, throwing MalformedResponseSpecException.

  • ObservedRequest — the PSR-7 request the handler received, flattened: method, path, query and params, headers, cookie params, REMOTE_ADDR, parsed body, uploaded files (the dotted path each nests at, filename, media type, PHP upload error code, contents), raw body, and the request’s identity — URI scheme, host, optional port, protocol version, request target. Files are flattened by path rather than by name because getUploadedFiles() is a tree: docs[] twice nests two files under one key, and only the path tells them apart; the error code is carried because an empty file control is UPLOAD_ERR_NO_FILE on every runtime and a file that is not UPLOAD_ERR_OK has no readable stream at all. fromArray() is exact — every field required, every type checked, base64 decoded strictly — because a coerced field or a leniently decoded body would report a broken fixture or driver as a passing conformance run; MalformedObservationException is what it throws. fromServerRequest(), header() (case-insensitive), toArray().

  • WireResponse — the response as the environment received it: status, headers as pairs, Set-Cookie values separately, body bytes, and bodyArrivalSpanSeconds — the time between the first and last body byte reaching the client, the evidence a stream was delivered as written rather than buffered (null for a driver with no wire to time). header() is case-insensitive.

  • AdapterRejection / Outcome — an adapter that refused to respond at all (exception class and message), and the pair a driver reports: the observed request (null when the handler never ran) and either a WireResponse or a rejection.

Request lifecycle, in order

  1. A RuntimeAdapterInterface receives the request and converts it to PSR-7.

  2. Kernel::handle() runs the global MiddlewarePipeline.

  3. Inside it: AppScope::createRequestScope(), which runs every initializer registered through AppScope::onRequestScopeCreated() — with kinetis/database-bridge installed, the lazy TransactionGuard binding whose first resolution registers rollbackDangling() on the scope’s disposal, and with kinetis/orm also installed, the lazy EntityManagerRegistry binding whose first resolution registers close() there — the request’s EntityManager is its default-connection manager.

  4. Router::match() resolves a Route, or throws RouteNotFoundException/MethodNotAllowedException (→ 404/405).

  5. The route’s #[Middleware] pipeline runs, wrapping Dispatcher::dispatch().

  6. Dispatcher resolves parameters (via a compiled plan if HttpCache is present, live reflection otherwise), invokes the controller.

  7. RequestScope::dispose() runs, contained so a cleanup failure can’t replace the outcome dispatch already decided (see Appendix: Middleware’s “A disposal failure never masks the real outcome”); gc_collect_cycles() runs if the adapter is persistent.

A Runtime\StreamableResponseInterface response defers step 7: Kernel returns a StreamedResponse wrapping it, whose emitter runs the original emitter and then releases the scope and collects. abandon() settles the same wrapper without writing a body — what an adapter that cannot stream calls. Kernel settles it itself, before handle() answers, whenever the response leaving the global pipeline is neither that wrapper nor a with* clone of it: a buffered reply, a stream of the middleware’s own, or a failure on its way out. A wrapper that reached none of those is released at the top of the next handle(), ahead of step 2 — global middleware can answer a request without reaching step 3 at all — with a warning naming the method and path.

See also