Appendix: System Layout¶
A reference map of what exists in core, by namespace. For the optional
satellite packages (kinetis/auth, kinetis/queue, kinetis/storage, and
so on), see Appendix: Satellite Packages.
Kinetis\Container¶
AppScope— the persistent, worker-lifetime container.bind()/instance()/middleware()beforeboot(); locked after. Only an explicit registration creates a singleton:get()on an unregistered class autowires a fresh instance per call, never cached — the same “never promoted” guaranteeRequestScopemakes, applied to the parent scope’s own API.boot()registers defaults if not already set:Kinetis\Runtime\AppEnvironment→ the detected environment,Psr\Log\LoggerInterface→Kinetis\Logging\ErrorLogLoggerin development /NullLoggerin production,Kinetis\Config\Config→Config::fromEnvironment(),Psr\SimpleCache\CacheInterface→Kinetis\SimpleCache\ClusteredRedisSimpleCache/RedisSimpleCache::fromConfig()(bothclass_exists()-gated against the optionalkinetis/cache-redispackage) when Redis is configured, elseNullSimpleCache— configured but not installed bindsUnavailableSimpleCache, which throws on use rather than at boot.RequestScope— the per-request container, created byAppScope::createRequestScope(), which also registers the scope onto itself (RequestScope::classresolves to that exact instance), disposed byKernelin afinallyblock. Delegates toAppScopefor explicitly registered ids only; autowires anything else, discarded ondispose().appScope()exposes the parent, for a worker-loop command that mints its own per-job scopes.PackageBootstrapInterface—register(AppScope $app, Config $config): void, the one method an installed package’sextra.kinetisbootstrap class implements (seeKinetis\Cache’sPackageDiscovery/RoutesFile::loadBootstrap()below). Runs before the application’s ownbootstrap.php, which wins on any shared binding; an implementation should stay inert when its configuration is absent — wiring, not side effects.Autowire— reflection-based constructor injection, used by both scopes.
Kinetis\Http¶
Kernel— the runtime-agnostic entry point.handle(ServerRequestInterface): ResponseInterface. Resolves anOpenApi\OpenApiAccess(fromexposeOpenApi, elseOPENAPI_ENVIRONMENTSagainstAPP_ENV) and registers it, plus theRouter, on each request’s scope forHttp\OpenApi\DocumentationController— it serves no endpoint of its own: it creates aRequestScope, matches a route, dispatches./openapi.json//openapiare ordinary routes onHttp\OpenApi\DocumentationController, and/mcpis an ordinary routekinetis/mcpcontributes — 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 typedServerRequestInterface/UploadedFileInterface(the raw request/an uploaded file),#[Body]DTO (JSON, orgetParsedBody()formultipart/form-data/application/x-www-form-urlencoded),#[Query]scalar, same-named path parameter, then any remaining class-typed parameter from the request container (plan sourcecontainer) — last, so it shadows none of the others. That last source is what lets one controller serve a public route and a middleware-guarded one, since a constructor is shared by every route on its class while a method signature is not; a 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 aContainer\Exception\CircularDependencyExceptionis 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 throwsException\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 currentRequestScope(seeKinetis\Containerabove), 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.Kernelbuilds two: a global one (fromAppScope::middlewares()plus$discoveredGlobalMiddleware, wraps the whole request) and a per-route one (from a matchedRoute’s#[Middleware]attributes, wraps justDispatcher::dispatch()).Kernel::expandMiddlewareGroups()replaces each@namereference in a route’s list with that group’s members in place, so a group occupies exactly the position its reference was declared at;assertMiddlewareGroupsExist()validates every reference across every registered route once, in the constructor, throwingMiddleware\Exception\UnknownMiddlewareGroupExceptionfor an undeclared group rather than failing on whichever request first hits that route.Middleware\ExceptionHandlerMiddleware— always the second global middleware, immediately insideSecurityHeadersMiddleware. CatchesThrowable, logs via the container’sLoggerInterface, returns a 500 — a generic body in production, the exception’s class/message/file:linealongside it in development. Constructor-injectsAppEnvironment(defaulting toProduction, so a directly-constructed instance never leaks detail by accident).Middleware\SecurityHeadersMiddleware— always the outermost global middleware, outsideExceptionHandlerMiddleware, so its headers reach the500that handler produces. Constructor-injectsConfigand reads every value once, stripping CR/LF (which would both throw insidewithHeader()and be a header injection), soprocess()cannot fail. SendsX-Content-Type-Options: nosniffalways, plusX-Frame-Options(defaultDENY) andReferrer-Policy(defaultstrict-origin-when-cross-origin), each overridable or disabled withoffviaSECURITY_FRAME_OPTIONS/SECURITY_REFERRER_POLICY.Content-Security-Policy(SECURITY_CSP),Permissions-Policy(SECURITY_PERMISSIONS_POLICY), HSTS (SECURITY_HSTS_MAX_AGEplusSECURITY_HSTS_INCLUDE_SUBDOMAINS/SECURITY_HSTS_PRELOAD), and the three cross-origin policies —Cross-Origin-Opener-Policy(SECURITY_COOP),Cross-Origin-Resource-Policy(SECURITY_CORP),Cross-Origin-Embedder-Policy(SECURITY_COEP) — are sent only when configured, since a wrong value for any of them breaks a working application: COOP cuts thewindow.openerlink an OAuth popup reports back through, CORP stops other origins embedding responses they embed today, and COEP blocks every cross-origin subresource that has not opted in. HSTS is sent regardless of the request scheme — a browser must ignore it over a non-secure transport, and a scheme check would suppress it behind a TLS-terminating proxy. 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 afterExceptionHandlerMiddleware. Constructor-injectsConfigdirectly and readsMAX_BODY_SIZE(bytes, default2097152) once. Rejects a request whose declaredContent-Lengthexceeds it with a413before#[Body]ever reads the body; underneath that, wraps every request’s body inMiddleware\Support\SizeLimitedStream implements StreamInterface, enforcing the same cap against the actual bytes read regardless of what — or whether —Content-Lengthclaimed.SizeLimitedStream::read()/getContents()throwMiddleware\Exception\BodyTooLargeExceptionpast the cap;__toString()reports an empty string instead, sinceStreamInterfaceforbids throwing there — which is whyDispatcher::resolveBodyFromPlan()reads the request body viagetContents(), 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 ownupload_max_filesize/post_max_sizeinstead.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), plusKinetis\Httpitself, sorted by priority (descending, ties broken by class name).$paths, orMIDDLEWARE_DISCOVERY_PATHSwhen 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);groupsis a map of group name to that group’s own priority-sorted members, since#[AsMiddlewareGroup]is repeatable and a project can declare any number of independent groups. It always containsGlobalMiddlewareDiscovery::OPENAPI_GROUP(openapi), holding theopenApibucket, empty included:DocumentationControllerreferences it unconditionally and a route naming a missing group is a startup error —discover()is a thin wrapper returning just['global'], kept for any caller that only ever wanted that one list.Middleware\GlobalMiddlewareOrder::resolve(array $explicit, array $discovered): list<class-string>— computes the global-middleware order:SecurityHeadersMiddlewarefirst,ExceptionHandlerMiddlewaresecond,MaxBodySizeMiddlewarethird, thenmerge($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 soKernel’sopenapigroup folding can reuse the identical rule without inheritingSecurityHeadersMiddleware/ExceptionHandlerMiddleware/MaxBodySizeMiddleware, which neither needs.KernelandConsole\RoutesListCommandeach map either method’s result through their own container afterward — both return plain class-strings.Middleware\RateLimitMiddleware— opt-in, global or route. Fixed-window counter keyed by client IP (sha256-hashed), raised throughSimpleCache\Counter. Construction requires the cache to implementSimpleCache\AtomicCounterInterface—NullSimpleCacheand any other non-atomic cache both throwMiddleware\Exception\RateLimitUnavailableException, since a cache that can only count by reading then writing back lets the limit be exceeded by every request that arrives concurrently: 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 consultsX-Forwarded-ForwhenREMOTE_ADDRmatches one of the constructor’strustedProxiesCIDRs (empty by default —REMOTE_ADDRalways used otherwise), walking the chain from the end backward past any further trusted hops. Construction also rejects amaxAttemptsorwindowSecondsbelow 1, and anytrustedProxiesentry that is not an address or CIDR range with a prefix length in its family’s range, viaMiddleware\Exception\InvalidRateLimitConfigException— a zero window divides by zero, a negative one expires the counter on write so no limit is enforced, and an unparseable range decides who may setX-Forwarded-For. Notfinal— a subclass overriding the constructor defaults is how two routes get two different limits at once, since#[Middleware]carries only a class-string.identifierFor()isprotected, notprivate, specifically so a subclass’s override actually takes effect.Middleware\AuthenticatedRateLimitMiddleware— extendsRateLimitMiddleware, overridingidentifierFor()to key byCurrentUserInterface::id()when one is already resolved on the currentRequestScope, falling back to IP otherwise. Route middleware only, registered after the auth middleware that resolvesCurrentUserInterface— never global, and never bound directly onAppScope(the same disconnected-RequestScopehazardJwtAuthMiddlewaredocuments 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/allowedOriginPatternsconstructor config;allowedHeaders: ['*']reflects the preflight’s requested headers,allowedOriginPatternsmatches origins by PCRE pattern, compiled at construction (an uncompilable one throwsInvalidArgumentException) and required to match the wholeOrigin— a partial match is refused, so an unanchored pattern cannot widen what is allowed. Inspecting the pattern for^/$instead would not be sound:#^https://good\.com$|evil\.com$#carries both and is unanchored on its second branch. Echoes the specific origin (never a literal*) whenever credentials are allowed, per spec.Routing\Router/Routing\Route—#[Get]/#[Post]/#[Put]/#[Patch]/#[Delete]discovery, path-template compilation,toArray()/fromArray()for the AOT cache.register()goes throughKinetis\Reflection\AttributeScopefirst: the controller must be a concrete class, and each routed method must be declared by it (a trait method counts, an inherited one does not). A class-level#[RoutePrefix]is resolved here too, so the storedpathTemplateis 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.Routethen normalizes every path it is given to one canonical form — leading slash, no trailing one,/unchanged — in its own constructor, so it holds forfromArray()too;/usersand/users/are therefore the same route and declaring both is a duplicate.matchPath()normalizes the request path through the same rule, so/users/reaches a route registered as/users;Kernelapplies it to its own/mcpcomparison 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 viaRoute::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\InvalidRouteConstraintExceptionfor both) — this reachesfromArray()too, since it runs through the identical constructor.Routing\RouteDiscovery— builds aRouterfrom every class found anywhere under a project’s own PSR-4 root(s), plusKinetis\Httpitself, mirroringKinetis\Console\CommandDiscovery/Kinetis\Mcp\McpDiscovery.discover(string $projectRoot, ?array $paths = null)—$paths, or theROUTE_DISCOVERY_PATHSenv var when omitted, restricts the project-side scan to one or more sub-paths relative to each PSR-4 base directory.Attributes\{Get,Post,Put,Patch,Delete,Body,Query,Middleware,Response,Hidden,RoutePrefix,PaginatedItem}— 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 toRoute’s own normalization, and a route declaring/sits at the prefix itself. It is what makes a trait of shared route methods mountable at a different path per controller — see Routing & Validation.PaginatedItem(class-string $itemClass)(TARGET_METHOD) names the item class aPaginator/CursorPaginatorreturn actually wraps, purely forOpenApiGenerator::paginatedResponseSchema()— see below.Attributes\AsGlobalMiddleware/AsOpenApiMiddleware(each{priority: int = 50}, bounded0-100, throwingInvalidArgumentExceptionoutside that range) are the opposite direction fromMiddleware— they live on the middleware class itself, not on a controller referencing one — and are whatMiddleware\GlobalMiddlewareDiscoverylooks for.Attributes\AsMiddlewareGroup({name: string, priority: int = 50},IS_REPEATABLE, same bounds plus a non-empty-name check) declares group membership on the same side; a route references the group via#[Middleware('@name')],Middleware::GROUP_PREFIXbeing that@. Group membership alone never makes a class run — only a route referencing the group does.AsOpenApiMiddlewarereaches/openapi.json+/openapithrough the group mechanism — those are ordinary routes onHttp\OpenApi\DocumentationController, so its classes are published as the built-inopenapimiddleware group that controller references — see Middleware.AppScope::openApiMiddleware()is its explicit-registration counterpart, mirroringAppScope::middleware()./mcpis covered by an ordinarymcpgroupkinetis/mcp’s controller references — join it with#[AsMiddlewareGroup('mcp')].Responses\HtmlResponse/PlainTextResponse/FileResponse/RedirectResponse/ErrorResponse— static factories overNyholm\Psr7\Response, not distinctResponseInterfaceimplementations (unlikeStreamedResponse); each builds a plain response with the right headers/body already set.FileResponse::fromPath()detects the content type with PHP’s bundledfinfowhen$contentTypeis omitted;fromContents()takes the same parameters for in-memory data.$downloadFilenameis treated as untrusted: written as an RFC 6266 quoted-string with\and"escaped, so a name cannot close the quoting and append a secondfilenameparameter; a non-ASCII name also travels as RFC 8187filename*=UTF-8''…beside an underscore-substituted ASCII fallback; a control character or an empty string throwsException\FileResponseException. Path separators are left to the recipient to strip, per RFC 6266.Kernel::error()/Middleware\ExceptionHandlerMiddleware’s own 404/405/500 responses are built throughErrorResponse::create()too, the same helper a controller uses.Pagination\Paginator(data,currentPage,perPage,total,lastPage) /Pagination\CursorPaginator(data,nextCursor,hasMore) — plainreadonlyresult envelopes with no dependency onkinetis/query-builderor any other source;kinetis/query-builder’sQuery::paginate()/cursorPaginate()(see Appendix: Satellite Packages) are the convenient way to build one from a real query, not the only way.
Kinetis\Events¶
EventDispatcher— implementsPsr\EventDispatcher\EventDispatcherInterface. Never explicitly registered; autowired fresh per request throughRequestScope, constructor-injectingRequestScope,EventListenerRegistry, andListenerInvokerInterface.dispatch()stops at a listener oncePsr\EventDispatcher\StoppableEventInterface::isPropagationStopped()returnstrue.Listener— aTARGET_METHODattribute,{priority: int = 50}(bounded0-100, throwingInvalidArgumentExceptionoutside that range); the event class is inferred from the method’s own single parameter type.EventListenerRegistry— reflects every public#[Listener]method on a registered class, the same shape asRouter/McpRegistry. Exact event-class matching only. Each event’s own list is re-sorted (priority descending, ties broken alphabetically by class then method name) on everyregister()call that adds to it.listenersFor(class-string): list<array{class, method, priority}>.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), plusKinetis\Eventsitself, rather than an explicitbootstrap.phpregistration.$paths, orLISTENER_DISCOVERY_PATHSwhen omitted, restricts the project-side scan.ShouldQueue— a marker interface a listener implements to be invoked throughListenerInvokerInterfaceinstead of directly.ListenerInvokerInterface/SynchronousListenerInvoker— the seam aShouldQueuelistener’s invocation is routed through;AppScope::boot()registers the synchronous default automatically.kinetis/queue’sQueuedListenerInvoker(see Appendix: Satellite Packages) implements this to actually defer invocation.
Kinetis\Runtime¶
RuntimeAdapterInterface+RuntimeDetector::detect()— picksFrankenPhpAdapterorFpmAdapterbased onfunction_exists('frankenphp_handle_request'); picksKinetis\BrefAdapter\BrefLambdaAdapter(separatekinetis/bref-adapterpackage —class_exists()-gated, not a hard reference) whengetenv('AWS_LAMBDA_RUNTIME_API')is set and that package is installed, otherwise throwsRuntimeUnavailableException::missingAdapterPackage(). Both signals are also accepted as optionaldetect()parameters so tests can exercise every branch without faking global PHP/process state.Adapters\FrankenPhpAdapter—run()is ado/whileloop callingfrankenphp_handle_request()repeatedly for as long as it returnstrue;isPersistent(): true.Adapters\FpmAdapter—run()handles exactly one request from superglobals, callingfastcgi_finish_request()when available so the response flushes before any post-response cleanup;isPersistent(): false.AppEnvironment—Development/Productionenum.detect()readsAPP_ENV; unset or unrecognized →Production.ProjectRoot::detect()— resolves the consumer project root, accounting for Composer’svendor/bin/kinetisproxy.SuperglobalsBridge— PSR-7 ⇄ superglobal conversion, shared byFrankenPhpAdapter/FpmAdapter. Also runs PHP 8.4’srequest_parse_body()for aPUT/PATCHmultipart or url-encoded body, whichfromGlobals()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$keyunchanged; any other name inserts itself, uppercased, after the key’s own prefix (REDIS_HOST+cache2→REDIS_CACHE2_HOST).EnvFile::safeLoad(string $projectRoot)— loads.envviavlucas/phpdotenv, called unconditionally inpublic/index.phpandbin/kinetis, beforeAppEnvironment::detect().
Kinetis\Logging¶
ErrorLogLogger— a minimal PSR-3 logger writing througherror_log(), with{placeholder}context interpolation and the class/file:lineof a Throwable under theexceptioncontext key appended.AppScope::boot()’s defaultLoggerInterfacebinding in development; a consumer-registered logger wins in every environment.
Kinetis\Async¶
Socket— non-blocking TCP, Fiber-suspendingconnect()/read()/write().Timer::delay()— Fiber-suspending delay.concurrently(array $tasks)— runs each task in its ownFiberdrawn fromFiberPool, collects results in task order, and rethrows the first failure (in task order) once every task has finished. Nested calls are supported. A task that suspends with nothing registered to resume it surfaces asException\DeadlockExceptionnaming the task’s index.FiberPool— resident worker Fibers that park between jobs instead of terminating, so the steady state allocates no Fiber stacks (per-taskmmap/munmapchurn 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— onlyconcurrently()submits jobs.ConcurrentBatch— oneconcurrently()call’s coordination state: records each task’s result or failure, parks the caller on a Revolt suspension the last task resumes, diagnoses deadlocks by first unfinished index, and assembles results.@internal.
Kinetis\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 bykinetis/persistence’s own MySQL/Postgres/Redis integration (the persistence drivers andamphp/redisalready 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.
AtomicCounterInterface—increment(string $key, int $ttlSeconds): intandcount(string $key): int, implemented alongsideCacheInterfaceby a backend that can count without reading first. PSR-16 has no such operation, and building one fromget()thenset()is not safe across processes. A counter is stored in whatever form the backend increments natively — a RedisINCRcounter holds a bare integer where the cache otherwise stores serialized values — so it is read throughcount(), neverget().AtomicConsumeInterface—consume(string $key, mixed $default = null): mixed, implemented alongsideCacheInterfaceby a backend that can read and delete a key in one operation. PSR-16 has no such operation either, and aget()then a separatedelete()is not safe across processes: two concurrent callers can both read the value before either deletes it. Required byKinetis\AuthJwt\RefreshTokenStore, which refuses a cache without it — unlike the counter above, there’s no safe read-then-write fallback for “consume once,” so this one has no soft-degrade path.Counter— wraps anyCacheInterfaceand counts throughAtomicCounterInterfacewhen the cache provides it, falling back to read-then-write when it does not.isAtomic()reports which.Http\Middleware\RateLimitMiddlewareandSecurity\AttemptThrottleboth use it, but reject any cache lackingAtomicCounterInterfaceat their own construction, so the fallback path is never actually reachable through either.NullSimpleCache— the default when Redis isn’t configured, or whenkinetis/cache-redisisn’t installed at all. Always misses, never stores.Exception\CacheException/Exception\InvalidArgumentException— implement the matchingPsr\SimpleCache\*exception interfaces; reused bykinetis/cache-redis’s own classes, not redeclared there.UnavailableSimpleCache— bound when Redis is configured (REDIS_HOST/REDIS_URL/REDIS_CLUSTER) butkinetis/cache-redisisn’t installed. Every operation throwsException\SimpleCacheUnavailableExceptionnaming the package; nothing is silently discarded, and nothing fails until the cache is actually used, so a leftoverREDIS_*in a.envleaves an application that never touches the cache unaffected. It implementsAtomicCounterInterface/AtomicConsumeInterfacetoo, so a counter orRefreshTokenStorebuilt on it fails on first use naming the package to install rather than being rejected at construction for lacking the interface. The same usage-time-over-configuration-time tradeRateLimitMiddleware/RevocationStoremake by rejectingNullSimpleCacheat construction rather than at boot.Exception\SimpleCacheUnavailableException— thrown by everyUnavailableSimpleCacheoperation, namingkinetis/cache-redis.
Kinetis\Security¶
AttemptThrottle— an identifier-keyed failure counter for anything failure-prone (a password check, a 2FA code, an invite redemption), not middleware: whether an attempt failed is only known once application code runs it, sorecordFailure()/clear()are called directly.tooManyAttempts()/availableInSeconds()read the current lockout; each failure refreshes the window todecaySecondsfrom that failure, so repeated attempts keep extending it. Identifiers are sha256-hashed (PSR-16 forbids@in a key). Counts throughSimpleCache\Counter, but requires the cache to implementSimpleCache\AtomicCounterInterface—NullSimpleCacheand any other non-atomic cache are both refused at construction withException\AttemptThrottleUnavailableException, since a cache that can’t count atomically lets failures arriving together — how a password is actually attacked — register as one.maxAttempts/decaySecondsbelow 1 are rejected too, withException\InvalidAttemptThrottleConfigException.
Kinetis\Cache¶
Compiler::compile()/compileProject()— walks aRouter/CommandRegistry/EventListenerRegistry, derives binding/validation plans, produces aCompiledCache.compileProject()builds them viaRouteDiscovery/CommandDiscovery/EventListenerDiscovery. MCP is not compiled here —kinetis/mcpdiscovers live on first resolution of its server.HttpCache/CommandCache/EventCache— the three independent artifacts.HttpCache::$packageBootstrapsandCommandCache::$packageBootstrapseach carry theextra.kinetisbootstrap-class list, so production loads it from whichever artifact its entry point already reads instead of re-readingvendor/composer/installed.json.HttpCache::$middlewareGroupscarries the#[AsMiddlewareGroup]map; a route’s ownmiddlewarelist stores raw references (a class-string or a@name) and is never expanded at compile time, so a group’s membership can change without recompiling every route that references it.CacheStorewrites them via atomic tmp-file +rename(), reads viarequire. An object anywhere in an artifact (in practice, a constructor default likenew DateTimeImmutable()captured into a plan) fails the write withException\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 owncomposer.jsondeclares, at any depth, with no directory/namespace convention required;$pathsrestricts the walk to one or more sub-paths relative to each PSR-4 base directory, deduplicated internally when$pathsnames 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 pairsPackageDiscovery::scanRoots()resolves. Both skip a file entirely (noclass_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, notNamespaceScanner’s — each Discovery class (kinetis/mcp’sMcpDiscoveryincluded) merges both calls through their own$seenset (or, forGlobalMiddlewareDiscovery, a class-string-keyed priority map) before ever registering anything.PackageDiscovery— readsvendor/composer/installed.jsonfor packages declaringextra.kinetis.scanRoots()resolves each package’s comma-separatedscanprefixes against its own PSR-4 map into concrete directories (fed toNamespaceScanner::classesUnderPackageRoots()by every Discovery class);bootstrapClasses()collects the declaredbootstrapclasses. A prefix outside the package’s own roots, or a missing bootstrap class, is logged viaerror_log()and skipped rather than failing the whole scan.RoutesFile::loadBootstrap(string $projectRoot, ?array $packageBootstraps = null)— composes the bootstrap chain run with(AppScope, Config)beforeboot()locks bindings: each package’sPackageBootstrapInterfaceclass first (from the given precompiled list, orPackageDiscovery::bootstrapClasses()live whennull), then the consumer’s ownbootstrap.php— last, so an application binding wins over a package’s for the same id. Routes, commands, global middleware, and event listeners are all found by namespace instead (RouteDiscovery/CommandDiscovery/GlobalMiddlewareDiscovery/EventListenerDiscovery).Not part of this cache:
Kinetis\Config(.env/environment values) — the cache is rebuilt from source viabin/kinetis build; environment configuration isn’t.
Kinetis\Validation / Kinetis\OpenApi¶
Hydrator— builds and validates a#[Body]-bound DTO from constructor-parameter reflection andConstraint-implementing attributes (#[Email],#[NotBlank],#[MinLength],#[MaxLength],#[GreaterThan],#[LessThan],#[Regex],#[In],#[Url],#[Uuid]). A missing key on a defaultless parameter isis required.; an explicitly-null value for a parameter whose declared type doesn’t allow null ismust not be null.— both 422 errors, never a rawTypeErrorfrom 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 stayvar_export()-representable.JsonSchema— the type/constraint → JSON Schema mapping shared byOpenApiGeneratorand MCP tool input schemas. An optional$classSchemacallback lets a caller substitute something other than inlining for a nested class-typed parameter’s schema;null(every MCP call site) keeps inlining.OpenApi\OpenApiAccess— whether this process serves/openapi.json//openapi.fromConfig()matchesOPENAPI_ENVIRONMENTS(comma-separated, case- and space-insensitive) against the rawAPP_ENV, treating an absent one asproduction;enabled()/disabled()decide outright. Resolved per request rather than at route registration, so a compiled cache cannot bake the decision.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 underCACHE_KEYin the boundCacheInterfacewith no expiry, cleared byConsole\OpenApiClearCommand. A disabled path answers withKernel’s own unmatched-path 404, byte for byte.Console\OpenApiClearCommand—openapi:clear, drops that cache entry. Run it on deployment alongsidebuild; safe when nothing is cached.OpenApiGenerator::generate()— builds the OpenAPI 3.1 document from aRouter’s registered routes, deduplicating every DTO schema (request body, response, or nested at any depth) intocomponents/schemaswith$ref, and deriving the default response’s schema from the controller method’s declared return type. 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 aPaginator/CursorPaginatorreturn: with a#[PaginatedItem]attribute present,datadescribes as an array of the named class’s own (deduplicated) schema, built inline rather than throughschemaRefFor()for the wrapper itself — a shared “Paginator” component would otherwise collapse two different routes’ different item types into one; without the attribute,datastays the bare{type: object}fallback.
Kinetis\Console¶
Attributes\Command—TARGET_METHOD,{name, description, bootstrap}. Discovered byCommandRegistry::register()the same wayRouter/McpRegistrydiscover their own attributes.bootstrap: falsemakesbin/kinetisskip the application’sbootstrap.php(and the transaction-guard hook) before dispatch — for commands that only operate on the project’s static shape and must run without the configuration the application’s services demand.CommandRegistry— validates each#[Command]method’s signature at registration time (zero parameters, or exactly one parameter typedCommandArguments; anything else throwsException\InvalidCommandException), and rejects a duplicate command name across two different registrations.commands(): list<CommandDefinition>,findCommand(string $name): ?CommandDefinition,toArray()/fromArray()for the AOT cache.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), plusKinetis\Consoleitself (Kinetis\Cache\NamespaceScanner), rather than an explicit registration file.$paths, orCOMMAND_DISCOVERY_PATHSwhen omitted, restricts the project-side scan.CommandArguments— injected by type into a command method, the same by-type special-casingProgressReporteralready gets for MCP tools.parse(array $argv)splits into positional values (get(int),all()) and--key=value/bare---flagoptions (option(string, ?string),hasOption(string)).CommandDispatcher::run(CommandDefinition, list<string> $arguments): int— resolves the controller through the container and invokes it; no per-call reflection, sinceCommandRegistry::register()already validated the signature. The method’s own return value becomes the exit code (intused directly,void/nullmeans0).BuildCommand—#[Command('build', bootstrap: false)]— cache pre-warming needs no application configuration (no database credentials in a CI pipeline). The one command that has to be found before it can be used to build anything, viabin/kinetis’s own lazy-generate-on-first-run bootstrap. Always removes.kinetis-cache/before writing a fresh one;--destroyremoves it and stops there, without writing anything back.RoutesListCommand—#[Command('routes:list')]. A read-only introspection tool, not a caching mechanism: runsRouteDiscovery/GlobalMiddlewareDiscoverylive (regardless ofAPP_ENV) and prints the result — never touches.kinetis-cache/. Declaresbootstrap: falseand constructs its own throwawayAppScoperunningbootstrap.phponce to readAppScope::middlewares(), sinceAppScopeitself is never registered onto theRequestScopea command is dispatched through.$output(aresource, defaulting toSTDOUT) is an appended constructor parameter for testability againstphp://memory— the same reasonStdioTransport’s input/output streams are injectable — since a#[Command]method itself must stay parameter-free or take exactly oneCommandArguments.bin/kinetis— has no hardcoded verbs at all. In production, loadsCommandCache(auto-generating it, via a fullCompiler::compileProject(), on the first invocation that finds none); in development, builds the registry live viaCommandDiscovery::discover(). Every name — the built-inbuild/routes:list, anything a package contributes (mcp:serve,queue:work), and the application’s own — is looked up in that same registry. One freshRequestScopeper invocation, withKinetis\Persistence\TransactionGuard::rollbackDangling()registered as a dispose hook whenever that class is available — the sameclass_exists()-gated safety netKernelgives every HTTP request, sinceTransactionGuardlives in the optionalkinetis/persistencepackage, not core. An uncaught exception is logged through the container’sLoggerInterfaceand produces exit code1; a missing or unknown command name lists every available command, one per line, and also exits1.
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), plusphase()for pre-container lifecycle phases reported with explicit timestamps;jobPushMetadata()returns opaque string metadata a queue backend stores with the job and hands back throughjobStarted()— the propagation channel that joins producer and consumer spans into one trace across processes. Deliberately broad while under evaluation, and not a consumer extension point — its implementors areNullTelemetryand 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-processglobal()accessor (a documentedNoStaticPropertiesRuleexemption, the FiberPool class of worker-lifetime infrastructure).AppScope::boot()binds it as theTelemetryInterfacedefault so app code can inject it; kinetis/telemetry’s package bootstrapswap()s in the OTel backend. Measured no-op cost: ~90ns per hook pair, one to two microseconds per fully hooked dispatch.
Kinetis\Reflection¶
AttributeScope— the one place that decides where attributes are read from, shared by every registry that reflects a class for them:Http\Routing\Router,Console\CommandRegistry,Mcp\McpRegistry,Events\EventListenerRegistry, and (viaCache\NamespaceScanner)Http\Middleware\GlobalMiddlewareDiscovery.reflect(class-string): ReflectionClassrejects an abstract class, interface, trait or enum withException\AttributeScopeException::notRegistrable().declares(ReflectionMethod, class-string): boolandassertDeclares()answer whether the registered class declares a method itself — a trait method does, since PHP reports its declaring class as the using class; an inherited one does not, and registering it throwsAttributeScopeException::inheritedMethod()naming both classes.isRegistrable(string): boolis the silent counterpartNamespaceScanneruses, so discovery skips what registration rejects rather than failing the application over an abstract base under a scanned namespace.
Kinetis\Linting¶
NoStaticPropertiesRule— a PHPStan rule flaggingstaticproperty declarations, shipped under the main autoload for consumer projects to add to their ownphpstan.neon.
Kinetis\Testing¶
TestClient— wraps aKernel.get()/post()/put()/patch()/delete()build a PSR-7 request and dispatch it; abodyarray is JSON-encoded withContent-Type: application/jsonset unless already provided.request()is the general form all five call. Every method returns aTestResponse.TestResponse— the response with assertions attached (assertStatus/assertOk/assertJson/assertJsonPath/assertValidationError, …), each returning$thisfor chaining. ImplementsResponseInterfaceitself and delegates, so it passes anywhere plain PSR-7 is expected. Failure messages include the response body;body()rewinds before reading, so the body can be read repeatedly.TestApplication— boots a real application from a project root: live discovery (routes, middleware, listeners), the package-then-app bootstrap chain, a bootedAppScope, a realKernel.boot(string $projectRoot, array $configOverrides = [], ?callable $beforeBoot = null)merges overrides over the environment — includingAPP_ENV, registered as the container’sAppEnvironmentfrom the merged config, sinceAppScope::boot()’s own default readsgetenv()and would never see the override.$beforeBootruns after the application’s ownbootstrap.phpand beforeboot()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 overTestApplication: boots per test via#[Before](so it runs ahead of any trait-declared hook in the concrete class,kinetis/persistence’s isolation traits included), exposing$client/$app/$application. OverrideprojectRoot()(required),configOverrides(), andregisterTestDoubles(AppScope $app, Config $config)for services a test should not reach — 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, onefinaltest 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 theCookieheader andgetCookieParams(),REMOTE_ADDR, url-encoded and multipart bodies (POST,PUTandPATCHalike), JSON left unparsed, the declaredContent-Lengthdelivered, a 1 MiB raw body whole, binary and empty and"0"bodies, response status/headers, a comma inside one header value, twoSet-Cookieas two cookies, a binary response body, streaming delivered — timed on the wire, so a proxy that holds the stream until the end fails it — or refused, per the driver’s declaration, and a body the environment can’t parse answered with a400carryingRuntimeAdapterInterface::MALFORMED_BODY_MESSAGEand no handler run.driver()is the one abstract method;assertMalformedBodyResponse(WireResponse)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 factsexpectedClientIp(),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 asname=valuestrings, raw body.json()is the JSON-body shorthand.ResponseSpec— what the handler answers with, as data: status, headers,Set-Cookievalues, body, orstreamChunksfor aStreamedResponsewithstreamDelayMsbetween them (the emitter closes any output buffer first, then writes and flushes each chunk).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-Cookievalues separately, body bytes, andbodyArrivalSpanSeconds— the time between the first and last body byte reaching the client, the evidence a stream was delivered as written rather than buffered (nullfor a driver with no wire to time).header()is case-insensitive.AdapterRejection/Outcome— an adapter that refused to respond at all (exception class and message), and the pair a driver reports: the observed request (nullwhen the handler never ran) and either aWireResponseor a rejection.
Request lifecycle, in order¶
A
RuntimeAdapterInterfacereceives the request and converts it to PSR-7.Kernel::handle()runs the globalMiddlewarePipeline.Inside it:
AppScope::createRequestScope(), thenTransactionGuard::rollbackDangling()registered as a dispose hook whenkinetis/persistenceis installed.Router::match()resolves aRoute, or throwsRouteNotFoundException/MethodNotAllowedException(→ 404/405).The route’s
#[Middleware]pipeline runs, wrappingDispatcher::dispatch().Dispatcherresolves parameters (via a compiled plan ifHttpCacheis present, live reflection otherwise), invokes the controller.RequestScope::dispose()runs in afinallyblock;gc_collect_cycles()runs if the adapter is persistent.
See also¶
Appendix: Satellite Packages — the same reference map for every optional satellite package.
Appendix: Continuous Integration — what actually runs in CI, and what’s deliberately not covered.
Appendix: Contributing to Kinetis — the monorepo layout, dev environment setup, and how to actually make a change.
Core Concepts, Container, Configuration, Routing & Validation, Middleware, Logging, Runtime Adapters, Concurrency, Persistence, Model Context Protocol (MCP), Caching & AOT Compilation, CLI, Testing — the task-oriented page for each namespace above.