# 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 {doc}`appendix-packages`. ## `Kinetis\Container` - `AppScope` — the persistent, worker-lifetime container. `bind()`/`instance()`/`middleware()` before `boot()`; locked after. Only an explicit registration creates a singleton: `get()` on an unregistered class autowires a fresh instance per call, never cached — the same "never promoted" guarantee `RequestScope` makes, applied to the parent scope's own API. `boot()` registers defaults if not already set: `Kinetis\Runtime\AppEnvironment` → the detected environment, `Psr\Log\LoggerInterface` → `Kinetis\Logging\ErrorLogLogger` in development / `NullLogger` in production, `Kinetis\Config\Config` → `Config::fromEnvironment()`, `Psr\SimpleCache\CacheInterface` → `Kinetis\SimpleCache\ClusteredRedisSimpleCache`/`RedisSimpleCache::fromConfig()` (both `class_exists()`-gated against the optional `kinetis/cache-redis` package) when Redis is configured, else `NullSimpleCache` — configured but not installed binds `UnavailableSimpleCache`, which throws on use rather than at boot. - `RequestScope` — the per-request container, created by `AppScope::createRequestScope()`, which also registers the scope onto itself (`RequestScope::class` resolves to that exact instance), disposed by `Kernel` in a `finally` block. Delegates to `AppScope` for explicitly registered ids only; autowires anything else, discarded on `dispose()`. `appScope()` exposes the parent, for a worker-loop command that mints its own per-job scopes. - `PackageBootstrapInterface` — `register(AppScope $app, Config $config): void`, the one method an installed package's `extra.kinetis` bootstrap class implements (see `Kinetis\Cache`'s `PackageDiscovery`/`RoutesFile::loadBootstrap()` below). Runs before the application's own `bootstrap.php`, which wins on any shared binding; an implementation should stay inert when its configuration is absent — wiring, not side effects. - `Autowire` — reflection-based constructor injection, used by both scopes. ## `Kinetis\Http` - `Kernel` — the runtime-agnostic entry point. `handle(ServerRequestInterface): ResponseInterface`. Resolves an `OpenApi\OpenApiAccess` (from `exposeOpenApi`, else `OPENAPI_ENVIRONMENTS` against `APP_ENV`) and registers it, plus the `Router`, on each request's scope for `Http\OpenApi\DocumentationController` — it serves no endpoint of its own: it creates a `RequestScope`, matches a route, dispatches. `/openapi.json`/`/openapi` are ordinary routes on `Http\OpenApi\DocumentationController`, and `/mcp` is an ordinary route `kinetis/mcp` contributes — see {doc}`appendix-packages`. - `Dispatcher` — resolves a matched route's controller from the container, binds each parameter, invokes it. Six sources, checked in order: a parameter typed `ServerRequestInterface`/`UploadedFileInterface` (the raw request/an uploaded file), `#[Body]` DTO (JSON, or `getParsedBody()` for `multipart/form-data`/`application/x-www-form-urlencoded`), `#[Query]` scalar, same-named path parameter, then any remaining **class-typed** parameter from the request container (plan source `container`) — last, so it shadows none of the others. That last source is what lets one controller serve a public route and a middleware-guarded one, since a constructor is shared by every route on its class while a method signature is not; a container failure propagates unless the parameter has a default, which is the explicit way to say absence is acceptable — and even then only for genuine absence, since a registered service that failed to construct or a `Container\Exception\CircularDependencyException` is reported rather than swallowed. A default is required even on a nullable type, unlike `#[Query]`/path parameters, because absence here usually means a route is missing its middleware. `Container\RequestScope::isRegistered()` is the predicate that distinguishes the two; `has()` cannot, since it also answers true for any autowirable class. Anything left over uses its default or throws `Exception\UnresolvableParameterException`, whose message names every source. - `CurrentUserInterface` — one method, `id(): string|int`. Nothing implements or registers it by default; a middleware registers a concrete implementation on the current `RequestScope` (see `Kinetis\Container` above), and any class downstream — a controller, another package — depends on this interface rather than a specific implementation. - `MiddlewarePipeline` / `CallableRequestHandler` — PSR-15 (`Psr\Http\Server\MiddlewareInterface`/`RequestHandlerInterface`) composition. `Kernel` builds two: a global one (from `AppScope::middlewares()` plus `$discoveredGlobalMiddleware`, wraps the whole request) and a per-route one (from a matched `Route`'s `#[Middleware]` attributes, wraps just `Dispatcher::dispatch()`). `Kernel::expandMiddlewareGroups()` replaces each `@name` reference in a route's list with that group's members in place, so a group occupies exactly the position its reference was declared at; `assertMiddlewareGroupsExist()` validates every reference across every registered route once, in the constructor, throwing `Middleware\Exception\UnknownMiddlewareGroupException` for an undeclared group rather than failing on whichever request first hits that route. - `Middleware\ExceptionHandlerMiddleware` — always the second global middleware, immediately inside `SecurityHeadersMiddleware`. Catches `Throwable`, logs via the container's `LoggerInterface`, returns a 500 — a generic body in production, the exception's class/message/`file:line` alongside it in development. Constructor-injects `AppEnvironment` (defaulting to `Production`, so a directly-constructed instance never leaks detail by accident). - `Middleware\SecurityHeadersMiddleware` — always the outermost global middleware, outside `ExceptionHandlerMiddleware`, so its headers reach the `500` that handler produces. Constructor-injects `Config` and reads every value once, stripping CR/LF (which would both throw inside `withHeader()` and be a header injection), so `process()` cannot fail. Sends `X-Content-Type-Options: nosniff` always, plus `X-Frame-Options` (default `DENY`) and `Referrer-Policy` (default `strict-origin-when-cross-origin`), each overridable or disabled with `off` via `SECURITY_FRAME_OPTIONS`/`SECURITY_REFERRER_POLICY`. `Content-Security-Policy` (`SECURITY_CSP`), `Permissions-Policy` (`SECURITY_PERMISSIONS_POLICY`), HSTS (`SECURITY_HSTS_MAX_AGE` plus `SECURITY_HSTS_INCLUDE_SUBDOMAINS`/`SECURITY_HSTS_PRELOAD`), and the three cross-origin policies — `Cross-Origin-Opener-Policy` (`SECURITY_COOP`), `Cross-Origin-Resource-Policy` (`SECURITY_CORP`), `Cross-Origin-Embedder-Policy` (`SECURITY_COEP`) — are sent only when configured, since a wrong value for any of them breaks a working application: COOP cuts the `window.opener` link an OAuth popup reports back through, CORP stops other origins embedding responses they embed today, and COEP blocks every cross-origin subresource that has not opted in. HSTS is sent regardless of the request scheme — a browser must ignore it over a non-secure transport, and a scheme check would suppress it behind a TLS-terminating proxy. A header already on the response is never replaced, so a single route can set its own. - `Middleware\MaxBodySizeMiddleware` — always the second global middleware, right after `ExceptionHandlerMiddleware`. Constructor-injects `Config` directly and reads `MAX_BODY_SIZE` (bytes, default `2097152`) once. Rejects a request whose declared `Content-Length` exceeds it with a `413` before `#[Body]` ever reads the body; underneath that, wraps every request's body in `Middleware\Support\SizeLimitedStream implements StreamInterface`, enforcing the same cap against the actual bytes read regardless of what — or whether — `Content-Length` claimed. `SizeLimitedStream::read()`/`getContents()` throw `Middleware\Exception\BodyTooLargeException` past the cap; `__toString()` reports an empty string instead, since `StreamInterface` forbids throwing there — which is why `Dispatcher::resolveBodyFromPlan()` reads the request body via `getContents()`, not a `(string)` cast. Only the raw JSON `#[Body]` path is guarded this way; a parsed multipart/form-urlencoded body is bounded by PHP's own `upload_max_filesize`/`post_max_size` instead. - `Middleware\GlobalMiddlewareDiscovery::discover(string $projectRoot, ?array $paths = null): list` — finds every `#[AsGlobalMiddleware]`-attributed class anywhere under a project's own PSR-4 root(s), plus `Kinetis\Http` itself, sorted by priority (descending, ties broken by class name). `$paths`, or `MIDDLEWARE_DISCOVERY_PATHS` when omitted, restricts the project-side scan. `discoverAll(string $projectRoot, ?array $paths = null): array{global: list, openApi: list, groups: array>}` performs exactly one project-wide scan and buckets by which of `#[AsGlobalMiddleware]`/`#[AsOpenApiMiddleware]`/`#[AsMiddlewareGroup]` a class carries, each sorted independently. The first two are flat lists (one pipeline each); `groups` is a map of group name to that group's own priority-sorted members, since `#[AsMiddlewareGroup]` is repeatable and a project can declare any number of independent groups. It always contains `GlobalMiddlewareDiscovery::OPENAPI_GROUP` (`openapi`), holding the `openApi` bucket, empty included: `DocumentationController` references it unconditionally and a route naming a missing group is a startup error — `discover()` is a thin wrapper returning just `['global']`, kept for any caller that only ever wanted that one list. - `Middleware\GlobalMiddlewareOrder::resolve(array $explicit, array $discovered): list` — computes the global-middleware order: `SecurityHeadersMiddleware` first, `ExceptionHandlerMiddleware` second, `MaxBodySizeMiddleware` third, then `merge($explicit, $discovered)`. `merge(array $explicit, array $discovered): list` is the plain explicit-then-discovered precedence rule with no fixed prepended classes, factored out so `Kernel`'s `openapi` group folding can reuse the identical rule without inheriting `SecurityHeadersMiddleware`/`ExceptionHandlerMiddleware`/`MaxBodySizeMiddleware`, which neither needs. `Kernel` and `Console\RoutesListCommand` each map either method's result through their own container afterward — both return plain class-strings. - `Middleware\RateLimitMiddleware` — opt-in, global or route. Fixed-window counter keyed by client IP (sha256-hashed), raised through `SimpleCache\Counter`. Construction requires the cache to implement `SimpleCache\AtomicCounterInterface` — `NullSimpleCache` and any other non-atomic cache both throw `Middleware\Exception\RateLimitUnavailableException`, since a cache that can only count by reading then writing back lets the limit be exceeded by every request that arrives concurrently: measured against real Redis, that fallback let a limit of 5 admit all 40 requests arriving together. The count is raised before the decision, so a rejected request counts too — harmless, since the key belongs to one window and the next uses another. `identifierFor()` only consults `X-Forwarded-For` when `REMOTE_ADDR` matches one of the constructor's `trustedProxies` CIDRs (empty by default — `REMOTE_ADDR` always used otherwise), walking the chain from the end backward past any further trusted hops. Construction also rejects a `maxAttempts` or `windowSeconds` below 1, and any `trustedProxies` entry that is not an address or CIDR range with a prefix length in its family's range, via `Middleware\Exception\InvalidRateLimitConfigException` — a zero window divides by zero, a negative one expires the counter on write so no limit is enforced, and an unparseable range decides who may set `X-Forwarded-For`. Not `final` — a subclass overriding the constructor defaults is how two routes get two different limits at once, since `#[Middleware]` carries only a class-string. `identifierFor()` is `protected`, not `private`, specifically so a subclass's override actually takes effect. - `Middleware\AuthenticatedRateLimitMiddleware` — extends `RateLimitMiddleware`, overriding `identifierFor()` to key by `CurrentUserInterface::id()` when one is already resolved on the current `RequestScope`, falling back to IP otherwise. Route middleware only, registered after the auth middleware that resolves `CurrentUserInterface` — never global, and never bound directly on `AppScope` (the same disconnected-`RequestScope` hazard `JwtAuthMiddleware` documents in {doc}`appendix-packages`). - `Middleware\CorsMiddleware` — opt-in, global only (a route-level registration would never see a preflight to an unmatched route). `allowedOrigins`/`allowedMethods`/`allowedHeaders`/`exposedHeaders`/`allowCredentials`/`maxAge`/`allowedOriginPatterns` constructor config; `allowedHeaders: ['*']` reflects the preflight's requested headers, `allowedOriginPatterns` matches origins by PCRE pattern, compiled at construction (an uncompilable one throws `InvalidArgumentException`) and required to match the whole `Origin` — a partial match is refused, so an unanchored pattern cannot widen what is allowed. Inspecting the pattern for `^`/`$` instead would not be sound: `#^https://good\.com$|evil\.com$#` carries both and is unanchored on its second branch. Echoes the specific origin (never a literal `*`) whenever credentials are allowed, per spec. - `Routing\Router` / `Routing\Route` — `#[Get]`/`#[Post]`/`#[Put]`/`#[Patch]`/`#[Delete]` discovery, path-template compilation, `toArray()`/`fromArray()` for the AOT cache. `register()` goes through `Kinetis\Reflection\AttributeScope` first: the controller must be a concrete class, and each routed method must be declared by it (a trait method counts, an inherited one does not). A class-level `#[RoutePrefix]` is resolved here too, so the stored `pathTemplate` is the finished path and nothing downstream needs to know a prefix existed. `register()` rejects a path or prefix that doesn't start with `/` (`Routing\Exception\InvalidRoutePathException`) — a route path is absolute, and the empty string would resolve to `/` and claim the root route. `Route` then normalizes every path it is given to one canonical form — leading slash, no trailing one, `/` unchanged — in its own constructor, so it holds for `fromArray()` too; `/users` and `/users/` are therefore the same route and declaring both is a duplicate. `matchPath()` normalizes the request path through the same rule, so `/users/` reaches a route registered as `/users`; `Kernel` applies it to its own `/mcp` comparison too, that being a literal check rather than a registered route. `register()` also rejects a route claiming exactly the same requests as an already-registered one (`Routing\Exception\DuplicateRouteException`), compared via `Route::conflictKey()` — method plus path shape with placeholder names normalized away, constraint patterns kept. Overlapping-but-distinct routes stay legal, ordered first-match-wins; `fromArray()` skips the check, since a compiled cache's routes already passed it when the cache was built. `Route`'s own constructor rejects two further mistakes at registration time, before either 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), and a `{name:pattern}` constraint whose fragment doesn't compile as a regex (`Routing\Exception\InvalidRouteConstraintException` for both) — this reaches `fromArray()` too, since it runs through the identical constructor. - `Routing\RouteDiscovery` — builds a `Router` from every class found anywhere under a project's own PSR-4 root(s), plus `Kinetis\Http` itself, mirroring `Kinetis\Console\CommandDiscovery`/`Kinetis\Mcp\McpDiscovery`. `discover(string $projectRoot, ?array $paths = null)` — `$paths`, or the `ROUTE_DISCOVERY_PATHS` env var when omitted, restricts the project-side scan to one or more sub-paths relative to each PSR-4 base directory. - `Attributes\{Get,Post,Put,Patch,Delete,Body,Query,Middleware,Response,Hidden,RoutePrefix,PaginatedItem}` — the route/binding/middleware/OpenAPI-documentation attributes. `Hidden` (class- or method-level) excludes a route from the generated OpenAPI document without affecting routing/dispatch; the class-level form is read from the controller the route is registered on. `RoutePrefix(string $prefix)` (`TARGET_CLASS`) prepends a path segment to every route on the controller, resolved at registration; it must start with `/` like any declared path, `join()` leaves stray trailing slashes to `Route`'s own normalization, and a route declaring `/` sits at the prefix itself. It is what makes a trait of shared route methods mountable at a different path per controller — see {doc}`routing-validation`. `PaginatedItem(class-string $itemClass)` (`TARGET_METHOD`) names the item class a `Paginator`/`CursorPaginator` return actually wraps, purely for `OpenApiGenerator::paginatedResponseSchema()` — see below. `Attributes\AsGlobalMiddleware`/`AsOpenApiMiddleware` (each `{priority: int = 50}`, bounded `0`-`100`, throwing `InvalidArgumentException` outside that range) are the opposite direction from `Middleware` — they live on the middleware class itself, not on a controller referencing one — and are what `Middleware\GlobalMiddlewareDiscovery` looks for. `Attributes\AsMiddlewareGroup` (`{name: string, priority: int = 50}`, `IS_REPEATABLE`, same bounds plus a non-empty-name check) declares group membership on the same side; a route references the group via `#[Middleware('@name')]`, `Middleware::GROUP_PREFIX` being that `@`. Group membership alone never makes a class run — only a route referencing the group does. `AsOpenApiMiddleware` reaches `/openapi.json`+`/openapi` through the group mechanism — those are ordinary routes on `Http\OpenApi\DocumentationController`, so its classes are published as the built-in `openapi` middleware group that controller references — see {doc}`middleware`. `AppScope::openApiMiddleware()` is its explicit-registration counterpart, mirroring `AppScope::middleware()`. `/mcp` is covered by an ordinary `mcp` group `kinetis/mcp`'s controller references — join it with `#[AsMiddlewareGroup('mcp')]`. - `Responses\HtmlResponse` / `PlainTextResponse` / `FileResponse` / `RedirectResponse` / `ErrorResponse` — static factories over `Nyholm\Psr7\Response`, not distinct `ResponseInterface` implementations (unlike `StreamedResponse`); each builds a plain response with the right headers/body already set. `FileResponse::fromPath()` detects the content type with PHP's bundled `finfo` when `$contentType` is omitted; `fromContents()` takes the same parameters for in-memory data. `$downloadFilename` is treated as untrusted: written as an RFC 6266 quoted-string with `\` and `"` escaped, so a name cannot close the quoting and append a second `filename` parameter; a non-ASCII name also travels as RFC 8187 `filename*=UTF-8''…` beside an underscore-substituted ASCII fallback; a control character or an empty string throws `Exception\FileResponseException`. Path separators are left to the recipient to strip, per RFC 6266. `Kernel::error()`/`Middleware\ExceptionHandlerMiddleware`'s own 404/405/500 responses are built through `ErrorResponse::create()` too, the same helper a controller uses. - `Pagination\Paginator` (`data`, `currentPage`, `perPage`, `total`, `lastPage`) / `Pagination\CursorPaginator` (`data`, `nextCursor`, `hasMore`) — plain `readonly` result envelopes with no dependency on `kinetis/query-builder` or any other source; `kinetis/query-builder`'s `Query::paginate()`/`cursorPaginate()` (see {doc}`appendix-packages`) are the convenient way to build one from a real query, not the only way. ## `Kinetis\Events` - `EventDispatcher` — implements `Psr\EventDispatcher\EventDispatcherInterface`. Never explicitly registered; autowired fresh per request through `RequestScope`, constructor-injecting `RequestScope`, `EventListenerRegistry`, and `ListenerInvokerInterface`. `dispatch()` stops at a listener once `Psr\EventDispatcher\StoppableEventInterface::isPropagationStopped()` returns `true`. - `Listener` — a `TARGET_METHOD` attribute, `{priority: int = 50}` (bounded `0`-`100`, throwing `InvalidArgumentException` outside that range); the event class is inferred from the method's own single parameter type. - `EventListenerRegistry` — reflects every public `#[Listener]` method on a registered class, the same shape as `Router`/`McpRegistry`. Exact event-class matching only. Each event's own list is re-sorted (priority descending, ties broken alphabetically by class then method name) on every `register()` call that adds to it. `listenersFor(class-string): list`. `toArray()`/`fromArray()` for the AOT cache. - `EventListenerDiscovery::discover(string $projectRoot, ?array $paths = null): EventListenerRegistry` — builds a registry from every class found anywhere under a project's own PSR-4 root(s), plus `Kinetis\Events` itself, rather than an explicit `bootstrap.php` registration. `$paths`, or `LISTENER_DISCOVERY_PATHS` when omitted, restricts the project-side scan. - `ShouldQueue` — a marker interface a listener implements to be invoked through `ListenerInvokerInterface` instead of directly. - `ListenerInvokerInterface` / `SynchronousListenerInvoker` — the seam a `ShouldQueue` listener's invocation is routed through; `AppScope::boot()` registers the synchronous default automatically. `kinetis/queue`'s `QueuedListenerInvoker` (see {doc}`appendix-packages`) implements this to actually defer invocation. ## `Kinetis\Runtime` - `RuntimeAdapterInterface` + `RuntimeDetector::detect()` — picks `FrankenPhpAdapter` or `FpmAdapter` based on `function_exists('frankenphp_handle_request')`; picks `Kinetis\BrefAdapter\BrefLambdaAdapter` (separate `kinetis/bref-adapter` package — `class_exists()`-gated, not a hard reference) when `getenv('AWS_LAMBDA_RUNTIME_API')` is set and that package is installed, otherwise throws `RuntimeUnavailableException::missingAdapterPackage()`. Both signals are also accepted as optional `detect()` parameters so tests can exercise every branch without faking global PHP/process state. - `Adapters\FrankenPhpAdapter` — `run()` is a `do`/`while` loop calling `frankenphp_handle_request()` repeatedly for as long as it returns `true`; `isPersistent(): true`. - `Adapters\FpmAdapter` — `run()` handles exactly one request from superglobals, calling `fastcgi_finish_request()` when available so the response flushes before any post-response cleanup; `isPersistent(): false`. - `AppEnvironment` — `Development`/`Production` enum. `detect()` reads `APP_ENV`; unset or unrecognized → `Production`. - `ProjectRoot::detect()` — resolves the consumer project root, accounting for Composer's `vendor/bin/kinetis` proxy. - `SuperglobalsBridge` — PSR-7 ⇄ superglobal conversion, shared by `FrankenPhpAdapter`/`FpmAdapter`. Also runs PHP 8.4's `request_parse_body()` for a `PUT`/`PATCH` multipart or url-encoded body, which `fromGlobals()` alone doesn't populate. - `Exception\RuntimeUnavailableException` — `missingFunction()`, `missingEnvironmentVariable()`, `missingAdapterPackage()`. ## `Kinetis\Config` - `Config` — typed environment access: `get()`, `string()`, `int()`, `float()`, `bool()`, `required()`. - `Config::scopedKey(string $key, string $connection = 'default'): string` — the named-connection convention every technology-specific connection builder shares. `'default'` returns `$key` unchanged; any other name inserts itself, uppercased, after the key's own prefix (`REDIS_HOST` + `cache2` → `REDIS_CACHE2_HOST`). - `EnvFile::safeLoad(string $projectRoot)` — loads `.env` via `vlucas/phpdotenv`, called unconditionally in `public/index.php` and `bin/kinetis`, before `AppEnvironment::detect()`. ## `Kinetis\Logging` - `ErrorLogLogger` — a minimal PSR-3 logger writing through `error_log()`, with `{placeholder}` context interpolation and the class/`file:line` of a Throwable under the `exception` context key appended. `AppScope::boot()`'s default `LoggerInterface` binding in development; a consumer-registered logger wins in every environment. ## `Kinetis\Async` - `Socket` — non-blocking TCP, Fiber-suspending `connect()`/`read()`/`write()`. - `Timer::delay()` — Fiber-suspending delay. - `concurrently(array $tasks)` — runs each task in its own `Fiber` drawn from `FiberPool`, collects results in task order, and rethrows the first failure (in task order) once every task has finished. Nested calls are supported. A task that suspends with nothing registered to resume it surfaces as `Exception\DeadlockException` naming the task's index. - `FiberPool` — resident worker Fibers that park between jobs instead of terminating, so the steady state allocates no Fiber stacks (per-task `mmap`/`munmap` churn serializes every thread of a ZTS process against the kernel's address-space lock). Per PHP thread, retains at most 64 idle residents; a Fiber suspended mid-job is never returned to service. `@internal` — only `concurrently()` submits jobs. - `ConcurrentBatch` — one `concurrently()` call's coordination state: records each task's result or failure, parks the caller on a Revolt suspension the last task resumes, diagnoses deadlocks by first unfinished index, and assembles results. `@internal`. ## `Kinetis\Persistence` Core itself only ships `Pool` here — `TransactionGuard`/`SqlConnectionFactory` live in the separate `kinetis/persistence` package (see {doc}`appendix-packages`), since they're the only classes in this namespace with a real MySQL/Postgres dependency. - `Pool` — generic connection-pool infrastructure, not used by `kinetis/persistence`'s own MySQL/Postgres/Redis integration (the persistence drivers and `amphp/redis` already pool internally); kept here in case a future hand-rolled protocol client needs it. - `Exception\PoolExhaustedException`. ## `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`/`ClusteredRedisSimpleCache`/`Cluster\*`/`Connection\TlsRedisConnector` all live in the separate `kinetis/cache-redis` package (see {doc}`appendix-packages`), since they're the only classes in this namespace with a real Redis dependency. - `AtomicCounterInterface` — `increment(string $key, int $ttlSeconds): int` and `count(string $key): int`, implemented alongside `CacheInterface` by a backend that can count without reading first. PSR-16 has no such operation, and building one from `get()` then `set()` is not safe across processes. A counter is stored in whatever form the backend increments natively — a Redis `INCR` counter holds a bare integer where the cache otherwise stores serialized values — so it is read through `count()`, never `get()`. - `AtomicConsumeInterface` — `consume(string $key, mixed $default = null): mixed`, implemented alongside `CacheInterface` by a backend that can read and delete a key in one operation. PSR-16 has no such operation either, and a `get()` then a separate `delete()` is not safe across processes: two concurrent callers can both read the value before either deletes it. Required by `Kinetis\AuthJwt\RefreshTokenStore`, which refuses a cache without it — unlike the counter above, there's no safe read-then-write fallback for "consume once," so this one has no soft-degrade path. - `Counter` — wraps any `CacheInterface` and counts through `AtomicCounterInterface` when the cache provides it, falling back to read-then-write when it does not. `isAtomic()` reports which. `Http\Middleware\RateLimitMiddleware` and `Security\AttemptThrottle` both use it, but reject any cache lacking `AtomicCounterInterface` at their own construction, so the fallback path is never actually reachable through either. - `NullSimpleCache` — the default when Redis isn't configured, or when `kinetis/cache-redis` isn't installed at all. Always misses, never stores. - `Exception\CacheException` / `Exception\InvalidArgumentException` — implement the matching `Psr\SimpleCache\*` exception interfaces; reused by `kinetis/cache-redis`'s own classes, not redeclared there. - `UnavailableSimpleCache` — bound when Redis is configured (`REDIS_HOST`/`REDIS_URL`/`REDIS_CLUSTER`) but `kinetis/cache-redis` isn't installed. Every operation throws `Exception\SimpleCacheUnavailableException` naming the package; nothing is silently discarded, and nothing fails until the cache is actually used, so a leftover `REDIS_*` in a `.env` leaves an application that never touches the cache unaffected. It implements `AtomicCounterInterface`/`AtomicConsumeInterface` too, so a counter or `RefreshTokenStore` built on it fails on first use naming the package to install rather than being rejected at construction for lacking the interface. The same usage-time-over-configuration-time trade `RateLimitMiddleware`/`RevocationStore` make by rejecting `NullSimpleCache` at construction rather than at boot. - `Exception\SimpleCacheUnavailableException` — thrown by every `UnavailableSimpleCache` operation, naming `kinetis/cache-redis`. ## `Kinetis\Security` - `AttemptThrottle` — an identifier-keyed failure counter for anything failure-prone (a password check, a 2FA code, an invite redemption), not middleware: whether an attempt failed is only known once application code runs it, so `recordFailure()`/`clear()` are called directly. `tooManyAttempts()`/`availableInSeconds()` read the current lockout; each failure refreshes the window to `decaySeconds` from that failure, so repeated attempts keep extending it. Identifiers are sha256-hashed (PSR-16 forbids `@` in a key). Counts through `SimpleCache\Counter`, but requires the cache to implement `SimpleCache\AtomicCounterInterface` — `NullSimpleCache` and any other non-atomic cache are both refused at construction with `Exception\AttemptThrottleUnavailableException`, since a cache that can't count atomically lets failures arriving together — how a password is actually attacked — register as one. `maxAttempts`/`decaySeconds` below 1 are rejected too, with `Exception\InvalidAttemptThrottleConfigException`. ## `Kinetis\Cache` - `Compiler::compile()` / `compileProject()` — walks a `Router`/`CommandRegistry`/`EventListenerRegistry`, derives binding/validation plans, produces a `CompiledCache`. `compileProject()` builds them via `RouteDiscovery`/`CommandDiscovery`/`EventListenerDiscovery`. MCP is not compiled here — `kinetis/mcp` discovers live on first resolution of its server. - `HttpCache` / `CommandCache` / `EventCache` — the three independent artifacts. `HttpCache::$packageBootstraps` and `CommandCache::$packageBootstraps` each carry the `extra.kinetis` bootstrap-class list, so production loads it from whichever artifact its entry point already reads instead of re-reading `vendor/composer/installed.json`. `HttpCache::$middlewareGroups` carries the `#[AsMiddlewareGroup]` map; a route's own `middleware` list stores raw references (a class-string or a `@name`) and is never expanded at compile time, so a group's membership can change without recompiling every route that references it. `CacheStore` writes them via atomic tmp-file + `rename()`, reads via `require`. An object anywhere in an artifact (in practice, a constructor default like `new DateTimeImmutable()` captured into a plan) fails the write with `Exception\CacheWriteException::unexportableObject()` naming the path to it — `var_export()` would render it as a `::__set_state()` call the reload can't replay, so a clear build-time error replaces a corrupt artifact. - `NamespaceScanner::classesInProject(string $projectRoot, array $paths = [])` — finds every class reachable from any PSR-4 prefix a project's own `composer.json` declares, at any depth, with no directory/namespace convention required; `$paths` restricts the walk to one or more sub-paths relative to each PSR-4 base directory, deduplicated internally when `$paths` names overlapping sub-paths. `classesUnderFrameworkSegment(string $segment, ?string $frameworkRoot = null)` — the framework-side counterpart, walking one fixed segment ("Console", "Http", "Events") under Kinetis's own package root specifically. `classesUnderPackageRoots(array $roots)` — the installed-package counterpart, walking the concrete prefix/directory pairs `PackageDiscovery::scanRoots()` resolves. Both skip a file entirely (no `class_exists()` autoload) unless it contains at least one PHP attribute, found via a cheap token scan rather than a full parse — what keeps an unrestricted, whole-project scan affordable on every request under PHP-FPM. Deduplicating a class found through *both* methods together (developing Kinetis itself makes the framework root and project root the same repository) is each Discovery class's own responsibility, not `NamespaceScanner`'s — each Discovery class (`kinetis/mcp`'s `McpDiscovery` included) merges both calls through their own `$seen` set (or, for `GlobalMiddlewareDiscovery`, a class-string-keyed priority map) before ever registering anything. - `PackageDiscovery` — reads `vendor/composer/installed.json` for packages declaring `extra.kinetis`. `scanRoots()` resolves each package's comma-separated `scan` prefixes against its own PSR-4 map into concrete directories (fed to `NamespaceScanner::classesUnderPackageRoots()` by every Discovery class); `bootstrapClasses()` collects the declared `bootstrap` classes. A prefix outside the package's own roots, or a missing bootstrap class, is logged via `error_log()` and skipped rather than failing the whole scan. - `RoutesFile::loadBootstrap(string $projectRoot, ?array $packageBootstraps = null)` — composes the bootstrap chain run with `(AppScope, Config)` before `boot()` locks bindings: each package's `PackageBootstrapInterface` class first (from the given precompiled list, or `PackageDiscovery::bootstrapClasses()` live when `null`), then the consumer's own `bootstrap.php` — last, so an application binding wins over a package's for the same id. Routes, commands, global middleware, and event listeners are all found by namespace instead (`RouteDiscovery`/`CommandDiscovery`/`GlobalMiddlewareDiscovery`/`EventListenerDiscovery`). - Not part of this cache: `Kinetis\Config` (`.env`/environment values) — the cache is rebuilt from source via `bin/kinetis build`; environment configuration isn't. ## `Kinetis\Validation` / `Kinetis\OpenApi` - `Hydrator` — builds and validates a `#[Body]`-bound DTO from constructor-parameter reflection and `Constraint`-implementing attributes (`#[Email]`, `#[NotBlank]`, `#[MinLength]`, `#[MaxLength]`, `#[GreaterThan]`, `#[LessThan]`, `#[Regex]`, `#[In]`, `#[Url]`, `#[Uuid]`). A missing key on a defaultless parameter is `is required.`; an explicitly-null value for a parameter whose declared type doesn't allow null is `must not be null.` — both 422 errors, never a raw `TypeError` from the constructor. A class-typed constructor parameter is hydrated as a nested DTO, recursively, whenever the corresponding value is an array; `compilePlan()` embeds each nested class's own plan inline (`nestedPlan`), stopping at a repeated class to stay `var_export()`-representable. - `JsonSchema` — the type/constraint → JSON Schema mapping shared by `OpenApiGenerator` and MCP tool input schemas. An optional `$classSchema` callback lets a caller substitute something other than inlining for a nested class-typed parameter's schema; `null` (every MCP call site) keeps inlining. - `OpenApi\OpenApiAccess` — whether this process serves `/openapi.json`/`/openapi`. `fromConfig()` matches `OPENAPI_ENVIRONMENTS` (comma-separated, case- and space-insensitive) against the raw `APP_ENV`, treating an absent one as `production`; `enabled()`/`disabled()` decide outright. Resolved per request rather than at route registration, so a compiled cache cannot bake the decision. - `Http\OpenApi\DocumentationController` — serves both paths as ordinary discovered routes, `#[Hidden]` so they stay out of the document and `#[Middleware('@openapi')]` so `#[AsOpenApiMiddleware]` applies. Generates per request in development; in production caches under `CACHE_KEY` in the bound `CacheInterface` with no expiry, cleared by `Console\OpenApiClearCommand`. A disabled path answers with `Kernel`'s own unmatched-path 404, byte for byte. - `Console\OpenApiClearCommand` — `openapi:clear`, drops that cache entry. Run it on deployment alongside `build`; safe when nothing is cached. - `OpenApiGenerator::generate()` — builds the OpenAPI 3.1 document from a `Router`'s registered routes, deduplicating every DTO schema (request body, response, or nested at any depth) into `components/schemas` with `$ref`, and deriving the default response's schema from the controller method's declared return type. Repeatable `#[Response(status, description)]` 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. `paginatedResponseSchema()` special-cases a `Paginator`/`CursorPaginator` return: with a `#[PaginatedItem]` attribute present, `data` describes as an array of the named class's own (deduplicated) schema, built inline rather than through `schemaRefFor()` for the wrapper itself — a shared "Paginator" component would otherwise collapse two different routes' different item types into one; without the attribute, `data` stays the bare `{type: object}` fallback. ## `Kinetis\Console` - `Attributes\Command` — `TARGET_METHOD`, `{name, description, bootstrap}`. Discovered by `CommandRegistry::register()` the same way `Router`/`McpRegistry` discover their own attributes. `bootstrap: false` makes `bin/kinetis` skip the application's `bootstrap.php` (and the transaction-guard hook) before dispatch — for commands that only operate on the project's static shape and must run without the configuration the application's services demand. - `CommandRegistry` — validates each `#[Command]` method's signature at registration time (zero parameters, or exactly one parameter typed `CommandArguments`; anything else throws `Exception\InvalidCommandException`), and rejects a duplicate command name across two different registrations. `commands(): list`, `findCommand(string $name): ?CommandDefinition`, `toArray()`/`fromArray()` for the AOT cache. - `CommandDiscovery::discover(string $projectRoot, ?array $paths = null): CommandRegistry` — builds a registry from every class found anywhere under a project's own PSR-4 root(s), plus `Kinetis\Console` itself (`Kinetis\Cache\NamespaceScanner`), rather than an explicit registration file. `$paths`, or `COMMAND_DISCOVERY_PATHS` when omitted, restricts the project-side scan. - `CommandArguments` — injected by type into a command method, the same by-type special-casing `ProgressReporter` already gets for MCP tools. `parse(array $argv)` splits into positional values (`get(int)`, `all()`) and `--key=value`/bare-`--flag` options (`option(string, ?string)`, `hasOption(string)`). - `CommandDispatcher::run(CommandDefinition, list $arguments): int` — resolves the controller through the container and invokes it; no per-call reflection, since `CommandRegistry::register()` already validated the signature. The method's own return value becomes the exit code (`int` used directly, `void`/`null` means `0`). - `BuildCommand` — `#[Command('build', bootstrap: false)]` — cache pre-warming needs no application configuration (no database credentials in a CI pipeline). The one command that has to be found before it can be used to build anything, via `bin/kinetis`'s own lazy-generate-on-first-run bootstrap. Always removes `.kinetis-cache/` before writing a fresh one; `--destroy` removes it and stops there, without writing anything back. - `RoutesListCommand` — `#[Command('routes:list')]`. A read-only introspection tool, not a caching mechanism: runs `RouteDiscovery`/`GlobalMiddlewareDiscovery` live (regardless of `APP_ENV`) and prints the result — never touches `.kinetis-cache/`. Declares `bootstrap: false` and constructs its own throwaway `AppScope` running `bootstrap.php` once to read `AppScope::middlewares()`, since `AppScope` itself is never registered onto the `RequestScope` a command is dispatched through. `$output` (a `resource`, defaulting to `STDOUT`) is an appended constructor parameter for testability against `php://memory` — the same reason `StdioTransport`'s input/output streams are injectable — since a `#[Command]` method itself must stay parameter-free or take exactly one `CommandArguments`. - `bin/kinetis` — has no hardcoded verbs at all. In production, loads `CommandCache` (auto-generating it, via a full `Compiler::compileProject()`, on the first invocation that finds none); in development, builds the registry live via `CommandDiscovery::discover()`. Every name — the built-in `build`/`routes:list`, anything a package contributes (`mcp:serve`, `queue:work`), and the application's own — is looked up in that same registry. One fresh `RequestScope` per invocation, with `Kinetis\Persistence\TransactionGuard::rollbackDangling()` registered as a dispose hook whenever that class is available — the same `class_exists()`-gated safety net `Kernel` gives every HTTP request, since `TransactionGuard` lives in the optional `kinetis/persistence` package, not core. An uncaught exception is logged through the container's `LoggerInterface` and produces exit code `1`; a missing or unknown command name lists every available command, one per line, and also exits `1`. ## `Kinetis\Instrumentation` - `TelemetryInterface` — the framework's instrumentation vocabulary: started/ended hook pairs joined by an opaque token (route match, middleware, hydration, controller, response encoding, queries with a server-started pool boundary, transactions, `concurrently()` batches and tasks, events/listeners, MCP calls, queue push and jobs), plus `phase()` for pre-container lifecycle phases reported with explicit timestamps; `jobPushMetadata()` returns opaque string metadata a queue backend stores with the job and hands back through `jobStarted()` — the propagation channel that joins producer and consumer spans into one trace across processes. Deliberately broad while under evaluation, and not a consumer extension point — its implementors are `NullTelemetry` and kinetis/telemetry's backend only, so the hook set can be thinned by measurement without a breaking change to anyone else. - `NullTelemetry` — the no-op default backend. - `Telemetry` — the swappable holder every call site talks to, with a per-process `global()` accessor (a documented `NoStaticPropertiesRule` exemption, the FiberPool class of worker-lifetime infrastructure). `AppScope::boot()` binds it as the `TelemetryInterface` default so app code can inject it; kinetis/telemetry's package bootstrap `swap()`s in the OTel backend. Measured no-op cost: ~90ns per hook pair, one to two microseconds per fully hooked dispatch. ## `Kinetis\Reflection` - `AttributeScope` — the one place that decides where attributes are read from, shared by every registry that reflects a class for them: `Http\Routing\Router`, `Console\CommandRegistry`, `Mcp\McpRegistry`, `Events\EventListenerRegistry`, and (via `Cache\NamespaceScanner`) `Http\Middleware\GlobalMiddlewareDiscovery`. `reflect(class-string): ReflectionClass` rejects an abstract class, interface, trait or enum with `Exception\AttributeScopeException::notRegistrable()`. `declares(ReflectionMethod, class-string): bool` and `assertDeclares()` answer whether the registered class declares a method itself — a trait method does, since PHP reports its declaring class as the using class; an inherited one does not, and registering it throws `AttributeScopeException::inheritedMethod()` naming both classes. `isRegistrable(string): bool` is the silent counterpart `NamespaceScanner` uses, so discovery skips what registration rejects rather than failing the application over an abstract base under a scanned namespace. ## `Kinetis\Linting` - `NoStaticPropertiesRule` — a PHPStan rule flagging `static` property declarations, shipped under the main autoload for consumer projects to add to their own `phpstan.neon`. ## `Kinetis\Testing` - `TestClient` — wraps a `Kernel`. `get()`/`post()`/`put()`/`patch()`/`delete()` build a PSR-7 request and dispatch it; a `body` array is JSON-encoded with `Content-Type: application/json` set unless already provided. `request()` is the general form all five call. Every method returns a `TestResponse`. - `TestResponse` — the response with assertions attached (`assertStatus`/`assertOk`/`assertJson`/`assertJsonPath`/`assertValidationError`, ...), each returning `$this` for chaining. Implements `ResponseInterface` itself and delegates, so it passes anywhere plain PSR-7 is expected. Failure messages include the response body; `body()` rewinds before reading, so the body can be read repeatedly. - `TestApplication` — boots a real application from a project root: live discovery (routes, middleware, listeners), the package-then-app bootstrap chain, a booted `AppScope`, a real `Kernel`. `boot(string $projectRoot, array $configOverrides = [], ?callable $beforeBoot = null)` merges overrides over the environment — including `APP_ENV`, registered as the container's `AppEnvironment` from the merged config, since `AppScope::boot()`'s own default reads `getenv()` and would never see the override. `$beforeBoot` runs after the application's own `bootstrap.php` and before `boot()` locks the container — the only window in which a test double replaces a binding the application made. `withRouter()` builds from an explicit route table instead of discovery. No PHPUnit dependency. - `ApplicationTestCase` — the PHPUnit base class over `TestApplication`: boots per test via `#[Before]` (so it runs ahead of any trait-declared hook in the concrete class, `kinetis/persistence`'s isolation traits included), exposing `$client`/`$app`/`$application`. Override `projectRoot()` (required), `configOverrides()`, and `registerTestDoubles(AppScope $app, Config $config)` for services a test should not reach — see {doc}`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. ## `Kinetis\Testing\Runtime` The runtime adapter conformance suite — see {doc}`testing`. - `RuntimeAdapterConformanceTestCase` — abstract PHPUnit base class holding every behavior all adapters must agree on, one `final` test method each: request line and query (including a numeric parameter name's int-key coercion, pinned as the shared outcome), a single and a repeated header (the repeat arrives comma-joined everywhere), a numeric header name, cookies into both the `Cookie` header and `getCookieParams()`, `REMOTE_ADDR`, url-encoded and multipart bodies (`POST`, `PUT` and `PATCH` alike), JSON left unparsed, the declared `Content-Length` delivered, a 1 MiB raw body whole, binary and empty and `"0"` bodies, response status/headers, a comma inside one header value, two `Set-Cookie` as two cookies, a binary response body, streaming delivered — timed on the wire, so a proxy that holds the stream until the end fails it — or refused, per the driver's declaration, and a body the environment can't parse answered with a `400` carrying `RuntimeAdapterInterface::MALFORMED_BODY_MESSAGE` and no handler run. `driver()` is the one abstract method; `assertMalformedBodyResponse(WireResponse)` is public and static, so an adapter's own tests hold environment-specific malformed inputs to the same 400 contract. - `RuntimeAdapterDriver` — what an adapter provides: `dispatch(WireRequest, ResponseSpec): Outcome`, plus the environment-decided facts `expectedClientIp()`, `supportsStreaming()`, `unparseableFormRequest()` — declared by the driver and asserted against, never used to skip. - `WireRequest` — method, path, query string, headers as a list of pairs (repeats preserved), cookies as `name=value` strings, raw body. `json()` is the JSON-body shorthand. - `ResponseSpec` — what the handler answers with, as data: status, headers, `Set-Cookie` values, body, or `streamChunks` for a `StreamedResponse` with `streamDelayMs` between them (the emitter closes any output buffer first, then writes and flushes each chunk). `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. - `ObservedRequest` — the PSR-7 request the handler received, flattened: method, path, query and params, headers, cookie params, `REMOTE_ADDR`, parsed body, uploaded files (field, filename, media type, contents), raw body. `fromServerRequest()`, `header()` (case-insensitive), `toArray()`/`fromArray()`. - `WireResponse` — the response as the environment received it: status, headers as pairs, `Set-Cookie` values separately, body bytes, and `bodyArrivalSpanSeconds` — the time between the first and last body byte reaching the client, the evidence a stream was delivered as written rather than buffered (`null` for a driver with no wire to time). `header()` is case-insensitive. - `AdapterRejection` / `Outcome` — an adapter that refused to respond at all (exception class and message), and the pair a driver reports: the observed request (`null` when the handler never ran) and either a `WireResponse` or a rejection. ## Request lifecycle, in order 1. A `RuntimeAdapterInterface` receives the request and converts it to PSR-7. 2. `Kernel::handle()` runs the global `MiddlewarePipeline`. 3. Inside it: `AppScope::createRequestScope()`, then `TransactionGuard::rollbackDangling()` registered as a dispose hook when `kinetis/persistence` is installed. 4. `Router::match()` resolves a `Route`, or throws `RouteNotFoundException`/`MethodNotAllowedException` (→ 404/405). 5. The route's `#[Middleware]` pipeline runs, wrapping `Dispatcher::dispatch()`. 6. `Dispatcher` resolves parameters (via a compiled plan if `HttpCache` is present, live reflection otherwise), invokes the controller. 7. `RequestScope::dispose()` runs in a `finally` block; `gc_collect_cycles()` runs if the adapter is persistent. ## See also - {doc}`appendix-packages` — the same reference map for every optional satellite package. - {doc}`appendix-ci` — what actually runs in CI, and what's deliberately not covered. - {doc}`appendix-contributing` — the monorepo layout, dev environment setup, and how to actually make a change. - {doc}`core-concepts`, {doc}`container`, {doc}`config`, {doc}`routing-validation`, {doc}`middleware`, {doc}`logging`, {doc}`runtime-adapters`, {doc}`concurrency`, {doc}`persistence`, {doc}`mcp`, {doc}`caching`, {doc}`cli`, {doc}`testing` — the task-oriented page for each namespace above.