Appendix: System Layout

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

Kinetis\Container

  • AppScope — the persistent, worker-lifetime container. bind()/instance()/middleware() before boot(); locked after. Only an explicit registration creates a singleton: get() on an unregistered class autowires a fresh instance per call, never cached — the same “never promoted” guarantee RequestScope makes, applied to the parent scope’s own API. boot() registers defaults if not already set: Kinetis\Runtime\AppEnvironment → the detected environment, Psr\Log\LoggerInterfaceKinetis\Logging\ErrorLogLogger in development / NullLogger in production, Kinetis\Config\ConfigConfig::fromEnvironment(), Psr\SimpleCache\CacheInterfaceKinetis\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.

  • PackageBootstrapInterfaceregister(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 Appendix: Satellite 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<class-string> — finds every #[AsGlobalMiddleware]-attributed class anywhere under a project’s own PSR-4 root(s), plus Kinetis\Http itself, sorted by priority (descending, ties broken by class name). $paths, or MIDDLEWARE_DISCOVERY_PATHS when omitted, restricts the project-side scan. discoverAll(string $projectRoot, ?array $paths = null): array{global: list<class-string>, openApi: list<class-string>, groups: array<string, list<class-string>>} 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<class-string> — computes the global-middleware order: SecurityHeadersMiddleware first, ExceptionHandlerMiddleware second, MaxBodySizeMiddleware third, then merge($explicit, $discovered). merge(array $explicit, array $discovered): list<class-string> is the plain explicit-then-discovered precedence rule with no fixed prepended classes, factored out so Kernel’s openapi group folding can reuse the identical rule without inheriting SecurityHeadersMiddleware/ExceptionHandlerMiddleware/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\AtomicCounterInterfaceNullSimpleCache 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 Appendix: Satellite 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 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 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 Appendix: Satellite 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<array{class, method, priority}>. 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 Appendix: Satellite 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\FrankenPhpAdapterrun() is a do/while loop calling frankenphp_handle_request() repeatedly for as long as it returns true; isPersistent(): true.

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

  • AppEnvironmentDevelopment/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\RuntimeUnavailableExceptionmissingFunction(), 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 + cache2REDIS_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 Appendix: Satellite 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 Appendix: Satellite Packages), since they’re the only classes in this namespace with a real Redis dependency.

  • AtomicCounterInterfaceincrement(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().

  • AtomicConsumeInterfaceconsume(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\AtomicCounterInterfaceNullSimpleCache 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\OpenApiClearCommandopenapi: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\CommandTARGET_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<CommandDefinition>, 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<string> $arguments): int — resolves the controller through the container and invokes it; no per-call reflection, since CommandRegistry::register() already validated the signature. The method’s own return value becomes the exit code (int used directly, void/null means 0).

  • BuildCommand#[Command('build', bootstrap: false)] — cache pre-warming needs no application configuration (no database credentials in a CI pipeline). The one command that has to be found before it can be used to build anything, via bin/kinetis’s own lazy-generate-on-first-run bootstrap. 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 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 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