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.boot()registers three defaults if not already set:Psr\Log\LoggerInterface→NullLogger,Kinetis\Config\Config→Config::fromEnvironment(),Psr\SimpleCache\CacheInterface→RedisSimpleCache::fromConfig()when Redis is configured, elseNullSimpleCache.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().Autowire— reflection-based constructor injection, used by both scopes.
Kinetis\Http¶
Kernel— the runtime-agnostic entry point.handle(ServerRequestInterface): ResponseInterface. Serves/openapi.jsonand/docsdirectly (toggle:exposeOpenApi), routes/mcpto anMcpServerif one is passed ($mcp), otherwise creates aRequestScope, matches a route, dispatches.Dispatcher— resolves a matched route’s controller from the container, binds each parameter:#[Body]DTO (JSON, orgetParsedBody()formultipart/form-data/application/x-www-form-urlencoded),#[Query]scalar, same-named path parameter, or — for a parameter typedServerRequestInterface/UploadedFileInterface— the raw request/an uploaded file directly, invokes it.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()).Middleware\ExceptionHandlerMiddleware— always the outermost global middleware. CatchesThrowable, logs via the container’sLoggerInterface, returns a 500.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 a413, before#[Body]ever reads the body. Only the declared header is checked, not the actual bytes read.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.Middleware\GlobalMiddlewareOrder::resolve(array $explicit, array $discovered): list<class-string>— computes the global-middleware order:ExceptionHandlerMiddlewarefirst,MaxBodySizeMiddlewaresecond, then$explicitas a group, then$discoveredminus anything already in$explicit. Returns plain class-strings;KernelandConsole\RoutesListCommandeach map this through their own container afterward.Middleware\RateLimitMiddleware— opt-in, global or route. Fixed-window counter keyed by client IP (sha1-hashed) againstPsr\SimpleCache\CacheInterface.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. 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 (must be anchored — an unanchored pattern is a CORS-bypass footgun). 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.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,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.PaginatedItem(class-string $itemClass)(TARGET_METHOD) names the item class aPaginator/CursorPaginatorreturn actually wraps, purely forOpenApiGenerator::paginatedResponseSchema()— see below.Attributes\AsGlobalMiddleware({priority: int = 50}, bounded0-100, throwingInvalidArgumentExceptionoutside that range) is the opposite direction fromMiddleware— it lives on the middleware class itself, not on a controller referencing one — and is whatMiddleware\GlobalMiddlewareDiscoverylooks for.Responses\HtmlResponse/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.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\Async¶
Socket— non-blocking TCP, Fiber-suspendingconnect()/read()/write().Timer::delay()— Fiber-suspending delay.concurrently(array $tasks)— runs each task in its ownFiber, collects results/rethrows the first failure once all tasks finish.
Kinetis\Persistence¶
TransactionGuard— request-scoped.transaction()(commit on success, rollback on throw) andbeginTransaction()/rollbackDangling()for the manual case.rollbackDangling()is registered as an unconditionalRequestScopedispose hook byKernel, and logs a warning when it finds something to close.SqlConnectionFactory::fromConfig(Config $config, string $connection = 'default'): MysqlConnectionPool|PostgresConnectionPool— builds a connection pool fromDB_*(orDB_{NAME}_*for a named connection); shared bykinetis/migrations’bin/migrateandkinetis/queue’sbin/queueinstead of each duplicating the connection-string assembly.Pool— generic connection-pool infrastructure, not used by the current MySQL/Postgres/Redis integration (amphp/mysql,amphp/postgres,amphp/redisalready pool internally).
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.
RedisSimpleCache— single-node, backed byAmp\Redis\RedisClient.fromConfig(Config $config, string $connection = 'default')/buildRedisConfig(Config $config, string $connection = 'default')readREDIS_URLor discreteREDIS_HOST/REDIS_PORT/REDIS_PASSWORD/REDIS_DATABASE/REDIS_TIMEOUT(or theirREDIS_{NAME}_*named-connection equivalents), returningnullwhen neither is set.clear()flushes the entire selected database.ClusteredRedisSimpleCache— the Redis Cluster counterpart, activated byREDIS_CLUSTER=true/REDIS_CLUSTER_SEEDS.Cluster\Crc16::slotFor()computes the owning slot (CRC16-XMODEM mod 16384, honoring a{...}hash tag when present — moot for a PSR-16 key, which can’t contain{/}, but part of the algorithm regardless);Cluster\ClusterTopologydiscovers the slot→node layout viaCLUSTER SHARDS(throughRedisClient::execute()— no typed method exists for it) and resolves a slot to theRedisClientthat owns it, refreshing once on aMOVEDreply.getMultiple()/deleteMultiple()dispatch one command per key rather than a batchedMGET/DEL— Redis Cluster rejects any multi-key command whose keys don’t share a slot, even when the same physical node happens to own all of them — run concurrently viaKinetis\Async\concurrently();clear()fansFLUSHDBout to every master the same way, since one node’sFLUSHDBonly clears its own shard. Only database 0 is supported, matching a real cluster’s own restriction.Connection\TlsRedisConnector— aRedisConnectorusingAmp\Socket\connectTls(), sinceAmp\Redis’s own default connector never upgrades to TLS.fromConfig()readsREDIS_TLS/REDIS_TLS_VERIFY_PEER/REDIS_TLS_CA_FILE, shared by bothRedisSimpleCache/ClusteredRedisSimpleCache; returnsnullwhenREDIS_TLSisn’t set, so the caller falls back to a plain connector.NullSimpleCache— the default when Redis isn’t configured. Always misses, never stores.Exception\CacheException/Exception\InvalidArgumentException— implement the matchingPsr\SimpleCache\*exception interfaces.
Kinetis\Mcp¶
McpServer— handles one decoded JSON-RPC message. Supports the legacy (2025-03-26)initializehandshake and the modern (2026-07-28) statelessserver/discovermodel side by side.loggerparam defaults toNullLogger(constructed directly, not through the container).McpRegistry—#[McpTool]/#[McpResource]discovery,toArray()/fromArray()for the AOT cache.McpDispatcher— the MCP analogue ofHttp\Dispatcher.ProgressReporter— injected by type into a tool method;report()streams anotifications/progressevent when_meta.progressTokenis present, a no-op otherwise.Transport\StdioTransport— one JSON-RPC message per line on stdin/stdout.KinetisDocsResource— registers everydocs/*.mdpage as an MCP resource (kinetis://docs/{slug}), read directly from whereverkinetis/kinetisis installed. Lives underKinetis\Mcp, soMcpDiscoveryalways finds it formcp:serve; registering it manually ($registry->register(KinetisDocsResource::class)) is only needed for a hand-wiredMcpRegistry, e.g. the HTTP/mcptransport, which discovery never touches.docs/is deliberately notexport-ignored in.gitattributesso this actually has something to read once installed as a dependency.McpDiscovery::discover(string $projectRoot, ?array $paths = null): McpRegistry— builds a registry from every class found anywhere under a project’s own PSR-4 root(s), plusKinetis\Mcpitself (NamespaceScanner, seeKinetis\Cachebelow), rather than an explicit registration file.$paths, orMCP_DISCOVERY_PATHSwhen omitted, restricts the project-side scan.
Kinetis\Cache¶
Compiler::compile()/compileProject()— walks aRouter/McpRegistry/CommandRegistry/EventListenerRegistry, derives binding/validation plans, generates the OpenAPI document, produces aCompiledCache.compileProject()builds them viaRouteDiscovery/McpDiscovery/CommandDiscovery/EventListenerDiscovery.HttpCache/McpCache/OpenApiCache/CommandCache/EventCache— the five independent artifacts.CacheStorewrites them via atomic tmp-file +rename(), reads viarequire.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”, “Mcp”, “Events”) under Kinetis’s own package root specifically. 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 —RouteDiscovery/McpDiscovery/CommandDiscovery/EventListenerDiscovery/GlobalMiddlewareDiscoveryeach merge both calls through their own$seenset (or, forGlobalMiddlewareDiscovery, a class-string-keyed priority map) before ever registering anything.RoutesFile::loadBootstrap()— loads a consumer’sbootstrap.php, run with(AppScope, Config)beforeboot()locks bindings. Routes, MCP tools/resources, commands, global middleware, and event listeners are all found by namespace instead (RouteDiscovery/McpDiscovery/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 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.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.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}. Discovered byCommandRegistry::register()the same wayRouter/McpRegistrydiscover their own attributes.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')]. 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.McpServeCommand—#[Command('mcp:serve')]. Constructor-injects the concreteRequestScope(never a genericContainerInterface— interfaces aren’t autowirable by reflection here); safe becausebin/kinetisalways dispatches a command through the request’s own scope.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/. Constructs its own throwawayAppScopeand re-runsbootstrap.phpto 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 — includingbuild/mcp:serve— is looked up in that same registry. One freshRequestScopeper invocation, withKinetis\Persistence\TransactionGuard::rollbackDangling()registered as a dispose hook — the same unconditional safety netKernelgives every HTTP request. 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\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.
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.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.
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.