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()beforeboot(); 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” guaranteeRequestScopemakes, 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\ErrorLogLoggerin development /NullLoggerin production,Kinetis\Config\Config→Config::fromEnvironment(),Http\Form\FormLimitsandHttp\TrustedProxies→fromConfig()on thatConfig(an entry point registers its own before the bootstrap chain runs, so a bootstrap can replace either andboot()leaves what it finds),Psr\SimpleCache\CacheInterface→Kinetis\SimpleCache\RedisSimpleCache::fromConfig()(class_exists()-gated against the optionalkinetis/cache-redispackage) when Redis is configured, elseNullSimpleCache— configured but not installed bindsUnavailableSimpleCache, which throws on use rather than at boot; a default cache implementingSimpleCache\DisposableCacheInterfacehas itsdispose()registered ononDispose(), and a cache the application bound itself is never registered. ResolvingRequestScope::classthroughAppScope— directly, or as a constructor dependency of anything elseAppScoperesolves — throwsException\DisconnectedRequestScopeExceptioninstead of autowiring a disconnected, unbootedRequestScope; there is no single worker-lifetimeRequestScopeto 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 afterboot(), 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 seconddispose()returns, and binding, resolution,boot(), request-scope creation and furtheronDispose()registration are all refused afterwards. See Appendix: Container Lifecycle.RequestScope— the per-request container, created byAppScope::createRequestScope(), which also registers the scope onto itself (RequestScope::classresolves 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’sQueueWorker/SyncQueue,kinetis/mcp’sScopedMessageHandler,bin/kinetis) — disposes it outside anyfinallythat could let that rethrown failure silently replace an already-decided outcome; see each owner’s own docs page for its exact precedence rule. Delegates toAppScopefor explicitly registered ids only; autowires anything else, discarded ondispose().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’sextra.kinetisbootstrap class implements (seeKinetis\Cache’sDiscoveryContext/RoutesFile::loadBootstrap()below). Runs before the application’s ownbootstrap.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 initializercreateRequestScope()runs on every scope it creates, in registration order, before returning it; allowed only beforeboot()(Exception\ContainerExceptionafter). If one throws, the scope is disposed — running what earlier initializers registered on it — and that failure propagates.Kernel,bin/kinetis,kinetis/mcp’sScopedMessageHandler, andkinetis/queue’sQueueWorker/SyncQueueall take their scopes fromcreateRequestScope(), so every package initializer runs on each; a#[Command(bootstrap: false)]command runs no package bootstrap and gets none.kinetis/database-bridgebindsTransactionGuard, and withkinetis/orminstalledEntityManager, through one — see Appendix: Container Lifecycle’s “Request-scope initializers”.Autowire— reflection-based constructor injection, used by both scopes.isAvailable(ContainerInterface, string): boolis the internal predicate that separates an absent dependency from a broken one;Http\Dispatcherasks 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 anOpenApi\OpenApiAccess(fromexposeOpenApi, elseOPENAPI_ENVIRONMENTSagainstAPP_ENV) and builds anOpenApi\OpenApiDocumentProviderfor its ownRouter, registering both — plus thatRouter— on each request’s scope forHttp\OpenApi\DocumentationController— it serves no endpoint of its own: it creates aRequestScope, matches a route, dispatches. That scope is disposed beforehandle()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 responsehandle()ends up answering with — see the request lifecycle below, and Appendix: Container Lifecycle./openapi.json//openapiare ordinary routes onHttp\OpenApi\DocumentationController, and/mcpis an ordinary routekinetis/mcpcontributes — 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 a400or415, or by validation, never constructs the controller. A binding failure leaves this class as theValidation\Exception\ValidationExceptionit is — route and application middleware get to catch it, and whatever reachesMiddleware\ExceptionHandlerMiddlewareis rendered there; a400and a415stay here, since neither carries per-field structure. Six sources, checked in order: a parameter typedServerRequestInterface/UploadedFileInterface(the raw request/an uploaded file),#[Body]DTO (json_decode()on the raw body forapplication/jsonand anyapplication/*+jsonsubtype,getParsedBody()formultipart/form-data/application/x-www-form-urlencoded; a nonblank body under any other media type, or under none, is a415raised before hydration asException\UnsupportedBodyMediaTypeExceptionand mapped here, never echoing the header received),#[Query]value, same-named path parameter, then any remaining class-typed parameter from the request container (plan sourcecontainer) — 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 aContainer\Exception\CircularDependencyExceptionincluded. An absent dependency with neither a default nor a nullable type is reported against the parameter, keeping the container’s own account asprevious. Anything left over uses its default or throwsException\UnresolvableParameterException, whose message names every source.#[Body('root')]hydrates the DTO from one top-level member of the decoded, upload-merged document (plan fieldbodyRoot) throughValidation\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 everyUPLOAD_ERR_NO_FILEleaf (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 witharray_values(), sophotos[]sent as file, empty, file binds two files at0and1. 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: anUploadedFileInterfaceparameter by its own top-level name, and a form-encoded#[Body]DTO throughmergeUploads(), which folds files into the parsed text recursively — two arrays merge, anything else leaves the text in place, a file-only key is added — soprofile[name]andprofile[avatar]hydrate one nested DTO. A JSON body never consumes the files. What each file then is — arrived, failed, or no file at all — isValidation\Hydrator::resolveUploadedFile()’s answer, so a direct parameter’s ownConstraintattributes run exactly where a DTO field’s do. The PSR-7 request itself is untouched; normalization is what binding reads.derivePlan()also rejects, atRouting\Router::register()time, a#[Query]/path parameter declaring a union or intersection type — one request value has one shape, and theT|Absentpresence 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 asenumClasswith the backing type asscalarType, andresolveScalarFromPlan()binds it throughValidation\Hydrator::resolveEnumValue()— the same method a DTO’s enum field takes — so malformed text is the ordinary type violation and text naming no case anenum_caseone, never aTypeErrorat invocation. Every other class there isException\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(exactlyapplication/json, or anapplication/subtype carrying RFC 6839’s+jsonsuffix — sotext/jsonandapplication/+jsonare neither),isMultipart(string): bool(anymultipart/*, which is what a part may never itself declare — seeHttp\Form\MultipartEnvelope), and theFORM_URLENCODED/MULTIPART_FORM_DATA/JSONconstants naming those literals. The one place aContent-Typeheader value is classified:Dispatcher,Http\Form\FormBodyandTesting\TestClientall 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\MultipartEnvelopeholds the whole section to one grammar and takes theboundaryfrom 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 currentRequestScope(seeKinetis\Containerabove), 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 6750Authorization: <scheme> <token68>grammar is implemented — used by bothkinetis/auth’sBearerAuthMiddlewareandkinetis/auth-jwt’sJwtAuthMiddlewarewith'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 credentialToken.parse()readsgetHeader('Authorization')(nevergetHeaderLine(), which comma-joins multiple header lines into one ambiguous string) and requires exactly one value before handing it toparseValue(). The wire scheme is compared case-insensitively. Returnsnull, never throws, for any parse failure — a caller treatsnullidentically to an unknown/invalid token.$expectedSchemeis trusted configuration rather than input: it must be an RFC 9110 auth-scheme token, checked before the header is read, and throwsInvalidArgumentExceptionotherwise.MiddlewarePipeline/CallableRequestHandler— PSR-15 (Psr\Http\Server\MiddlewareInterface/RequestHandlerInterface) composition.Kernelbuilds two: a global one (fromAppScope::middlewares()plus$discoveredGlobalMiddleware, wraps the whole request) and a per-route one (from a matchedRoute’s#[Middleware]attributes, wraps justDispatcher::dispatch()).Attributes\Middleware::expandGroups()replaces each@namereference 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 andOpenApi\OpenApiGeneratorread;assertMiddlewareGroupsExist()validates every reference across every registered route once, in the constructor, throwingMiddleware\Exception\UnknownMiddlewareGroupExceptionfor an undeclared group rather than failing on whichever request first hits that route.Middleware\ExceptionHandlerMiddleware— always global middleware, insideSecurityHeadersMiddlewareand a registeredCorsMiddleware. A singlecatch (Throwable $e)— not a siblingcatch (HttpStatusExceptionInterface) {} catch (Throwable) {}pair, since PHP never lets a later siblingcatchsee an exception thrown from inside an earlier one’s own body. One implementingException\HttpStatusExceptionInterfaceis mapped viatryHttpStatusResponse(), 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 uncaughtThrowabletakes, logged with the original exception plus aMiddleware\Exception\HttpStatusMappingExceptiondescribing the mapping failure as extra context. A validHttpStatusExceptionInterface(a declared4xxor5xxalike) 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 brokenHttpStatusExceptionInterface) becomes a 500, logged via the container’sLoggerInterfacethroughLogging\SafeLogger(see below): a generic body in production, the exception’s class/message/file:linealongside it in development, JSON-encoded withJSON_INVALID_UTF8_SUBSTITUTEso a message that is not valid UTF-8 still encodes rather than throwing from inside this same catch handler. Constructor-injectsAppEnvironment(defaulting toProduction, so a directly-constructed instance never leaks detail by accident). AValidation\Exception\ValidationExceptionis recognized before that, inside the same single catch, and rendered by theValidationExceptionRendererInterfacethe constructor takes — an immutable default instance, so an application binding the interface beforeContainer\AppScope::boot()wins throughContainer\Autowirewith nothing registered either way.tryValidationResponse()wraps the call the same waytryHttpStatusResponse()wraps its own: a renderer that throws falls through to the identical generic-500 path, logged with the original validation failure plus thatThrowableunderrenderFailure. 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-suppliedLoggerInterfacethat 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 fromAppScopeand 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) catchesValidationExceptionin route middleware instead. See Middleware.ProblemDetailsValidationExceptionRenderer— the default implementation, stateless and immutable: RFC 9457 problem details at 422 withContent-Type: application/problem+json, carryingtype: about:blank(Kinetis owns no resolvable problem-type URI, and 422 already supplies the semantics), IANA’s registeredtitle: Unprocessable Content, the matchingstatus, a fixeddetail, and an orderederrorsextension of serializedValidation\Violationobjects. Encoded withJSON_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 byExceptionHandlerMiddleware’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 extendThrowableitself — nothing about implementing this interface requires it, only reachingExceptionHandlerMiddlewareby actually being thrown does, and this interface has no say over that;ExceptionHandlerMiddlewareexpresses the localThrowable&HttpStatusExceptionInterfaceintersection where it actually needs it instead. The seam a satellite package’s own exception implements to declare its HTTP status withoutExceptionHandlerMiddlewareever 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 uncaughtThrowablegets.kinetis/authorization’sAuthorizationExceptionimplements it — see Appendix: Satellite Packages.Middleware\Exception\HttpStatusMappingException— a brokenHttpStatusExceptionInterfaceimplementation’s own failure, as logged context alongside the original exception, never returned to a client.invalidStatus()for a status outside 400-599;threw()forhttpStatus()/response construction itself throwing, chaining the real cause viagetPrevious().Middleware\SecurityHeadersMiddleware— always the outermost global middleware, outsideExceptionHandlerMiddleware, so its headers reach the500that handler produces. Constructor-injectsConfigand reads every value once, stripping CR/LF (which would both throw insidewithHeader()and be a header injection), so no configured value can makeprocess()fail. SendsX-Content-Type-Options: nosniffalways, plusX-Frame-Options(defaultDENY) andReferrer-Policy(defaultstrict-origin-when-cross-origin), each overridable or disabled withoffviaSECURITY_FRAME_OPTIONS/SECURITY_REFERRER_POLICY.Content-Security-Policy(SECURITY_CSP),Permissions-Policy(SECURITY_PERMISSIONS_POLICY), HSTS (SECURITY_HSTS_MAX_AGEplusSECURITY_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 thewindow.openerlink 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}inSECURITY_CSPbecomes one fresh base64 nonce per request, passed downstream as the request attributeNONCE_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 afterExceptionHandlerMiddleware(seeGlobalMiddlewareOrder::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 oneHttp\Form\FormLimitsinstance the entry point bound. Rejects a request whose declaredContent-Lengthexceeds the byte ceiling with a413before the body is read at all; then stages the body — read once, incrementally, counted, into a seekable temporary stream viaHttp\Form\StagedRequestBody— rewinds it, and for the two form media types parses it intogetParsedBody()/getUploadedFiles()throughHttp\Form\FormBodyunder the same instance’s complexity ceilings. Over any ceiling is a413naming the limit and the handler never runs; a body that cannot be parsed is a400carrying the fixedRuntime\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), plusKinetis\Httpitself, sorted by priority (descending, ties broken by class name).$paths, orMIDDLEWARE_DISCOVERY_PATHSwhen 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);groupsis 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 containsGlobalMiddlewareDiscovery::OPENAPI_GROUP(openapi), holding theopenApibucket, empty included:DocumentationControllerreferences 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:SecurityHeadersMiddlewarefirst, thenCorsMiddlewareonce when$explicitnames it, thenExceptionHandlerMiddleware,RequestBodyMiddleware, andmerge($explicit, $discovered)withoutCorsMiddleware.merge(array $explicit, array $discovered): list<class-string>is the plain explicit-then-discovered precedence rule with no fixed prepended classes, factored out soKernel’sopenapigroup folding can reuse the identical rule without inheritingSecurityHeadersMiddleware/ExceptionHandlerMiddleware/RequestBodyMiddleware, which neither needs.KernelandConsole\RoutesListCommandeach 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 ownSimpleCache\AtomicCounterInterface, which construction requires it to implement —NullSimpleCacheand any other non-atomic cache both throwMiddleware\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-emptypolicyId, 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 consultsX-Forwarded-ForwhenREMOTE_ADDRmatches one of the constructor’strustedProxiesCIDRs (empty by default —REMOTE_ADDRalways used otherwise), walking the chain from the end backward past any further trusted hops. Construction rejects a blankpolicyId, amaxAttemptsorwindowSecondsbelow 1, and anytrustedProxiesentry that is not an address or CIDR range with a prefix length in its family’s range, viaMiddleware\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 setX-Forwarded-For. Notfinal: since#[Middleware]carries only a class-string, the policy an application registers is a thin subclass supplying its own ID and limits.identifierFor()isprotected, notprivate, 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 forRetry-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-Remainingare 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 fortime()— for deterministic tests only, never a real caller.Middleware\AuthenticatedRateLimitMiddleware— extendsRateLimitMiddleware, taking the samepolicyIdand overridingidentifierFor()to key byCurrentUserInterface::id()when one is already resolved on the currentRequestScope, falling back to IP otherwise. Route middleware only, registered after the auth middleware that resolvesCurrentUserInterface— never global, and never bound directly onAppScope(the sameContainer\Exception\DisconnectedRequestScopeExceptionhazardJwtAuthMiddlewaredocuments in JWT Authentication).Middleware\CorsMiddleware— opt-in, global only (a route-level registration would never see a preflight to an unmatched route).GlobalMiddlewareOrderruns a registered instance once, directly insideSecurityHeadersMiddlewareand outsideExceptionHandlerMiddlewareandRequestBodyMiddleware, so framework-built error and body-limit responses carry its headers; the constructor validatesallowedMethods/allowedHeaders/exposedHeadersas header values (InvalidArgumentException) soprocess()cannot throw outside the exception boundary.allowedOrigins/allowedMethods/allowedHeaders/exposedHeaders/allowCredentials/maxAge/allowedOriginPatternsconstructor config;allowedHeaders: ['*']reflects the preflight’s requested headers,allowedOriginPatternsmatches origins by PCRE pattern, compiled at construction (an uncompilable one throwsInvalidArgumentException) and required to match the wholeOrigin— 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/allowedOriginPatternsnon-empty) — true even for a literalallowedOrigins: ['*'], whose header value never changes but whose presence still depends on whetherOriginwas sent, which is whatVary: Originmarks on both the allowed and the disallowed/absent branch.isPreflight()-true responses additionally carryVary: Access-Control-Request-Method(partitioning a preflight from the ordinaryOPTIONSresponse routing itself may produce at the same URI), plusVary: Access-Control-Request-Headersexactly whenallowedHeaders === ['*']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 existingVary: *is left untouched rather than appended to. See Appendix: Middleware’s “Response caching andVary” 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 throughKinetis\Reflection\AttributeScopefirst: 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 fromRouteDiscovery::discover()/Kinetis\Cache\Compiler::compileProject()— a route’s storedpathTemplatecomposes 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.Routethen normalizes every path it is given to one canonical form — leading slash, no trailing one,/unchanged — in its own constructor, so it holds forfromArray()too;/usersand/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 viaRoute::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\InvalidRoutePathExceptionfor all three). There is no inline constraint syntax; a route narrows a placeholder through itswheremap instead (below). This reachesfromArray()too, since it runs through the identical constructor.Route constraints:
Route::__construct()takes a finalarray $where = [](placeholder name => delimiterless PCRE2 fragment) and exposes the validated map aspublic 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\nis ordinary regex syntax). Failures areInvalidRoutePathException::invalidConstraint()(a non-string key, or a value that is not a non-empty control-free string),unknownConstraintPlaceholder(),uncontainedConstraint(),acceptingConstraint()anduncompilableConstraint(). 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\Qor#comment swallowing that), or a(?J)group reusing a placeholder’s name);acceptingConstraint()when it contains(*ACCEPTand 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, everyname: fragmentandpreg_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 byte0x01delimiters, which neither a template nor a fragment can contain.matchPath()returns the captures on1andnullon0;false, any PCRE error rather than a result, throwsRouting\Exception\RouteMatchingExceptionnaming the template andpreg_last_error_msg()but not the request path, whichRouter::match()propagates rather than reporting a miss, andKernelanswers as a500. PHP’spcre.backtrack_limit,pcre.recursion_limitand JIT stack are the only bounds. A captured value may contain/—.*capturesa/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, or405when another method’s route admits the path), while a binding or validation failure after the match stays a422. See Appendix: Routing & Validation’s “Route constraints”.register()’s own reflection loop walks everyRouteAttribute-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
controllerClassalready registered is a safe no-op on a later call only when its$globalMiddlewarecontext matches — tracked viaRouter::$registrationContexts(array<class-string, string|null>), keyed by a canonical, order-preserving signature of the middleware list (implode("\0", $globalMiddleware)). This is the invariantRouteDiscoveryrelies 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’sextra.kinetisroot, or the framework root and project root being the same repository when developing Kinetis itself) — every such pass shares one project-wide$globalMiddlewarelist, so their signatures always agree. A repeat call carrying a different context throwsException\ConflictingRegistrationContextExceptioninstead of silently keeping the routes built under the first one:Routeris public API, and a second call with a different context is a real mistake, not a harmless rescan.fromArray()recordsnullfor every class it reconstructs — a compiled route’s own data carries no memory of the$globalMiddlewarelist its controller was registered under, so there is nothing to compare a later liveregister()call against, andnullcan 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 owntcharset — 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(), throwingRouting\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$routesbyRoute::compareForMatching()— a stable, purely content-based comparator, re-applied after every commit in bothregister()andfromArray(), 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 viaRoute::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/readmeand/files/{id}/metaboth outrank a.*catch-all/files/{path}. Segments themselves come fromRoute::urlSegmentGroups(), which regroupsRoute::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 aRouterfrom every class found anywhere under a project’s own PSR-4 root(s), plusKinetis\Httpitself, mirroringKinetis\Console\CommandDiscovery/Kinetis\Mcp\McpDiscovery.discover(Cache\DiscoveryContext $context, ?array $paths = null, array $globalMiddleware = [])—$paths, or theROUTE_DISCOVERY_PATHSenv 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 implementsAttributes\RouteAttribute—httpMethod(),path(),status(), andwhere(): array<string,string>, returning the constraint map as declared;Routing\Routevalidates 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 toRoute’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@namegroup reference is silently skipped —class_exists()already returnsfalsefor one, needing no special case), andjoinAll(string $path, self ...$prefixesOuterToInner): stringfolds them around a path, the first prefix contributing the leftmost segment. Neither method validates rootedness itself — that stays inRouter::rootedPrefixes(), which is what letsdeclaredOn()remain a pure attribute reader with no dependency onHttp\Routing’s own exception type.PaginatedItem(class-string $itemClass)(TARGET_METHOD) names the item class held in thedatalist of whatever response wrapper the route returns, purely forOpenApiGenerator::paginatedResponseSchema()— see below.OpenApiSecurity(class-string<OpenApi\SecurityDescriberInterface> ...$providers)(TARGET_CLASS|TARGET_METHOD) states one operation’s publishedsecurityinstead of the one its middleware would describe: providers are combined as AND, a method declaration replaces a class one, and no provider at all publishessecurity: []. 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}, bounded0-100, throwingInvalidArgumentExceptionoutside that range) are the opposite direction fromMiddleware— they live on the middleware class itself, not on a controller referencing one — and are whatMiddleware\GlobalMiddlewareDiscoverylooks 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_PREFIXbeing that@. Group membership alone never makes a class run — only a route referencing the group does.AsOpenApiMiddlewarereaches/openapi.json+/openapithrough the group mechanism — those are ordinary routes onHttp\OpenApi\DocumentationController, so its classes are published as the built-inopenapimiddleware group that controller references — see Middleware.AppScope::openApiMiddleware()is its explicit-registration counterpart, mirroringAppScope::middleware()./mcpis covered by an ordinarymcpgroupkinetis/mcp’s controller references — join it with#[AsMiddlewareGroup('mcp')], and register aCurrentUserInterface, which that group’s own final guard requires before anything is dispatched (see Model Context Protocol (MCP)).POST /broadcasting/authis covered the same way by abroadcastinggroupkinetis/broadcasting’s controller references; aCurrentUserInterfaceregistered 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 overNyholm\Psr7\Response, not distinctResponseInterfaceimplementations (unlikeStreamedResponse); each builds a plain response with the right headers/body already set.JsonResponse::create(mixed $data, int $status = 200, array $headers = [])usesJSON_THROW_ON_ERROR, fixesContent-Type: application/jsoncase-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.$downloadFilenameis treated as untrusted: written as an RFC 6266 quoted-string with\and"escaped, so a name cannot close the quoting and append a secondfilenameparameter; a non-ASCII name also travels as RFC 8187filename*=UTF-8''…beside an underscore-substituted ASCII fallback; a control character or an empty string throwsException\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 throughErrorResponse::create()too, the same helper a controller uses.ErrorResponse::create(int $status, string $message, array $headers = [])’s$messageis not necessarily framework-controlled — a satellite package’s ownHttpStatusExceptionInterfacemessage, say — so its JSON encoding carriesJSON_INVALID_UTF8_SUBSTITUTEalongsideJSON_THROW_ON_ERROR: malformed UTF-8 in$messagedegrades to a substitute character rather than an uncaughtJsonException, and the given$statusis 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_VARS512,MAX_FILE_PARTS16,MAX_NESTING_DEPTH8,MAX_MULTIPART_PARTS512,MAX_PART_HEADERS16,MAX_PART_HEADER_BYTES8192) plus one per-application byte ceiling. Afinal readonlyvalue object, not a static holder:fromConfig(#[SensitiveParameter] Config)readsMAX_BODY_SIZE(defaultDEFAULT_MAX_BODY_BYTES, 2 MiB) once and the constructor rejects anything below 1, so there is no livegetenv()read anywhere and one instance is built at the entry point and registered onAppScope, whereMiddleware\RequestBodyMiddlewareautowires it. The counts sit below PHP’s ownmax_input_vars/max_file_uploadsdefaults, so the contract is the edge a client meets.assertBodyWithinLimit(int $actualBytes, ?int $declaredBytes)checks both sizes;assertRawPairCount(),assertMultipartPartCount(),assertPartHeaderCount()andassertPartHeaderLength()are the pre-parse counts taken from raw bytes;assertNamesParseable(list<string> $names)is the preflight run immediately before everyFormPairs::parse()call in this namespace — it holds the raw names toMAX_INPUT_VARSand, throughFormFieldName::depth(), toMAX_NESTING_DEPTH, and then to this runtime’s ownmax_input_vars/max_input_nesting_levelwhen either sits below the contract, throwingsapiMayHaveTruncated()and naming the setting. That order matters becauseparse_str()reports neither refusal: a list pastmax_input_varscomes back shorter, and a name nested pastmax_input_nesting_levelis 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 throwsException\FormLimitExceededException(orHttp\Middleware\Exception\BodyTooLargeExceptionfor bytes), answered with a413whose message names a configured ceiling and nothing from the request.MultipartEnvelope::parts()— one bounded scan of a raw multipart body, and themultipart/form-datacontract 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 rootContent-Type’s parameter section is read whole, under the same grammar a part’s own headers meet, and has to nameboundaryexactly once (boundary=A; boundary=Bandboundary="A"junkareambiguousMultipartBoundary(), a header naming none at allnoMultipartBoundary()); a delimiter isCRLF--boundaryfollowed 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 isambiguousDelimiter()); a part’s bytes are the wire’s, soContent-Transfer-Encodingmay only be7bitorbinary; 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 nestedmultipart/*body; and its header lines must be complete lines — no obs-fold, no nameless line, no control character, and at most one each ofContent-Disposition,Content-TypeandContent-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 — withException\UnparseableFormBodyException.MultipartPart— one scanned part: its header lines in arrival order, theContent-Dispositionname/filename, itsContent-Type, and its raw body.nameisnullfor an unnamed part;filenamedistinguishes 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 matchingcategoryslug, and never apreviouschain. 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 a400carryingRuntime\RuntimeAdapterInterface::MALFORMED_BODY_MESSAGE.Exception\FormStagingException—couldNotOpenTempStream(),bodyReadStalled(),bodyWriteFailed(),couldNotCloseTempStream(): this worker failing rather than the client, so never a400/413; inside the Kernel it reachesExceptionHandlerMiddleware, 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 seekablephp://memorystream, 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.$openStreamis the seam that lets tests drive a stream that short-writes, refuses to write, or fails to close — none of whichphp://memorycan be made to do.MultipartFormBuilder—addField()/addFile()/build(), constructed with theFormLimitsits result is held to. BuildsgetParsedBody()andgetUploadedFiles()throughFormPairs—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 itsUploadedFile, so one rule shapes both structures. An empty file control — an empty part with an empty filename — is reported the way PHP’s$_FILESreports 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 formparse_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 — anddepth()reads the same structure the encoding writes (email1,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 reachesparse_str(), and the&contract it reaches it under.parse_str()splits onarg_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&;letsa=1;b=2;…past a count that saw one pair and then truncates it at that runtime’s ownmax_input_vars. So anything but exactly&isException\FormParserConfigurationExceptionbefore a body is parsed at all. The setting isPHP_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 routeException\FormStagingExceptiondoes rather than becoming a400/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=1a 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 wayparse_str()decodes them, soa%5Bb%5Dis measured as the two levels it builds.FormBody::apply(ServerRequestInterface $request, FormLimits $limits)— the one entry point that turns a staged body intogetParsedBody()/getUploadedFiles(), called byHttp\Middleware\RequestBodyMiddlewareand by nothing else: a request whose content type is neither form media type is returned untouched, url-encoded bodies go throughUrlEncodedForm, and multipart bodies throughMultipartEnvelope’s parts, built byMultipartFormBuilder. 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)readsTRUSTED_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 withException\InvalidTrustedProxyException— a range that silently never matched looks exactly like a correct one that is never reached.trusts(?string $ip)and the staticmatches(string $ip, string $range)doinet_pton()-based binary comparison, so IPv4 and IPv6 are handled uniformly;Middleware\RateLimitMiddlewarevalidates its own$trustedProxiesthroughunusableReason()— 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)answersnull— 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 throwsException\UntrustedForwardedHeaderExceptionwhen a trusted peer sends anything but exactlyhttporhttps, folded or repeated values included: there is no rule that picks the right answer out of two.clientAddress(?string $remoteAddr, string $forwardedFor)walks anX-Forwarded-Forchain 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 whatMiddleware\RateLimitMiddlewarekeys a bucket on. Neither method rewritesREMOTE_ADDR: the transport peer stays what actually connected.
Kinetis\Events¶
EventDispatcher— implementsPsr\EventDispatcher\EventDispatcherInterface. Never explicitly registered; autowired fresh per request throughRequestScope, constructor-injectingRequestScope,EventListenerRegistry, andListenerInvokerInterface.dispatch()stops at a listener oncePsr\EventDispatcher\StoppableEventInterface::isPropagationStopped()returnstrue. Checks the registry’s ownqueuedflag for each matched listener before resolving anything: a non-queued listener resolves throughRequestScopeand is called directly; a queued one is never constructed here at all — its class-string, method, and the event go straight toListenerInvokerInterface::invoke(), so only the invoker (notEventDispatcher) ever decides whether/when to construct it.Listener— aTARGET_METHODattribute,{priority: int = 50}(bounded0-100, throwingInvalidArgumentExceptionoutside 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 asRouter/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 everyregister()call that adds to it.listenersFor(class-string): list<array{class, method, priority, queued}>—queuedcomputed once viais_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 repeatedregister()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 withclass/methodfurther 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 throwsException\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. UnlikeRouter/McpRegistry/CommandRegistry, which every transport hands directly to whatever dispatches them,EventDispatcherconstructor-injects this and is itself autowired through the container — so an instance has to be$app->instance()’d explicitly beforeAppScope::boot()locks bindings. Every framework-managed entry point does this viaBootSequence::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), plusKinetis\Eventsitself, rather than an explicitbootstrap.phpregistration.$paths, orLISTENER_DISCOVERY_PATHSwhen 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 onEventListenerRegistry::register()’s own idempotency rather than a separate deduplication set here.ShouldQueue— a marker interface a listener implements to be invoked throughListenerInvokerInterfaceinstead of directly.ListenerInvokerInterface/SynchronousListenerInvoker— the seam aShouldQueuelistener’s invocation is routed through, taking the listener by class-string plus the dispatchingRequestScope, never a resolved instance — construction is entirely the invoker’s own decision.SynchronousListenerInvokerresolves it from the given scope and calls it inline;AppScope::boot()registers this as the default, only where nothing is bound yet.kinetis/queue’sQueuedListenerInvoker(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_CONNECTIONfor the default connection) — package bootstraps run beforeboot(), so the synchronous default applies exactly when no queue is configured.
Kinetis\Runtime¶
HttpStartup::run(string $entryPointDir)— the whole HTTP startup program, and everything apublic/index.phpcontains beyond the Composer autoloader. Resolves the project root from the calling directory viaProjectRoot, loads.envbeforeAppEnvironment::detect(), buildsConfigand binds it, then either discovers routes, middleware, listeners, plugin sections and package bootstraps live through oneCache\DiscoveryContext(development) or resolves them throughCache\BootSequence::resolveHttp()(production — the published artifact, or one fresh compile published as the artifactkinetis buildproduces). RegistersHttp\Form\FormLimitsandHttp\TrustedProxiesbefore the package/application bootstrap chain, so a bootstrap can replace either; runsBootSequence::run()andAppScope::boot(); reports thebootstrap.env/bootstrap.discovery/bootstrap.servicesphases to whatever telemetry backend that chain installed; reads the settledTrustedProxiesback out of the booted container forRuntimeDetector::detect(); and constructs theKernelwith the adapter’s ownisPersistent()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): selfis the same program stopping short of the adapter’s request loop, exposing the bootedapp, thekerneland theadapter;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. Everythingassemble()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 reachesFrankenPhpAdapter,FpmAdapterandRoadRunnerAdapter;BrefLambdaAdapteris 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 (seeKinetis\BrefAdapter\LambdaRequestIdentityin Appendix: Satellite Packages). The request body needs no argument here — an adapter hands it on raw, andHttp\Middleware\RequestBodyMiddlewarebounds and parses it inside the Kernel. PicksFrankenPhpAdapterorFpmAdapterbased onfunction_exists('frankenphp_handle_request'); picksKinetis\BrefAdapter\BrefLambdaAdapter(separatekinetis/bref-adapterpackage —class_exists()-gated, not a hard reference) whengetenv('AWS_LAMBDA_RUNTIME_API')is set and that package is installed; picksKinetis\RoadRunnerAdapter\RoadRunnerAdapter(separatekinetis/roadrunner-adapterpackage, sameclass_exists()-gated pattern) whengetenv('RR_MODE') === 'http'; either missing-package case throwsRuntimeUnavailableException::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 ado/whileloop callingfrankenphp_handle_request()repeatedly for as long as it returnstrue;isPersistent(): true.Adapters\FpmAdapter—run()handles exactly one request from superglobals, callingfastcgi_finish_request()when available so the response flushes before any post-response cleanup;isPersistent(): false.AppEnvironment—Development/Productionenum.detect()readsAPP_ENV: the exact namedevelopment, ignoring case, →Development; unset or any other name, a deployment’s ownstagingincluded, →Production. A deployment that needs its own environment names told apart matches the rawAPP_ENVstring instead, the wayOpenApi\OpenApiAccessdoes.ProjectRoot—detect(string $callerDir, ?string $composerBinDir = null): stringresolves the consumer project root, accounting for Composer’svendor/bin/kinetisproxy. An instance is an immutable value whosepathis the root an entry point passed toCache\BootSequence::run(), which binds it onAppScope.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 throughgetBody().getEmitter(): Closurereturns the closure that writes those bytes, andabandon(): voidreleases 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 streamHttp\Kernelhands back holds that request’s ownContainer\RequestScopeopen for the emitter to resolve from, so an adapter that cannot stream callsabandon()rather than dropping the response. The contract lives in this namespace, notKinetis\Http, so an adapter never needs to knowHttp\StreamedResponseexists.SuperglobalsBridge— PSR-7 ⇄ superglobal conversion and response emission, shared byFrankenPhpAdapter/FpmAdapter, the only two adapters whose request arrives as superglobals plus aphp://inputstream. This class does not parse the request body, and PHP must not either:assertCapabilities()refuses to serve anything unlessenable_post_data_readingis 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 offphp://inputcarries 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\RequestBodyMiddlewarestages, 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 theHostheader the client sent and collapses that header to one value: PSR-7’s own server-request creation takes the host fromHTTP_HOSTbut the port fromSERVER_PORT, and adds a secondHostheader derived from the URI, so a request toexample.comserved 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 whereHttp\TrustedProxiessays the connecting peer is an edge, since PSR-7’s own creation appliesX-Forwarded-Protounconditionally.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 a400carryingRuntimeAdapterInterface::MALFORMED_BODY_MESSAGE, the real reason logged and never returned.emit()sends the status and headers, then either echoes the body or, for aStreamableResponseInterface, invokesgetEmitter()— 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 andfile:line, and the loop serves the next request; the client keeps the truncated body. The containment lives here and not inHttp\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 (ornullforintOrNull()); anything else that doesn’t parse throwsException\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$keyunchanged; any other name inserts itself, uppercased, after the key’s own prefix (REDIS_HOST+cache2→REDIS_CACHE2_HOST).EnvFile::safeLoad(string $projectRoot)— loads.envviavlucas/phpdotenv, called unconditionally inRuntime\HttpStartupandbin/kinetis, beforeAppEnvironment::detect().
Kinetis\Logging¶
ErrorLogLogger— a minimal PSR-3 logger writing througherror_log(), with{placeholder}context interpolation and the class/file:lineof a Throwable under theexceptioncontext key appended.AppScope::boot()’s defaultLoggerInterfacebinding 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 atry/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 throwingLoggerInterfacemust 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; everyRequestScopeowner 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-suspendingconnect()/read()/write().Timer::delay()— Fiber-suspending delay.concurrently(array $tasks)— runs each task in its ownFiberdrawn fromFiberPool, 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 asException\DeadlockExceptionnaming 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— onlyconcurrently()submits jobs.ConcurrentBatch— oneconcurrently()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): intandcount(string $key): int, implemented alongsideCacheInterfaceby a backend that can count without reading first. PSR-16 has no such operation, and building one fromget()thenset()is not safe across processes. A counter is stored in whatever form the backend increments natively — a RedisINCRcounter holds a bare integer where the cache otherwise stores serialized values — so it is read throughcount(), neverget().AtomicConsumeInterface—consume(string $key, mixed $default = null): mixed, implemented alongsideCacheInterfaceby a backend that can read and delete a key in one operation. PSR-16 has no such operation either, and aget()then a separatedelete()is not safe across processes: two concurrent callers can both read the value before either deletes it. Required byKinetis\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 (seeUnavailableSimpleCachebelow for the configured-but-not-installed case). Always misses, never stores.Exception\CacheException/Exception\InvalidArgumentException— implement the matchingPsr\SimpleCache\*exception interfaces; reused bykinetis/cache-redis’s own classes, not redeclared there.UnavailableSimpleCache— bound when Redis is configured (REDIS_HOST/REDIS_URL/REDIS_CLUSTER) butkinetis/cache-redisisn’t installed. Every operation throwsException\SimpleCacheUnavailableExceptionnaming the package; nothing is silently discarded, and nothing fails until the cache is actually used, so a leftoverREDIS_*in a.envleaves an application that never touches the cache unaffected. It implementsAtomicCounterInterface/AtomicConsumeInterfacetoo, so a counter orRefreshTokenStorebuilt 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 tradeRateLimitMiddleware/RevocationStoremake by rejectingNullSimpleCacheat construction rather than at boot.Exception\SimpleCacheUnavailableException— thrown by everyUnavailableSimpleCacheoperation, namingkinetis/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, sorecordFailure()/clear()are called directly.tooManyAttempts()/availableInSeconds()read the current lockout; each failure refreshes the window todecaySecondsfrom that failure, so repeated attempts keep extending it. The cache key folds a policy identity (maxAttempts,decaySeconds, an optionalnamespace) 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 throughSimpleCache\AtomicCounterInterface, and requires the cache to implement it —NullSimpleCacheand any other non-atomic cache are both refused at construction withException\AttemptThrottleUnavailableException, since a cache that can’t count atomically lets failures arriving together — how a password is actually attacked — register as one.maxAttempts/decaySecondsbelow 1 are rejected too, withException\InvalidAttemptThrottleConfigException.
Kinetis\Cache¶
Compiler::compile()/compileProject(DiscoveryContext $context)— walks aRouter/CommandRegistry/EventListenerRegistry, derives binding/validation plans, produces aCompiledCache.compileProject()builds them viaGlobalMiddlewareDiscovery/RouteDiscovery/CommandDiscovery/EventListenerDiscovery/PluginDiscovery, all through the one context it is given, which also supplies the package-bootstrap list.kinetis buildand the production fallback compile each pass a fresh context.Compileritself never walks MCP tool/resource definitions directly — butPluginDiscovery::discover()compileskinetis/mcp’s ownMcpRegistryintoPluginCachetoo, the same as any other installed package’s declaredCacheableDiscoveryInterfaceclass (see below), so MCP tool/resource definitions are part of the compiled cache oncekinetis/mcpis 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 (seeCacheStorebelow) even though a given entry point only ever reads the sections it actually consumes, not all four — an HTTP boot readsHttpCache/EventCache/PluginCache, the CLI readsCommandCache/EventCache/PluginCache.CompiledCache::$packageBootstrapscarries theextra.kinetisbootstrap-class list once, beside the format version — production reads it from the artifact its entry point already loads whole instead of re-readingvendor/composer/installed.json.HttpCache::$middlewareGroupscarries the#[AsMiddlewareGroup]map; a route’s ownmiddlewarelist 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::$datais aclass-string => arraymap, one entry per installed package’s ownextra.kinetisdiscoveryclass (seeCacheableDiscoveryInterfacebelow). Each one’s ownfromArray()validates every top-level field’s presence and type viaException\ArtifactValidation(@internal) — including, viaArtifactValidation::exactKeys(), that no extra field is present either, matchingtoArray()’s own shape exactly rather than merely tolerating a superset of it.HttpCache/CommandCacheadditionally validate each route/command entry’s own required fields (again exact-keyed) and reject a duplicate route/command the same wayRouter/CommandRegistry’s ownfromArray()do (see below) — every one of these throwsException\InvalidCacheArtifactExceptionfor anything missing, extra, or wrong-typed, rather than a raw missing-array-key warning surfacing several calls deeper as aTypeErroronce the resultingnullreaches a non-nullable typed constructor parameter.globalMiddleware/openApiMiddlewareare validated aslist<string>(ArtifactValidation::listOfStrings()) andmiddlewareGroupsasarray<string, list<string>>(ArtifactValidation::mapOfListOfStrings()), not merely “an array”. A route entry is exactlyhttpMethod,pathTemplate,controllerClass,controllerMethod,status,middlewareandwhere— the last always serialized,[]included, and validated asarray<string, string>byArtifactValidation::mapOfStrings(), which rejects a numeric key and a non-string value; a constraintRoute’s own constructor then rejects is reclassified asInvalidCacheArtifactExceptionbyRouter::fromArray();CompiledCache::fromArray()validates its ownpackageBootstrapsaslist<string>the same way, one level up.HttpCache::fromArray()delegateshttpBindingPlans/hydrationPlansvalidation toHttp\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 placeHttpBindingPlan’s andHydrationPlan’s own shapes are ever validated, called byHttpCache::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 —defaultValueexcepted, an arbitrary PHP value with no single type to check. Every constraint-descriptor list ({class, args}) is validated via the sharedException\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-nullnestedPlan, and the one alistItemdescriptor carries — themselves the identical shape one level deeper, exactly ascompilePlan()embeds them; the descriptor’s own fields (scalarType,enumClass,dtoClass,constraints) are exact-keyed and type-checked like any other. Naturally bounded, sincecompilePlan()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 byHttpCache/CommandCache/EventCache/PluginCache/Http\Routing\Router/Console\CommandRegistry) andEvents\Exception\InvalidListenerExceptionboth implement it.BootSequence’s cache-bundle loaders (below) catch exactly this interface — never a bareThrowable— around every reconstruction call working purely from data just read off disk, so a real bug (an undefined method inside a plugin’s ownfromArray(), say) still propagates instead of being silently relabelled “corrupt cache” and retried as a fresh compile. ACacheableDiscoveryInterface::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 onevar_export()ed PHP file at.kinetis-cache/compiled.php, andload(): ?CompiledCachereads it back. A publish writes to a uniquely-named temporary file in the same directory, verifies the whole write landed and thatrequireing the result reconstructs the identical array, and only thenrename()s it over the live path and callsopcache_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 CLIkinetis buildcan 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 withException\UnexportableArtifactExceptionnaming 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 theException\CacheWriteExceptiona 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, byReflection\ParameterDefaultwhere the plan is derived.load()wraps the artifact’s ownrequirenarrowly, by type only, forParseError: a syntax-corrupt file (a truncated write, disk corruption, manual tampering; never a shapewrite()itself produces) is treated identically to a missing one, returningnullrather than letting the error escape this class. A format version that is not exactlyCacheFormat::VERSIONis alsonull— 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, aTesting\TestApplicationboot,Compiler::compileProject()(forkinetis buildand the production fallback compile), orConsole\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 owncomposer.jsonautoload.psr-4roots, at any depth, with no directory/namespace convention required;$pathsrestricts 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 asextra.kinetisscan, 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$pathsrestriction is a different pair. A file is skipped entirely (noclass_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 noautoload.psr-4map is reported once per context viaerror_log().vendor/composer/installed.jsonis parsed once per context:packageBootstraps()lists the declaredbootstrapclasses anddiscoverySections()the declareddiscoveryclasses implementingCacheableDiscoveryInterface, both in Composer’s recorded order. Ascanprefix outside the package’s own roots, a missing bootstrap class, or a discovery class not implementing the interface is logged viaerror_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): arrayis how a discovery section reads another — seeCacheableDiscoveryInterfacebelow.CacheableDiscoveryInterface/PluginDiscovery— the pluggable half of the AOT cache: a package declares one class (itsextra.kinetisdiscoverykey) implementingcompile(DiscoveryContext $context): array(live discovery, reduced to plain data) andfromArray(array $data): static(reconstruction) — required to throw something implementingException\CacheArtifactExceptionInterface(above) when$datadoesn’t represent a valid instance; anything elsefromArray()throws (a genuine defect, not a data-shape problem) is expected to propagate uncaught. A section is compiled only throughDiscoveryContext::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 owncompile(), 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()throwsException\DiscoverySectionExceptionfor 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 throughcompiled()and returns them keyed in that order — the same methodCompiler::compileProject()calls to buildPluginCache, 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 implementsCacheableDiscoveryInterface, every entry an array — throwingException\InvalidCacheArtifactExceptionfor a violation, then calls each entry’s ownfromArray()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): voidbinds already-reconstructed instances intoAppScopedirectly, before the bootstrap chain runs, with zero reconstruction of its own, sofromArray()— object construction, not a guaranteed pure validator — runs once per boot. A package’s ownPackageBootstrapInterface::register()never touches this data at all — by the time it runs, the framework has already bound it.kinetis/broadcasting’sBroadcastChannelRegistry,kinetis/database-bridge’sOrmMetadataandkinetis/mcp’sMcpRegistryare the real consumers.RoutesFile::loadBootstrap(string $projectRoot, array $packageBootstraps)— composes the bootstrap chain run with(AppScope, Config)beforeboot()locks bindings: each package’sPackageBootstrapInterfaceclass first (the list the entry point resolved — from the artifact, orDiscoveryContext::packageBootstraps()), then the consumer’s ownbootstrap.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, byPluginDiscovery::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, andKinetis\Testing\TestApplicationall call it, so none of them can drift on this ordering. Bindsnew Runtime\ProjectRoot($projectRoot)first, whatever$runBootstrapis, then callsPluginDiscovery::bindInstances()with$pluginInstances(already-reconstructedclass-string => object, from the artifact or the entry point’s own live discovery), binds the given$listenerRegistry, then runsRoutesFile::loadBootstrap()with$packageBootstrapsunless$runBootstrapisfalse(the onebin/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 ownPackageBootstrapInterface::register()as well as the project’sbootstrap.php, not just the latter). Stops short of calling$app->boot()itself:TestApplicationneeds one more step, its own$beforeBootcallback, to run after the bootstrap chain and before the container locks, so every caller callsboot()right after its own final pre-boot seam — immediately forHttpStartup/bin/kinetis, or after$beforeBootforTestApplication.BootSequence::resolveHttp(CacheStore $store, callable $compile): array{httpCache: HttpCache, router: Http\Routing\Router, listenerRegistry: EventListenerRegistry, pluginInstances: array, packageBootstraps: list<class-string>}andresolveCli(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(acallable(): 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-memoryCompiledCache, 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: aCacheWriteExceptionis caught and reported as oneerror_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$compilefailure, 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>}andloadCliFromCache(CacheStore $store): ?array{registry: Console\CommandRegistry, listenerRegistry: EventListenerRegistry, pluginInstances: array, packageBootstraps: list<class-string>}are the cache-hit halfresolveHttp()/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\CommandRegistryincluded, not just the raw DTOs) — ornullthe 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. Thecatchinside is scoped narrowly two ways: by what code sits inside thetry(theCacheStore::load()call and every reconstruction that follows, nothing else — no live discovery, nobootstrap.phpregistration), and by exception type (Exception\CacheArtifactExceptionInterfaceonly, above) — a plugin’s ownfromArray()throwing anything else propagates uncaught rather than being silently relabelled “corrupt cache.” Onnull, a caller compiles fresh (viaresolveHttp()/resolveCli(), or by hand) and uses thatCompiledCache’s own in-memory sections directly for the boot.BootSequence::assertReconstructable(CompiledCache $compiled): voidis the whole-artifact form the same reconstructions compose into: both entry points’ registries —Http\Routing\RouterandConsole\CommandRegistry— plus the event and plugin sections they share, each reconstructed exactly once, throwing whatever a section’s ownfromArray()throws.Console\BuildCommandpublishes 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: aCacheableDiscoveryInterfaceimplementation whosecompile()output its ownfromArray()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 becausefromArray()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 noConsole\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 viabin/kinetis build; environment configuration isn’t.
Kinetis\Validation / Kinetis\OpenApi¶
Hydrator— builds and validates a#[Body]-bound DTO from constructor-parameter reflection andConstraint-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-shapedarrayfield, 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 anInvalidArgumentExceptionwhere the constraint is instantiated. A missing key on a defaultless parameter isis required.(coderequired); an explicitly-null value for a parameter whose declared type doesn’t allow null ismust not be null.(codenull_not_allowed) — both validation failures, never a rawTypeErrorfrom the constructor. A class-typed constructor parameter accepts an object-shaped value hydrated into that class, or a value already an instance of it.UploadedFileInterfaceis 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, andDispatcher’s own direct parameter — answering the ordinarynot_an_instanceviolation for a value that is no file, oneupload_failed(['error' => <the raw UPLOAD_ERR_* status>], messagecould not be uploaded.) for a file whose status is notUPLOAD_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_FILEnever reaches it,Dispatcherhaving 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 publicresolveEnumValue()resolves the value throughresolveScalar()as the enum’s own backing type and then hands it totryFrom(), so a wrong primitive is an ordinarytype_mismatchand a correctly typed value naming no case is oneenum_caseviolation carrying['choices' => <backing values>], never aTypeError;Http\Dispatcherbinds 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 recordsenumClassplus the backing type asscalarType, and rules run against the resolved case.backedEnumScalarType()asksenum_exists()before theBackedEnumrelationship, so the interface itself — which satisfiesis_a()against itself while inheriting an abstractcases()— stays the instance-only class shape rather than reaching an engineError.#[ListOf(string $type)](itemType()) declares a list’s element type —string,int,float,bool, a backed enum, an instantiable class, or exactlyUploadedFileInterface, the one interface admitted because a repeated file control is a real multipart shape — compiled into one plain-datalistItemdescriptor (scalarType,enumClass,dtoClass,nestedPlan,constraints) by the publiclistItem(), whichJsonSchemareads too so a publisheditemsand 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 carriesdtoClasswith a nullnestedPlan, 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 aConstraintevery 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. Anarrayparameter carrying#[ListOf]’s counterpart#[ObjectMap]admits a JSON object of arbitrary keys instead, handing the constructor its plain array form; admission is keyed on theJsonObjectprovenance 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 booleanobjectMapfield per parameter, retaining no attribute instance. The publicobjectMap()decides that boolean and refuses both invalid#[ObjectMap]declarations listed below;JsonSchemaandMcpDispatcher::derivePlan()read it too. The publicresolveObjectMap(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, theJsonObjectprovenance check (not_a_json_objectfor a JSON array), the recursive unwrap and the field’s own rules, returning[value, list<Violation>]. A parameter may also declare the presence unionT|Absent/T|null|Absentdefaulted to exactlyAbsent::Value(seeAbsentbelow);absentUnion()resolves it toTplus whethernullwas named, the plan records the declaration as a booleanabsentfield, and the rest of the parameter is compiled fromTexactly asTalone would be.compilePlan()embeds each nested class’s own plan inline (nestedPlan, and a DTO list item’s ownnestedPlaninside itslistItem), collects the class’s ownObjectConstraintattributes into the plan root’sobjectRulesas the same literal{class, args}descriptors (collectObjectRules(), which builds each rule transiently to check every name itsfields()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 throwsException\UnsupportedDtoDefinitionExceptionfor 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 thanAbsent::Value), a recursive or mutually recursive class reference, an unresolvableself/parent/static,#[ListOf]on a non-arrayparameter 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’senummay 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 implementConstraint,#[ObjectMap]on a non-arrayparameter 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 nullnestedPlan: it accepts an existing instance and nothing else. See Appendix: Routing & Validation’s “DTO definitions Kinetis rejects”.SUPPORTED_BUILTIN_TYPESis 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, andJsonSchemadescribes 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 viaDispatcher, an MCP tool argument viaMcpDispatcher— 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()andobjectExpectedViolation()stay public for the failures detected before there is a scalar to resolve). An acceptednullis the value and skips the field’s rules. UnderInputSource::Jsonthe object is closed: every member of$datanaming no constructor parameter is anis not expected.violation (codeunexpected_field) on its own path, in the input’s own order, combined with every other field failure in the sameValidationException— at every nesting level, and for a constructor-less DTO too;TextandNativestay open, since a form body legitimately carries CSRF/submit/honeypot members and aNativecaller hands over values it already holds, often wider than the DTO reading them.unexpectedFieldViolation()is public soMcpDispatchercloses 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’sobjectRulesrun against it with aValidationContextnaming the supplied fields; after any field failure they do not run at all.hydrate()’s own$sourcedefaults toInputSource::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 segmentedlist<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, andarray<string, scalar|list<scalar>|null> $parametersthe message was built from. Every one of those rules is checked at construction and a breach is anInvalidArgumentException, since application code builds violations too.under(string|int ...$segments)prepends segments for a nested field or list element and returns a new instance. ImplementsJsonSerializable, and that one encoding is the wire shape both transports use — the HTTPerrorsextension and the MCP tool-error envelope alike — withparameterscast to an object so an empty set stays{}.Exception\ValidationException— a non-empty orderedlist<Violation>and nothing about any transport.fromViolations()is the only factory and refuses an empty list, a keyed map, or a non-Violationelement.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\ExceptionHandlerMiddlewarerenders it throughHttp\ValidationExceptionRendererInterface, andKinetis\Mcp\KinetisMcpApplicationputs the same violations in its ownisErrorenvelope.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"forboolincluded), andNative(a directHydrator::hydrate()call over PHP values the caller already holds — both spellings of a number, and1/0/"1"/"0"forbool).DispatcherpicksJsonorTextper request from the body’s own media type and alwaysTextfor#[Query]/path;McpDispatcherpicksJson. See Appendix: Routing & Validation’s “Scalar type checking”.Constraint— the two-method contract every rule implements:validate(mixed $value): ?Violationreturns the rule’s own code, message and parameters at a path relative to the value (normally[], prefixed byHydrator), andschema(): arrayreturns 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 a422.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_numberfor a non-number),#[MultipleOf](multiple_of,not_an_integerfor anything but anint),#[Regex](regex),#[In]/#[NotIn](in/not_in),#[MinItems]/#[MaxItems](min_items/max_items,not_a_listfor 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 ananyOfofformat: ipv4andformat: ipv6),#[Date](date,YYYY-MM-DDin years0001-9999),#[DateTime](date_time, RFC 3339’sdate-timewithout leap seconds and without the space separator),#[FileSize(int $maxBytes, int $minBytes = 0)](file_too_large/file_too_smallwith{max|min, size},file_size_unknownwhen PSR-7 reports no size,not_a_filefor a non-upload) and#[FileExtension(array $extensions)](file_extensionwith{choices},not_a_filefor 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 withstr_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 ofConstraint, for a rule relating two fields rather than checking one value:validate(object $value, ValidationContext $context): iterableyieldsViolations at paths relative to the DTO ([]addresses the object itself),fields(): arraynames the constructor fields the rule relates ([]for a whole-object rule), andschema(): arrayreturns object-level JSON Schema keywords or[].Hydrator::collectObjectRules()checks everyfields()name against the guarded class’s constructor, so a mistyped one is anException\UnsupportedDtoDefinitionExceptionwhere the plan is compiled and where the schema is generated, never a rule that silently never matches. Discovered withReflectionAttribute::IS_INSTANCEOFlike 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 codeat_least_one_providedand publishing ananyOfof onerequiredclause per field — and#[SameAs($field, $other)], which reads both values by direct reflection (a promotedprivatefield included) and compares them only when the input supplied both, fails at[$field]with codesame_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; aSameAsnaming one field twice — with anInvalidArgumentException, both return their configured names fromfields(), and aSameAsfield it cannot read off the constructed object is a definition failure.ValidationContext— the immutable per-hydration value anObjectConstraintreceives beside the DTO, carrying only the constructor field names the input supplied, withwasSupplied(string): boolas 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|AbsentorT|null|Absent, defaulted to exactlyAbsent::Value, distinguishes an omitted member from an explicitnullfrom a supplied value. Bound only from that default — no input can produce it, and anAbsentin hydrated data is refused as the wrong type for the field — and omitted from generated schemas, which describeT(widened withnullwhere declared) and leave the member out ofrequired. DTO constructor fields only:Dispatcher::derivePlan(), MCP tool schema generation andMcpDispatcher::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 byOpenApiGeneratorand MCP tool input schemas. Two entry points share one object-schema builder:forClass()describes a DTO’s constructor, where aT|Absentpresence union is legal (published asT, widened withnullonly where the union names it, and left out ofrequiredbecause the declaration carries a default) and where the class’s ownObjectConstraint::schema()keywords are merged in;forParameters()describes a method’s parameters, where any composite type is anException\JsonSchemaException— so an MCP tool whose schema cannot be stated truthfully fails at registration. Every object either produces saysadditionalProperties: false, including one with no properties at all, matching whatHydratorenforces for JSON input andMcpDispatcherfor a tool’s arguments. An optional$classSchemacallback 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: therequiredarray is driven purely by!isDefaultValueAvailable(), matchingHydrator::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’stypeis 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]’senum) widens alongside it rather than being merged onto an already-widenedtype; a nullable class-typed/#[ListOf]parameter gets the identical widening when inlined, or — since$refonly combines with sibling keywords as an intersection in JSON Schema 2020-12, never a union — is wrapped inanyOf: [<the $ref>, {type: null}]when$classSchemaproduces one.OpenApiGenerator’s own#[Query]/path parameterrequired(computed independently, never throughforParameters()) already followed the default-driven rule and needed no change. An#[ObjectMap]parameter is described as{type: object, additionalProperties: true}— the onearray-typed parameter whose schema is an object, decided byHydrator::objectMap(), so a declaration it refuses fails schema generation too — andschemaForListOf()merges a#[ListOf]parameter’s own constraint keywords (minItems/maxItems) beside itsitems, through the samewithConstraintSchema()helperschemaForScalar()uses.itemsitself comes fromHydrator::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 backingtypeplus the exactenumof its cases, with each#[Each]rule’s own keywords merged onto it. A backed-enum field publishes that same pair throughschemaForEnum(), 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 reachesschemaForClassTyped()throughforParameters()and is refused, so an MCP tool declaring one fails registration. The publicschemaForScalar(), whichOpenApiGeneratorcalls directly for a#[Query]/path parameter, answers a backed enum with that same pair instead, matching whatDispatcherbinds such a parameter against; any other class type there isException\JsonSchemaException, whichDispatcher::derivePlan()has already refused at route registration.withNullableSchema()widens anenumalongsidetype, both domains a nullable value has to satisfy.withConstraintSchema()takes the keywords the PHP declaration owns and merges each rule’s ownConstraint::schema()onto them, reading the rules throughHydrator::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 throwException\JsonSchemaExceptionrather than letting declaration order pick a bound, and so does a rule contributing a keyword the declaration itself states (type, or a#[ListOf]’sitems), which would publish a shape the request is not checked against. One privatewithRuleKeywords()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 ownObjectConstraints contribute, against each other and against the four the PHP declaration owns (type,properties,required,additionalProperties).schemaForClassTyped()throwsException\JsonSchemaExceptionfor a class-typed parameter whose class cannot be instantiated —Hydratoraccepts only an existing instance there, so an expanded object schema would describe input it rejects;UploadedFileInterfacenever reaches it, being described as{type: string, format: binary}directly.forType()throws the same exception for any builtin outsideHydrator::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 placemixed’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()matchesOPENAPI_ENVIRONMENTS(comma-separated, case- and space-insensitive) against the rawAPP_ENV, treating an absent one asproduction;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.Kernelconstructs it for its ownRouter— with the effective global middleware order and the group map it dispatches against, both as class-strings — and registers it on every request scope alongsideRouterandOpenApiAccess. Development generates on every call; production generates once and holds it for the provider’s lifetime. AKernelbelongs to one worker thread, so the memo is per-thread state that dies with it, and a deployment — a new process, a newRouter, 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 fromOpenApi\OpenApiDocumentProviderand encodes it; the Swagger UI page comes fromOpenApi\SwaggerUiPage. A disabled path answers withKernel’s own unmatched-path 404, byte for byte.OpenApiGenerator::generate()— builds the OpenAPI 3.1 document from aRouter’s registered routes, deduplicating every DTO schema (request body, response, or nested at any depth) intocomponents/schemaswith$ref, and deriving the default response’s schema from the controller method’s declared return type.describeRequestBody()publishes a#[Body]DTO underapplication/json,application/x-www-form-urlencodedandmultipart/form-dataalike, sinceDispatcherhydrates the same class whichever the client sent — unless the DTO’s compiled hydration plan namesUploadedFileInterfaceanywhere (a field, a list element, or either inside a nested plan at any depth), in which casemultipart/form-datais published alone: neither of the other two can carry a file. AnUploadedFileInterfaceparameter is not request-body input and none is synthesized for it, so a route binding one publishes norequestBodyat 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’sdescription, plus — when the attribute names abody— that DTO’s component schema under the attribute’smediaType(application/jsonby default), through the sameschemaRefFor()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 abodynaming something that is not a class throwsValidation\Exception\JsonSchemaExceptionrather than registering a component nothing backs. A#[Query]/path parameter’s ownschemacomes fromValidation\JsonSchema::schemaForScalar(), which publishes a backed enum’s backingtypeplus the exactenumof 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 Schemapattern— and the Parameter Object itself gainsx-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 whosedatais the item list — inline: the wrapper’s ownJsonSchema::forClass()schema, nested classes still deduplicated, withdatareplaced by an array of the named class’s deduplicated schema. The wrapper itself never goes throughschemaRefFor(), 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 barearray $datadescribed as{type: array}. Security is composed from the same route table: the global middleware order becomes the document’s rootsecurity, a route’s own#[Middleware]list —@namereferences expanded throughHttp\Attributes\Middleware::expandGroups()— is composed onto it as AND, and an operation publishes its ownsecurityonly 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\SecurityDescriptioncarries 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 — withscheme()building the usual one-scheme description andTYPESnaming the fivetypevalues 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 reachescomponents/securitySchemes, besidecomponents/schemasrather than in place of it.publish()hands back a requirement object with no string key — the anonymous alternative among them — as astdClass, 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\OpenApiSecurityExceptionrefuses a provider that does not implement the interface, a nameless scheme or a definition that is not an array, atypeoutside 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 byCommandRegistry::register()the same wayRouter/McpRegistrydiscover their own attributes.bootstrap: falsemakesbin/kinetisskip the whole package-then-application bootstrap chain (every installed package’s ownPackageBootstrapInterface::register()as well as the project’sbootstrap.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/kinetisneeds the command’s own definition to read this flag off it before it can act on it at all), and the discovered/cachedEventListenerRegistryand plugin-discovery data always bind toAppScope;bin/kinetisnever constructs or binds aRouterat all.CommandRegistry— validates each#[Command]method’s signature at registration time (zero parameters, or exactly one parameter typedCommandArguments; anything else throwsException\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 viaCache\Exception\ArtifactValidationand re-checks the identical duplicate-name invariantregister()enforces, throwingCache\Exception\InvalidCacheArtifactExceptionfor anything missing, extra, wrong-typed, or a repeated name — never trusting that a hand-edited or otherwise corrupt artifact still holds an invarianttoArray()itself would always have preserved.Http\Routing\Router::fromArray()does the equivalent for routes (its own$routesByConflictKeypopulated and checked the same wayregister()populates it), plus reclassifying any other failureRoute’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), plusKinetis\Consoleitself (read through the context), rather than an explicit registration file.$paths, orCOMMAND_DISCOVERY_PATHSwhen omitted, restricts the project-side scan.CommandArguments— injected by type into a command method, the same by-type special-casingProgressReporteralready gets for MCP tools.parse(array $argv)splits into positional values (get(int),all()) and--key=value/bare---flagoptions (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, sinceCommandRegistry::register()already validated the signature. The method’s own return value becomes the exit code (intused directly,void/nullmeans0).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, viabin/kinetis’s own lazy-generate-on-first-run bootstrap. Compiles the project, validates the whole result throughCache\BootSequence::assertReconstructable()(below), and only then callsCacheStore::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: runsRouteDiscovery/GlobalMiddlewareDiscoverylive (regardless ofAPP_ENV) and prints the result — never touches.kinetis-cache/. Columns areMethod,Path,Where,Status,Controller,Middleware;Whereholds onename: fragmentline per constraint in placeholder order andMiddlewareone 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 requireext-mbstring, or by byte count for text that is not valid UTF-8. Declaresbootstrap: falseand constructs its own throwawayAppScoperunningbootstrap.phponce to readAppScope::middlewares(), sinceAppScopeitself is never registered onto theRequestScopea command is dispatched through.$output(aresource, defaulting toSTDOUT) is an appended constructor parameter for testability againstphp://memory— the same reasonmcp:serve’s input/output streams are injectable — since a#[Command]method itself must stay parameter-free or take exactly oneCommandArguments.bin/kinetis— has no hardcoded verbs at all. In production, loadsCommandCache(auto-generating it, via a fullCompiler::compileProject(), on the first invocation that finds none); in development, builds the registry, listeners, plugin sections and package bootstrap list live through oneCache\DiscoveryContext, released before the command runs. Every name — the built-inbuild/routes:list, anything a package contributes (mcp:serve,queue:work), and the application’s own — is looked up in that same registry. One freshRequestScopeper invocation fromAppScope::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’sLoggerInterface, dispatchesEvents\CommandFailed, and produces exit code1— 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 exits1.$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 code0, where either disposal failing on its own produces exit code70(EX_SOFTWARE) rather than a misleading plain success; see CLI.Events\CommandFailed—{commandName, exception}, dispatched bybin/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 throughKernel::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), plusphase()for pre-container lifecycle phases reported with explicit timestamps;jobPushMetadata()returns opaque string metadata a queue backend stores with the job and hands back throughjobStarted()— 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,NullTelemetryand 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-processglobal()accessor (a documentedNoStaticPropertiesRuleexemption, the FiberPool class of worker-lifetime infrastructure).AppScope::boot()binds it as theTelemetryInterfacedefault so app code can inject it; kinetis/telemetry’s package bootstrapswap()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 tonull,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 viaerror_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 (viaCache\DiscoveryContext’s class scan)Http\Middleware\GlobalMiddlewareDiscovery.reflect(class-string): ReflectionClassrejects an abstract class, interface, trait or enum withException\AttributeScopeException::notRegistrable().declares(ReflectionMethod, class-string): boolandassertDeclares()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 throwsAttributeScopeException::inheritedMethod()naming both classes.isRegistrable(string): boolis the silent counterpartNamespaceScanneruses, 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 byValidation\Hydrator’s hydration plan andHttp\Dispatcher’s HTTP binding plan.capture(ReflectionParameter, string $owner): mixedreturns the declared default, ornullwhen there is none (the pairing every plan writes alongside its ownhasDefault). A plan is derived once and reused — memoized for a persistent worker’s lifetime, written verbatim into.kinetis-cache/compiled.phpbykinetis 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, throwsException\UnsupportedDefaultValueExceptionnaming$owner(the DTO class, orController::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 flaggingstaticproperty declarations, shipped under the main autoload for consumer projects to add to their ownphpstan.neon.NoBlockingIoRule— a PHPStan rule, identifierkinetis.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 bykinetis/skeleton’sphpstan.neon; categories, replacements and limitations in Concurrency.
Kinetis\Testing¶
TestClient— wraps aKernel. 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, withrequest(string $method, string $uri, array $body = [], array $headers = [], array $query = [])the general form the array-body verbs call — abodyarray is JSON-encoded withContent-Type: application/jsonset unless a JSON-shaped override (application/json, or anapplication/*+jsonstructured suffix, parameters like; charset=...allowed —MediaType::isJson()decides, the same classifierDispatcherreads a typed body through) is given, and any other explicit Content-Type paired with an array$bodythrowsInvalidArgumentExceptionrather than silently sending JSON bytes under a mismatched header;query(onget()orrequest()) is merged onto the request URI’s own query component viaUriInterface::withQuery()— never raw string concatenation, which would corrupt a URI already carrying a#fragment— withgetQueryParams()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 throughMediaTypeabove; 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 onget()/delete()(orrequest()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 genuineapplication/x-www-form-urlencodedbody — the raw bytes are exactlyhttp_build_query($form), andgetParsedBody()is that same string parsed back withparse_str(), not$formitself (every scalar becomes a string, anullvalue is omitted entirely) — the actual shape a real form post arrives with, unlike the JSON verbs, which leavegetParsedBody()null; an explicit Content-Type override must itself be form-urlencoded-shaped, or it throws the same wayrequest()’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 andgetParsedBody()left null — for a webhook payload, binary content, or anything none of the other modes cover. ACookieheader given to any of the methods above is parsed intogetCookieParams()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 aTestResponse.TestResponse— the response with assertions attached (assertStatus/assertOk/assertJson/assertJsonPath/assertValidationError, …), each returning$thisfor chaining. ImplementsResponseInterfaceitself 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 oneCache\DiscoveryContextper boot (routes, middleware, listeners, plugin sections, package bootstraps), the package-then-app bootstrap chain, a bootedAppScope, a realKernel.boot(string $projectRoot, array $configOverrides = [], ?callable $beforeBoot = null)merges overrides over the environment — includingAPP_ENV, registered as the container’sAppEnvironmentfrom the merged config, sinceAppScope::boot()’s own default readsgetenv()and would never see the override. Delegates toBootSequence::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.$beforeBootruns after that — after the application’s ownbootstrap.phpand beforeboot()locks the container — the only window in which a test double replaces a binding the application made,EventListenerRegistryincluded.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 becauseAppScope::dispose()is. No PHPUnit dependency.ApplicationTestCase— the PHPUnit base class overTestApplication: 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. OverrideprojectRoot()(required),configOverrides(), andregisterTestDoubles(AppScope $app, Config $config)for services a test should not reach. A#[After]hook disposes that test’s application, guarded byisset()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 aTimer::delay()sentinel run beside$operationinconcurrently()resumed while the operation was in flight; throwsException\LoopLivenessInconclusiveExceptionwhen 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, onefinaltest 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 wayparse_str()reads them); request identity — the URI authority matching theHostheader the client sent, with and without a port, the scheme the environment serves,X-Forwarded-Protodeciding it only from a trusted edge and ignored entirely from a directly reachable client (an environment no plaintext request can reach refuses a forwardedhttpoutright 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 theCookieheader andgetCookieParams()(order asserted where the environment keeps it),REMOTE_ADDR, url-encoded and multipart bodies (POST,PUTandPATCHalike), 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 declaredContent-Lengthdelivered, 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 pastHttp\Form\FormLimits, each with a security-significant field placed beyond the edge, answered with a413and no handler run; response status/headers, a comma inside one header value, twoSet-Cookieas 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 a400carryingRuntimeAdapterInterface::MALFORMED_BODY_MESSAGEand no handler run.driver()is the one abstract method;assertMalformedBodyResponse(WireResponse)andassertOverLimitFormResponse(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 factsexpectedClientIp(),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 oneHttp\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 assertsgetBody()is the request byte for byte on every adapter.WireRequest— method, path, query string, headers as a list of pairs (repeats preserved), cookies asname=valuestrings, raw body.json()is the JSON-body shorthand.ResponseSpec— what the handler answers with, as data: status, headers,Set-Cookievalues, body, orstreamChunksfor aStreamedResponsewithstreamDelayMsbetween 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 — withof(),streaming()andjson()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, throwingMalformedResponseSpecException.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 becausegetUploadedFiles()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 isUPLOAD_ERR_NO_FILEon every runtime and a file that is notUPLOAD_ERR_OKhas 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;MalformedObservationExceptionis what it throws.fromServerRequest(),header()(case-insensitive),toArray().WireResponse— the response as the environment received it: status, headers as pairs,Set-Cookievalues separately, body bytes, andbodyArrivalSpanSeconds— the time between the first and last body byte reaching the client, the evidence a stream was delivered as written rather than buffered (nullfor 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 (nullwhen the handler never ran) and either aWireResponseor a rejection.
Request lifecycle, in order¶
A
RuntimeAdapterInterfacereceives the request and converts it to PSR-7.Kernel::handle()runs the globalMiddlewarePipeline.Inside it:
AppScope::createRequestScope(), which runs every initializer registered throughAppScope::onRequestScopeCreated()— withkinetis/database-bridgeinstalled, the lazyTransactionGuardbinding whose first resolution registersrollbackDangling()on the scope’s disposal, and withkinetis/ormalso installed, the lazyEntityManagerRegistrybinding whose first resolution registersclose()there — the request’sEntityManageris its default-connection manager.Router::match()resolves aRoute, or throwsRouteNotFoundException/MethodNotAllowedException(→ 404/405).The route’s
#[Middleware]pipeline runs, wrappingDispatcher::dispatch().Dispatcherresolves parameters (via a compiled plan ifHttpCacheis present, live reflection otherwise), invokes the controller.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¶
Appendix: Satellite Packages — the same reference map for every optional satellite package.
Appendix: Continuous Integration — what actually runs in CI, and what’s deliberately not covered.
Appendix: Contributing to Kinetis — the monorepo layout, dev environment setup, and how to actually make a change.
Core Concepts, Appendix: Container Lifecycle, Configuration, Routing & Validation, Middleware, Logging, Runtime Adapters, Concurrency, Database, Model Context Protocol (MCP), Caching & AOT Compilation, CLI, Testing — the task-oriented page for each namespace above.