Appendix: Satellite Packages¶
A reference map of what exists in each optional satellite package, by
namespace. For core (kinetis/framework itself), see Appendix: System Layout.
Two entries under packages/ are not mapped here, because they are
applications rather than libraries: kinetis/skeleton (one controller,
one route, nginx + PHP-FPM) and kinetis/pingpong (a FrankenPHP worker
over MySQL, a Redis queue, events, a scheduled command, an MCP tool and
live WebSocket updates). Each is read as example code — its own README
and Tutorial, which builds kinetis/pingpong from an empty
directory, are where they are covered.
packages/bref-adapter (kinetis/bref-adapter)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\BrefAdapter\BrefLambdaAdapter implements Kinetis\Runtime\RuntimeAdapterInterface— constructed from the Runtime API endpoint and nothing else, noTrustedProxies: an invocation has no connecting peer whose forwarded headers would need weighing.run()polls the Lambda Runtime API for the next invocation in awhile (true)loop, converts the API Gateway HTTP API (payload format 2.0) event into a PSR-7 request, and posts the response back as the invocation’s result;isPersistent(): true(a warm container keeps reusing the same process across invocations, the same shapeFrankenPhpAdapterhas). Talks to the Runtime API with plain stream-context HTTP, notext-curl; a transport failure or a non-2xx status from the Runtime API itself throws rather than being treated as an empty response. Maps the event’s top-levelcookieslist into a realCookieheader/getCookieParams()andrequestContext.http.sourceIpinto the request’sREMOTE_ADDRserver parameter — neither is inheaders, and nothing else here has a real socket to read either from.handleEvent(array $event, callable $handler)is one invocation from decoded event to response payload — the Lambda counterpart ofSuperglobalsBridge::handle(), and the entry point the runtime conformance suite drives (tests/Conformance/LambdaDriver).headersFromEvent()refuses an event carrying one header name under two spellings, a name that is not an RFC 9110 token, or a value with a control character, and thecookieslist is validated rather than filtered — an ambiguity resolved by key order is not an identity, and a filtered list is a silently shortened one.requestFromEvent(array $event)decodes a base64 body strictly (invalid base64 isException\MalformedRequestBodyException, this package’s one Lambda-specific client error, never an empty body);responseToPayload()refuses aStreamableResponseInterface— abandoning it first, so the request scope the Kernel holds open for its emitter is released on that invocation without running the emitter — base64-encodes a response body that isn’t valid UTF-8 (json_encode()would otherwise reject it) and emits everySet-Cookieheader value as its own entry in the payload’scookiesarray rather than folding them into one comma-joined header. Hands the decoded body on as raw bytes: staging, ceilings, parsing and the parse-failure vocabulary belong to core’sKinetis\Http\Middleware\RequestBodyMiddleware, inside the Kernel, so a form is accepted or refused identically under every runtime. Kinetis validates what API Gateway delivered; it cannot bound what API Gateway already read. See Runtime Adapters for the full supported/unsupported feature list.Kinetis\BrefAdapter\LambdaRequestIdentity— where the request was addressed, decided once from the event:requestContext.domainNameis the host,x-forwarded-portthe port,requestContext.http.protocolthe version,rawPath/rawQueryStringthe request target byte for byte. The scheme ishttpsand comes from the platform, not the event: an API Gateway HTTP API and a Lambda Function URL are TLS-only, sox-forwarded-protois checked against that fact rather than deciding it — absent orhttpsis the event API Gateway builds, and any other value,httpincluded, describes an invocation that cannot have happened. That, ahostheader naming a different domain, a port two fields disagree on, a path that is not absolute or carries its own query, or any of them not valid UTF-8 or carrying a control character is a malformed event, reported to the invocation error endpoint before anything is dispatched.authority()/uri()/requestTarget()are whatrequestFromEvent()builds the request from, so the URI and theHostheader cannot disagree. The event’s ownqueryStringParametersis validated as part of the event shape and read nowhere: it comma-joins a repeated parameter, which PHP would then read as one parameter containing a comma.Depends on
kinetis/framework(via apathrepository to this monorepo’s root),nyholm/psr7,psr/http-message. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/roadrunner-adapter (kinetis/roadrunner-adapter)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\RoadRunnerAdapter\RoadRunnerAdapter implements Kinetis\Runtime\RuntimeAdapterInterface—run()builds aSpiral\RoadRunner\Http\PSR7WorkeroverSpiral\RoadRunner\Worker::create()and loopswaitRequest()/respond();isPersistent(): true. Catches anyThrowablea handler throws and reports it viaWorker::error()instead of letting it propagate — the opposite ofFrankenPhpAdapter’s convention, and whatroadrunner-server/http’s Go source calls for: anERROR-framed Goridge reply becomes a clean error response to that one client while the worker process itself keeps serving the next request, whereas propagating would kill the whole persistent worker over one bad request.handle(ServerRequestInterface, callable, TrustedProxies)(public, static) is the per-request counterpart ofSuperglobalsBridge::handle()and the entry point the runtime conformance suite drives (tests/Conformance/RoadRunnerDriver) — folds a repeated header into one comma-joined value first (PSR7Worker’s own mapping presents repeats as separate array values, unlike every other adapter here), appliesX-Forwarded-Prototo the request URI only when the connecting peer matches the application’sTrustedProxiespolicy (RoadRunner’s own listener is plaintext whenever TLS is terminated in front of it; a directly reachable one reads the header from nobody, and a trusted proxy sending anything but onehttp/httpsis a fixed400), and hands the body on as the raw bytes the client sent, for core’sKinetis\Http\Middleware\RequestBodyMiddlewareto stage, bound and parse inside the Kernel. AStreamableResponseInterfaceresult is abandoned — releasing the request scope the Kernel holds open for its emitter, on that request, without running the emitter — and becomes a real501(STREAMING_NOT_SUPPORTED_MESSAGE, a public class constantRoadRunnerDrivermatches by exact status/body pairing to report anAdapterRejection) rather than being buffered or dropped. Requireshttp.raw_body: truein RoadRunner’s own configuration — without it, RoadRunner parses form bodies itself, in Go, before PHP is ever invoked — and checks that on every request through therr_parsed_bodyattribute the worker library stamps:trueis the misconfiguration, and anything that is notfalsemeans the capability cannot be verified at all, which is refused rather than assumed good (Exception\RoadRunnerAdapterException::rawBodyNotEnabled()/rawBodyUndetectable()). See Runtime Adapters for the full supported/unsupported feature list, including the two environment-caused limitations (a purely-numeric header name; cookie order) that this adapter’s driver declares and the shared conformance suite asserts in both directions rather than skipping.Depends on
kinetis/framework(via apathrepository to this monorepo’s root),nyholm/psr7,psr/http-message,spiral/roadrunner-http,spiral/roadrunner-worker;spiral/roadrunner-cli(dev-only, providesvendor/bin/rr get-binary) fetches the real binary the conformance suite spawns. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/persistence (kinetis/persistence)¶
Separate Composer package, not part of kinetis/framework core, so core
itself has no direct MySQL/Postgres dependency. Depends on no Kinetis
package; kinetis/database-bridge (below) wires it into an application.
Kinetis\Persistence\TransactionGuard— SQL transaction safety net for one unit of work.transaction()(commit on success, rollback on throw) andbeginTransaction()/rollbackDangling()for the manual case.beginTransaction()and thetransaction()callback carry the link’s transaction type throughContract\SqlLink’sTTransactiontemplate:MysqlTransactionfor aMysqlLink,PostgresTransactionfor aPostgresLink,SqlTransactionfor a genericSqlLink. The host callsrollbackDangling()at the end of each unit of work; in a Kinetis applicationkinetis/database-bridge’s request-scope initializer registers it (see below).rollbackDangling()closes every tracked transaction independently (one failing never blocks the rest) and rethrows the first failure after all attempts;transaction()always preserves the exception that triggered cleanup over any secondary rollback failure, and neither path depends on the configured logger not throwing — see Appendix: Databases’s “What happens when cleanup itself fails”.Kinetis\Persistence\Exception\QueryException—getQuery(): string;getCode(), the server’s error number or0;getSqlState(): ?string, the SQLSTATE the server or driver reported;isUniqueViolation(): bool, true for SQLSTATE23505or MySQL-family error 1062 — see Database’s “Unique violations”.Kinetis\Persistence\ConnectionDefinition—__construct(string $dialect, string $host, string $database, string $user, string $password, ?int $port = null, string $driver = 'auto', ConnectionOptions $options = new ConnectionOptions(), int $warmConnections = 0):$dialectismysql|pgsql, a null$porttakes the dialect’s default (3306/5432), and$driverisauto|native|pdo. Rejects an unknown dialect or driver, a port outside 1–65535 and a negative warm count withInvalidArgumentException;ConnectionOptionsvalidates its own fields.Kinetis\Persistence\SqlConnectionFactory::create(ConnectionDefinition $definition, ?Contract\SqlInstrumentation $instrumentation = null): Contract\MysqlLink|Contract\PostgresLink— builds a runtime-matched driver client:autois the native async driver whenfrankenphp_handle_request()exists orRR_MODE=httpis set, PDO everywhere else. A positivewarmConnectionsopens connections at construction via each driver’swarmUp(?int $connections = null)— load-bearing for the mysqli driver under worker mode (see Performance tuning).Kinetis\Persistence\SqlConnectionFactory::singleSession(ConnectionDefinition $definition, ?Contract\SqlInstrumentation $instrumentation = null): Contract\MysqlLink|Contract\PostgresLink— the same build under one session: PDO whatever the definition’s driver says, and closed for good rather than reconnected if that session is ever discarded. For work held in the session itself, which is whykinetis/migrations’ commands run on it.Kinetis\Persistence\Contract\SqlInstrumentation—queryDispatched()/queryServerStarted()/queryReaped()/transactionStarted()/transactionEnded(), the moments every client and transaction reports to the instrumentation it was built with; without one they report nothing. A failure an implementation throws is contained by the drivers.Kinetis\Persistence\Testing\DatabaseTruncation— per-test database isolation for a consumer’s PHPUnit suite (see Testing): the tables a test names are emptied before it runs, and the test supplies the connection via an abstractdatabaseLink(). Holds no transaction of its own, so it works on every driver and with code that opens its own transactions.Testing\DatabaseIsolation(@internal) holds its table-name check.Kinetis\Persistence\Contract\PrefersPreparedStatements— a marker, no methods: this link is faster binding a value than reading it as a literal, so a caller that could safely emit either keeps binding. Carried byPdoMysqlClient/PdoPgsqlClientand their transactions, which run native prepared statements and memoize them per connection (a transaction runs on the client’s own connection, so it shares that one cache); the native drivers do not carry it, having no prepared statement for a bound value to reuse.Kinetis\QueryBuilder\Queryis the caller that branches on it.Depends on
psr/logandrevolt/event-loop, and on no Kinetis package (kinetis/frameworkonly inrequire-dev); the drivers useext-mysqli/ext-pgsql/PDO, suggested rather than required. The native Postgres driver additionally needsext-sockets— it ends a connection’s transport withsocket_shutdown()so a statement in flight can be abandoned without blocking the event loop — and refuses to construct without it. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/database-bridge (kinetis/database-bridge)¶
Separate Composer package, not part of kinetis/framework core: the
Kinetis wiring for kinetis/persistence.
Kinetis\DatabaseBridge\ConnectionFactory::fromConfig(Config $config, string $connection = 'default', array $poolOptions = [], ?string $driver = null): Contract\MysqlLink|Contract\PostgresLink— readsDB_*(orDB_{NAME}_*for a named connection, viaConfig::scopedKey()) into aConnectionDefinition, validating every key before any driver is constructed —DB_CONNECTIONfirst, so a named connection with no block at all is reported as itsDB_{NAME}_CONNECTION— and builds the client throughSqlConnectionFactory::create().$driveroverridesDB_DRIVERfor one call;$poolOptions['maxConnections']/$poolOptions['warmConnections']win overDB_MAX_CONNECTIONS/DB_WARM_CONNECTIONS. Used by this package’sPackageBootstrapandkinetis/queue-sql’sSqlQueueFactory; key reference in Configuration.Kinetis\DatabaseBridge\ConnectionFactory::singleSession(Config $config, string $connection = 'default'): Contract\MysqlLink|Contract\PostgresLink— the same keys throughSqlConnectionFactory::singleSession(): PDO whateverDB_DRIVERsays.kinetis/migrations’ commands run on it.Kinetis\DatabaseBridge\TelemetrySqlInstrumentation— adaptsKinetis\Persistence\Contract\SqlInstrumentationto core’sKinetis\Instrumentation\TelemetryInterface. Both factory methods build every client with one overTelemetry::global(), so a backendkinetis/telemetryswaps in after a client was built is the one that client reports to.Kinetis\DatabaseBridge\PackageBootstrap— declared viaextra.kinetis. Registers anAppScope::onRequestScopeCreated()initializer that bindsKinetis\Persistence\TransactionGuardlazily on everyRequestScope: the first resolution in a scope builds the guard and registers itsrollbackDangling()on that scope’s disposal, and a scope that never resolves it builds none. Every unit of work whose scope comes fromcreateRequestScope()gets the binding — an HTTP request (Kernel), a command (bin/kinetis), an MCP message over stdio (kinetis/mcp’sScopedMessageHandler), a queued job (kinetis/queue’sQueueWorker/SyncQueue); a#[Command(bootstrap: false)]command runs no package bootstrap and gets none. WithDB_CONNECTIONset, also buildsConnectionFactory::fromConfig()’s default connection, registers that exact object’sclose()onAppScope::onDispose()and then binds it under its dialect contract (Contract\MysqlLinkorContract\PostgresLink) before the application’s ownbootstrap.phpruns (which wins on the same binding), and bindsContract\SqlLinkas an uncached alias resolving that dialect contract on every lookup — the same object, no second connection or close, an application’s replacement of the dialect binding included; an applicationSqlLinkbinding replaces the alias. The close is registered ahead of the binding, so a later bootstrap or boot failure still closes an already-open connection, and it holds the link this package built — an application that binds its own owns that one’s lifetime. WithDB_CONNECTIONabsent, no connection is built, no link contract is bound and nothing is registered. Named (non-default) connections stay explicit app-side wiring for SQL. Withkinetis/orminstalled (detected withclass_exists()), also bindsKinetis\Orm\OrmFactoryRegistryonAppScope— built on first resolution from theOrmMetadatabound then, the link bound under the dialect contract whenDB_CONNECTIONis set, an application’s own binding included, and a link for every connection an entity names: the application’sdb.<name>binding, type-checked and never closed here, or else, after checkingDB_<NAME>_CONNECTION(Exception\DatabaseNotConfiguredExceptionwhen absent),ConnectionFactory::fromConfig($config, '<name>'), whose exact object is registered onAppScope::onDispose()and reused by a later resolution after a failed one — and registers an initializer bindingKinetis\Orm\EntityManagerRegistrylazily on everyRequestScope: the first resolution creates it and registers itsclose()on that scope’s disposal. WithDB_CONNECTIONset,Kinetis\Orm\OrmFactoryand the scope’sKinetis\Orm\EntityManagerare uncached aliases of the two registries’defaultentries; with it absent, both ids are bound to throwException\DatabaseNotConfiguredException, as is the registry when an entity lives on the default connection. Withoutkinetis/orm, no ORM id is bound.Kinetis\DatabaseBridge\OrmMetadata implements Kinetis\Cache\CacheableDiscoveryInterface— this package’sextra.kinetisdiscoveryclass.compile()reads the project’s PSR-4 roots and installed packages’scanroots through the operation’sKinetis\Cache\DiscoveryContextfor classes carrying#[Kinetis\Orm\Attributes\Entity]and returnsMetadataRegistry::fromClasses()->toArray()for exactly those;fromArray()rebuilds throughMetadataRegistry::fromArray()and reports itsMappingExceptionasInvalidCacheArtifactException, so a stale entry recompiles. Loadable withoutkinetis/orm: it then compiles[], reconstructs[], and reports any other entry as stale.Kinetis\DatabaseBridge\Exception\DatabaseNotConfiguredException— aRuntimeExceptionnaming the exact missing connection key.forOrm()namesDB_CONNECTIONwhen no default connection is configured, thrown by theOrmFactoryandEntityManagerbindings above, and byOrmFactoryRegistrywhen an entity lives on the default connection.forConnection()names a named connection’sDB_<NAME>_CONNECTION(DB_REPORTING_CONNECTIONforreporting), thrown byOrmFactoryRegistrywhen an entity names that connection, the application binds nodb.<name>and the key is absent — checked before any other key of that connection is read.Depends on
kinetis/framework,kinetis/persistenceandpsr/log(viapathrepositories to this monorepo’s root); suggestskinetis/migrations,kinetis/ormandkinetis/query-builder, and declares the last two as development dependencies only; conflicts withkinetis/orm<1.10.0, whoseOrmFactoryRegistryandEntityManagerRegistrythe ORM wiring requires. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/redis (kinetis/redis)¶
Separate Composer package, not part of kinetis/framework core — and,
like kinetis/revolt-http-client, not dependent on it either:
kinetis/framework appears only in require-dev, for the
NoStaticPropertiesRule dogfooding. Installable and usable in any
Revolt-based project. It owns the transport; PSR-16, Config,
serialization, and cache key policy stay with kinetis/cache-redis.
Kinetis\Redis\Client— one node, and the package’s whole single-node surface.create(Endpoint, ClientOptions)opens nothing: the socket, the TLS handshake,AUTHandSELECTall run on the first command.execute(string $command, int|float|string ...$parameters)returns the unwrapped reply. It implementsRoutedExecutortoo, ignoring the routing key, so one consumer works unchanged against a node and a cluster;allowsCrossSlotKeys()is true here, which is what keepsMGETand a multi-keyDELone round trip.link()returns the vendorAmp\Redis\Connection\RedisLinkfornew Amp\Redis\RedisClient($client->link()), which is howkinetis/queue-redisreaches the typed command facade.Kinetis\Redis\ClusterClient—create(non-empty-list<Endpoint>, ClientOptions), refusing a non-zero database because Redis Cluster has noSELECT. The slot map is read from the seeds viaCLUSTER SLOTSon the first operation, and accepted only when its ranges cover 0-16383 exactly once.executeKeyed()/script()route on a caller-named key — never one guessed from a command’s parameters. AMOVEDreply patches that one slot and the command goes straight to the target it named, with no topology read in between — the reply proves both. Discovery itself reads the seeds in order, each given an equal share of what is left of the budget, and concurrent fibers share one in-flight read; a fiber waiting on another’s read reports its own expiry asException\TopologyUnavailable. A routed operation that fails withException\ConnectionFaileddrops the cached map before reporting it, so the next operation rediscovers the topology and routes at the current owner;Exception\OutcomeUnknownleaves the map in place, and neither re-sends the command. AnASKreply leaves ownership alone and sendsASKINGplus the command as one socket write on the target’s existing connection, so no other fiber’s command can land between the pair and no second connection per redirect exists. A script underASKis sent asASKING+EVAL, neverEVALSHA, which would consume theASKING. Six attempts in total per operation, thenException\RedirectLimitExceeded.nodes()returns oneClientper current master, deduplicated, from a fresh discovery — what a prefix scan across the whole keyspace needs.Kinetis\Redis\Internal\Link(@internal) — the pipelined, non-replaying connection both clients run on, replacingamphp/redis’sReconnectingRedisLink, which re-sends every queued command after a connection loss. Concurrent fibers share one socket and replies match commands in order. A failure before the write isException\ConnectionFailedand is safe to retry; a failure after it isException\OutcomeUnknownand is never written again. Discarding a connection settles every frame on it exactly once.close()aborts a connection attempt still in flight, and an attempt that is no longer the link’s current one closes its socket rather than publishing it. The socket is referenced only while a reply is outstanding, so an idle link letsEventLoop::run()— and thereforeKinetis\Async\concurrently()— return.Kinetis\Redis\Deadline— one absolute monotonic budget per operation (ClientOptions::$timeoutseconds), covering connect, TLS,AUTH,SELECT, write, reply, discovery, and every redirect hop. The write is bounded by closing the socket, since Amp’s writable stream takes no cancellation and backpressure would otherwise suspend past any deadline. A budget already spent is refused before dispatch asConnectionFailed, on the first dispatch of an operation and on every later one. Expiry after the write closes the connection and fails every frame on it once asOutcomeUnknown, so a half-open connection to a vanished server heals on the next call rather than wedging the link.Kinetis\Redis\Endpoint/ClientOptions/ConnectionUri— a{host, port}pair parsed fromhost:portor[ipv6-address]:port; an immutable password/database/TLS/timeout carrier propagated unchanged to seeds, discovered masters, and redirect targets alike; and aredis://URI read through the vendorRedisConfig, so any URIamphp/redisaccepts is accepted identically. The password reaches the wire only as theAUTHframe’s argument; no exception carries it, a URI holding it, or a server reply quoting a rejected credential, and the parameters that carry it are marked#[\SensitiveParameter]so a trace cannot either. A malformed URI is reported by a fixed message with no cause attached, since the vendor’s own failure quotes the whole URI back.Kinetis\Redis\Cluster\HashSlot/SlotMap/Redirect— CRC16-XMODEM mod 16384 with{...}hash-tag handling; the parsedCLUSTER SLOTSmap plus itsMOVEDpatches; and a structurally validated redirect reply,nullfor a message that is not one andAmp\Redis\Protocol\ProtocolExceptionfor one naming a kind it then fails to parse.Four exceptions, all extending
Amp\Redis\RedisException:ConnectionFailed,OutcomeUnknown,RedirectLimitExceeded,TopologyUnavailable. A Redis error reply staysAmp\Redis\Protocol\QueryException.Depends on
amphp/redis,amphp/socket,amphp/amp, andrevolt/event-loop. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/cache-redis (kinetis/cache-redis)¶
Separate Composer package, not part of kinetis/framework core — extracted
from it for the identical reason kinetis/persistence was: core has no
direct Redis dependency either. NullSimpleCache and the PSR-16 exception
types stay in core (see Appendix: System Layout’s Kinetis\SimpleCache section);
only the classes with a real Redis dependency moved. The transport
itself moved on again, into kinetis/redis (above), which this package
composes.
Kinetis\SimpleCache\RedisSimpleCache— one PSR-16 class overkinetis/redis’sRoutedExecutor, serving a single node and a Redis Cluster alike: the executor routes each key, and the only behaviour that differs is whether one command may name keys from more than one slot.fromConfig(Config $config, string $connection = 'default')returnsnullwhen Redis is not configured at all. Values are serialized withAmp\Serialization\NativeSerializer. Also implements core’sKinetis\SimpleCache\AtomicCounterInterface:increment()runsINCRandEXPIREas one Lua script, so concurrent callers each receive a distinct value, andcount()reads that counter — a bare integer, not a serialized value, soget()cannot read it. AndKinetis\SimpleCache\AtomicConsumeInterface:consume()runsGETandDELas one Lua script, so at most one of two concurrent callers ever receives a given value. Both scripts carry exactly one key, so each runs on the node that owns that key’s slot.replace(string $key, mixed $value, int $ttlSeconds): boolis oneSET ... EX ... XX— it writes only over a key that is already there and reportsfalseotherwise, which is whatkinetis/session’sRedisSessionStoreneeds and PSR-16 cannot express. And core’sKinetis\SimpleCache\DisposableCacheInterface: the constructor’s optional?Closure $disposer, after the serializer, closes the executor;fromConfig()passes the executor it opened asdisposer: $client->close(...), and a cache constructed without one borrows its executor.dispose()drops the disposer before running it, so a second call closes nothing.Physical keys are
kinetis_cache:<namespace>:<key>, withREDIS_CACHE_NAMESPACE([A-Za-z0-9_-]+,defaultunless set) naming the namespace. The prefix carries no{}hash tag, so keys still spread across cluster slots rather than collapsing onto one.clear()runsSCAN MATCH <prefix>*plusUNLINKin bounded chunks on every entrynodes()reports, neverFLUSHDB, so keys this cache did not write survive it; it is non-atomic, and costs one pass over each node’s keyspace.getMultiple()/deleteMultiple()send oneMGET/multi-keyDELwhenallowsCrossSlotKeys()is true and one command per key concurrently throughKinetis\Async\concurrently()otherwise, since Redis Cluster rejects a multi-key command whose keys do not share a slot. Cache failures surface asException\CacheExceptionnaming the operation and never the key, which is routinely a session identifier or a token hash.Kinetis\SimpleCache\RedisConnectionFactory— theREDIS_*half:fromConfig()mapsREDIS_CLUSTER/REDIS_CLUSTER_SEEDS,REDIS_URL, or discreteREDIS_HOST/REDIS_PORT/REDIS_DATABASEto aKinetis\Redis\ClusterClientorClient, andoptions()mapsREDIS_TIMEOUT(the whole operation budget),REDIS_PASSWORD, and theREDIS_TLS*keys toClientOptions.Kinetis\Config\Configis a framework type, so this mapping lives here rather than in the standalone transport package.Kinetis\Container\AppScope::boot()callsRedisSimpleCache::fromConfig(),class_exists()-gated against this package, and registers that cache’sdispose()onAppScope::onDispose(); a cache the application bound first is neither built nor registered. Redis configured (REDIS_HOST/REDIS_URL/REDIS_CLUSTER) but this package not installed binds core’sUnavailableSimpleCache, whose every operation throwsKinetis\SimpleCache\Exception\SimpleCacheUnavailableExceptionnaming this package — never a silent fallback toNullSimpleCache, and never a boot-time failure for an application that doesn’t touch the cache.Depends on
kinetis/frameworkandkinetis/redis(both viapathrepositories to this monorepo’s root),amphp/redis,amphp/socket,amphp/serialization. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/mcp-protocol (kinetis/mcp-protocol)¶
Separate Composer package requiring PHP and nothing else — no framework,
container, HTTP transport, attribute, reflection, discovery, docs
fetching, filesystem policy or application bootstrap. It owns the MCP
wire three servers share: kinetis/mcp, kinetis/mcp-docs and
kinetis/orbitron. Namespace Kinetis\McpProtocol, no binary, no
extra.kinetis, so installing it registers nothing anywhere. The
contract is stated once in Appendix: MCP Reference.
ServerInfo— the immutable identityinitializeanswers with:name,version, and optionalinstructions.ToolDescription/ToolAnnotations/ResourceDescription— wire DTOs, not discovery records: a tool’s published name, description,inputSchemaand optional annotations, and a resource’suri,name,descriptionandmimeType.ToolAnnotationscarries all four 2025-06-18 hints (readOnly,destructive,idempotent,openWorld) together, because a partial set is the one that misleads — a tool declaringreadOnlyHint: falseand omittingdestructiveHintreads as the specification’s default, which is destructive. A tool with no annotations omits the object entirely.ToolResult/ResourceResult— one text content block, anisErrorflag and optionalstructuredContent, and one read’suri/mimeType/text.ToolResult::text()andToolResult::error()carry text alone;ToolResult::structured(string $text, array $document, bool $isError)also carries the associative document that text encodes, whichMcpServer::callTool()sends asstructuredContentbeside the unchanged text block — cast to an object, so an empty document is{}— and omits when none was supplied. Encodability is settled by the server’s response check like every other member; no output schema is validated or published.McpApplication— the one interface a consumer implements:tools(),resources(),callTool()andreadResource(). The server owns everything around it — it validates every envelope and parameter, resolvesname/uriagainst those two lists, and never invokes a call for a name they do not carry, so an implementation answers only for its own published surface. Capabilities are derived from the same lists: a consumer publishing no tools advertises notoolscapability.$contextis an opaque?objectthe server passes through untouched; the package names no container, scope, lifecycle or logging concept.ProgressEmitter— onetools/call’s progress, tied to that call’s token.report()invokes the transport’s emit closure synchronously on the tool’s own call stack, and does nothing when the call carried no token or the transport cannot carry a notification.totalandmessageare omitted from the notification when not given rather than written asnull.MessageHandler— what a transport hands one decoded message to:handle(array $message, ?Closure $emit, ?object $context): ?array.McpServerimplements it; an adapter needing a per-message unit of work wraps one and implements it too, which is howkinetis/mcpkeeps its request-scope policy out of the transport.McpServer— MCP2025-06-18and no other revision, against oneMcpApplication. Stateless: immutable server metadata and the consumer reference, and nothing else — no negotiated version, session, arguments, progress token or result survives a call, which is what lets one instance serve a persistent stdio process and a stateless HTTP route at once. It follows that initialization ordering is not enforced.initializealways selects2025-06-18whichever revision the client asked for, per the specification’s own negotiation rule, so a single-version server has no unsupported-version error to raise. Six methods:initialize,ping,tools/list,tools/call,resources/list,resources/read; anything else is-32601. Notifications are answered with nothing and dispatch nothing, and a well-formed client response message is ignored, since this server sends no requests. A tool that ran and failed is an ordinaryisError: trueresult; only a malformed request, an unknown method, an unknown tool (-32602) or resource (-32002), or an unexpected failure is a JSON-RPC error. A consumer’s ownJsonRpcExceptionreaches the client as written; any other exception is contained as a generic-32603whose caught text is discarded rather than reported, so a consumer that wants diagnostics writes them itself.JsonRpcCodec/JsonObject— the one decode and structural-validation path every entry point shares, with three outcomes rather than one nullable answer: a message to dispatch, an error response to send, or a message to ignore. Object-mode decode keeps a JSON object astdClassand a JSON array a plain array, and that fidelity is kept rather than flattened, so a tool’sargumentsreaches its consumer with{}and[]still distinct at every depth.isValidId()admits a string or an integer only:2025-06-18states an id MUST NOT be null, so{"id": null}is an invalid request rather than a request whose id is null.JsonObjectis the explicit “treat this as an object” marker a caller building a message in PHP needs, since[]is the only array literal for both empty shapes;toObjectTree()converts a tree holding markers back to thestdClass/array shape a consumer receives, so a marker never reaches consumer code.StdioLoop— the transport: one message per line in, one frame per response out, synchronous and one at a time, which is the backpressure. EOF ends the loop normally. Input is read in bounded chunks and capped atMAX_PAYLOAD_BYTES(2 MiB, matching the framework’s defaultMAX_BODY_SIZE); a line past the cap is drained through its next terminator or to EOF, answered with exactly one-32700underid: null, and the next frame is then processed normally, while a full chunk arriving with no terminator is not oversized on that ground alone and a final unterminated line at EOF is a complete message. Only\r/\nare stripped, never a baretrim(), whose default charlist would also remove NUL and vertical-tab bytes. Every frame is written whole,fwrite()looped until every byte lands; a write that stops making progress isException\StdioWriteExceptionand ends the loop, since nothing can safely follow a partial frame. A failed progress write is stashed rather than thrown out of the emit closure — where the consumer would catch it as an ordinary tool failure and that failure would then be written into the corrupted stream — every later notification for that message is skipped, and the stash is re-thrown once the handler returns, before any final response is attempted. There is no output cap: a tool result has no universal safe size, so each consumer bounds its own.Exception\JsonRpcException/Exception\StdioWriteException— one named constructor per code a call site may raise, so none is invented, and the partial-frame failure carrying the exact byte counts.Depends on nothing. Own
composer.json/phpunit.xml/phpstan.neon/psalm.xml— the last two without core’sNoStaticPropertiesRule/NoBlockingIoRule, which ship inkinetis/framework.
packages/mcp (kinetis/mcp)¶
The Model Context Protocol server for a Kinetis application. Installing
the package is the whole setup: its extra.kinetis declares a scan root
covering the namespace, a bootstrap, and a discovery class, so the
/mcp route and mcp:serve appear with nothing to wire. Core has no MCP
surface without it. The wire belongs to kinetis/mcp-protocol; what
lives here is everything Kinetis-specific around it.
PackageBootstrap— lazy-bindsKinetis\McpProtocol\McpServer, built from aServerInfonaming Kinetis and aKinetisMcpApplicationaround whateverMcpRegistryis already resolvable from the container.McpRegistryis not discovered here at all — it’s declared as this package’s ownextra.kinetisdiscoveryclass instead, so the framework itself compiles, caches, and binds it before this method ever runs (seeKinetis\Cache\PluginDiscovery).KinetisMcpApplication— the whole adapter from this package’s registry and dispatcher to the sharedMcpApplicationcontract.tools()/resources()mapToolDefinition/ResourceDefinitionto the protocol’s own descriptions.callTool()converts the rawstdClassarguments throughKinetis\Validation\JsonTree::convert()— which is what keeps a JSON object whose keys happen to look sequential ({"0":"a","1":"b"}) distinguishable from a real JSON array once flattened, soHydrator’s array/iterable check and#[ListOf]can refuse the first — unwraps only the top-level marker, and hands the members toMcpDispatcherwith the per-message container as its scope. A returnedToolResultpasses through unchanged, which is how a tool reports a deliberate refusal; any other return is JSON-encoded as a successful text result. AValidationExceptionbecomes anisError: trueresult carrying its real violations, the argument feedback an agent retries on; any other exception becomes the fixed textTool execution failed.with the real one logged. A resource has no error result of its own, so a throwing resource method is logged and raised as a generic-32603. Both logging calls route through a privatelogSafely(), which catches and discards a failure from the logger itself: one bad message, or one bad log call, must not crash a long-running stdio process or replace a result already decided on.ScopedMessageHandler— the stdio lifecycle policy, and the only place it lives. It wraps the shared server and gives each decoded message a freshRequestScopefromAppScope::createRequestScope()with every package request-scope initializer run on it, passes that scope as the opaque context, and disposes it — followed bygc_collect_cycles(), since a Kinetis request scope can hold cycles — in afinally, before the transport writes the final frame. Disposal is guaranteed not to throw: a failure is logged throughSafeLogger::logFrom()againstAppScope’s own logger, since the message’s own scope is already disposed by then, and it never writes a second protocol response or ends the process. A parse error the codec answers on its own never reaches the handler and so creates no scope. Over HTTP there is no decorator: the request already has a scopeKernelcreated and disposes.Http\McpController—/mcpas an ordinary route (#[Post('/mcp')],#[Middleware('@mcp')]). Decodes the body through the sharedJsonRpcCodec, enforces the one protocol header, then either opens the SSE progress stream or dispatches and returns one buffered JSON response.MCP-Protocol-Versionis the only protocol header:initializemay omit it, every later message must carry exactly2025-06-18, and a missing header on a later message means the specification’s2025-03-26fallback this single-version server does not implement, so it is400rather than assumed — nothing is persisted between requests to infer it from. Malformed input is400with the JSON-RPC parse/invalid-request envelope; a protocol error after a valid envelope is an ordinary200, because the request was understood and its outcome belongs in the envelope. A valid notification, and a client response message, are202with no body. Sessions are absent, so noMcp-Session-Idis ever emitted, andGET/DELETEdeclare no routes: the router’s own405withAllow: POSTis what a server implementing neither a server stream nor session termination returns.wantsProgressStream()requiresarray_key_exists('id', ...), thetools/callmethod, and a_meta.progressTokenthat is already a string or an integer — streaming is request-only, and a malformed token belongs in an ordinary bufferable-32602rather than inside a stream already committed totext/event-stream. The stream is aStreamedResponsewhose emitter dispatches on theRequestScopeinjected into the controller — the request’s own, whichKernelkeeps alive for a streamed body and disposes through its lease once the emitter returns or fails, so a tool sees every binding anmcp-group middleware published, under every id it used, exactly as an ordinary call does; this package owns none of that lifecycle and creates no second scope. The body is read with a(string)cast, notgetContents(): the staged stream is replayable, and the cast is the representation that rewinds first, so a middleware that already inspected the body cannot shorten the envelope this decodes.Http\McpOriginMiddleware— the spec-requiredOriginvalidation, readingMCP_ALLOWED_ORIGINS(comma-separated exact list, empty means any request carrying an Origin is rejected403). A permanentmcp-group member at priority 100 — which is also what guarantees the groupMcpControllerreferences always exists.Http\McpIdentityGuardMiddleware— the group’s permanent member at priority 0, the last thing to run beforeMcpController. Delegates whenMCP_HTTP_PUBLIC(aConfig::bool()read, so an unrecognized value throwsKinetis\Config\Exception\InvalidConfigValueException) is true, or when the request’s ownRequestScopereportsisRegistered(Kinetis\Http\CurrentUserInterface::class); otherwiseErrorResponse::create(401, 'Unauthenticated.'), with noWWW-Authenticateheader — the scheme belongs to whichever authentication middleware the application put in the group at the default priority 50.isRegistered()specifically, neverhas()/get(): both answer for any autowirable class, so either would accept a manufactured, disconnected object as proof of authentication.CurrentUserInterfacealone counts — an identity published only under a concrete user class is not the portable boundary a tool or another package depends on. Model Context Protocol (MCP)’s “Securing the HTTP transport” is where the contract itself is stated; stdio, which has no middleware group, is unaffected.Console\McpServeCommand—#[Command('mcp:serve')], resolving the bootstrap’s own server binding and running the sharedStdioLoopover aScopedMessageHandlerbuilt from the realAppScope.McpRegistry—#[McpTool]/#[McpResource]discovery,toArray()/fromArray()for the AOT cache. ImplementsKinetis\Cache\CacheableDiscoveryInterface;compile()delegates toMcpDiscovery::discover()with the operation’sKinetis\Cache\DiscoveryContext. A tool name and a resource URI are each globally unique across every registered class:register()stages a class’s own definitions and checks them — against everything already registered, and against each other — before appending any of them, so a class with one conflicting definition registers none of them (atomic per class), and a genuine name/URI collision between two different classes throwsKinetis\Mcp\Exception\DuplicateDefinitionExceptionnaming both conflictingClass::method()pairs rather than letting one silently shadow the other.register()is idempotent per class ($registeredClasses, the same patternEventListenerRegistry::register()already uses): registering the same class a second time — directly, or viaMcpDiscovery’s own two scan passes both finding it — is a safe no-op, never re-appends its definitions and never treats them as a conflict against themselves.toArray()/fromArray()carry a tool’sinputSchemaas its own JSON text, ininputSchemaJson— an unambiguous JSON representation of the schema, written so the cache can restore it, not the bytes a transport puts on the wire (a transport encodes the wholetools/listresponse itself, withoutJSON_PRESERVE_ZERO_FRACTION, so a stored1.0goes out as1). JSON Schema distinguishes the empty object{}from the empty array[]:JsonSchemaspells{}as a live(object) [], produced for a zero-parameter tool’spropertiesand for amixed-typed argument’s whole schema, while an emptyrequiredlist is an ordinary empty array that stays an array. A PHP array expresses only the second of the two, and a compiled artifact carries plain data only —Kinetis\Cache\CacheStorerefuses a live object anywhere in it before writing. A JSON string is plain data and already carries the distinction, so the artifact holds one self-describing field with nothing alongside it to disagree with, and every empty object and every empty array survives at any depth and in any combination. The root is cast to an object on the way out and read back as an array on the way in, sinceToolDefinition::$inputSchema’s own array type fixes the root’s JSON type rather than the document doing so.fromArray()rejects three things asKinetis\Cache\Exception\InvalidCacheArtifactException— text that is not valid JSON (a truncated or hand-edited file), a document whose root is not a JSON object, and an object whose member names PHP reads as numbers, which a PHP array cannot hold apart from a list and whichtoArray()never writes. That is what lets the framework’s cache loaders classify the artifact as unusable and recompile from live discovery, rather thantools/listadvertising — andtools/callvalidating against — a schema the application never declared.fromArray()validates the exact top-level and per-entry shape viaKinetis\Cache\Exception\ArtifactValidation, satisfyingCacheableDiscoveryInterface::fromArray()’s own contract to throw something implementingKinetis\Cache\Exception\CacheArtifactExceptionInterfacefor malformed data —inputSchemaJsonis validated only as far as decoding it requires, per the three rejections above, its own deeply recursive JSON-Schema vocabulary never re-checked here — and re-checks the same tool-name/resource-URI uniqueness invariantregister()enforces live, rejecting a compiled artifact carrying a duplicate rather than silently preserving whichever entry was listed first; everycontrollerClassit reads also marks that class registered, so a later liveregister()call for a class already present in a loaded artifact is the same no-op it would be after a live registration.McpDispatcher— the MCP analogue ofHttp\Dispatcher.callTool()/readResource()take an optional per-call scope the adapter passes through from the transport; the controller and its dependencies resolve from it, falling back to the constructor’s container when none is given (which is then not per-message-scoped). Every argument failure is aKinetis\Validation\Exception\ValidationExceptioncarrying structured violations, never a dispatcher-specific exception: an argument that is absent with no default isrequiredat its own path, and a scalar one entersHydrator::resolveScalar()underInputSource::Json. An#[ObjectMap]argument, recorded as the binding plan’sobjectMapflag fromHydrator::objectMap(), entersHydrator::resolveObjectMap()instead — the path a DTO’s#[ObjectMap]field takes — and binds a JSON object as a recursively plain array, reportingnot_a_json_objectfor a JSON array. A DTO-typed argument decides null first, exactly as a#[Body]field does — a nullable parameter takes it, a non-nullable one reportsnull_not_allowed— then accepts an existing instance or hydrates an object-shaped value, and reportsnot_an_instancefor any other object andtype_mismatchfor a scalar. The arguments object is closed: a key naming no client-facing parameter isunexpected_fieldat its own path (Hydrator::unexpectedFieldViolation(), the same code and sentence a JSON DTO member gets), and an injectedProgressReporterparameter is never such a name. Every argument failure a call has — unknown, missing, wrong-typed or rule-refused — is collected and raised in oneValidationException, in parameter order followed by the call’s own key order. A resource has no client argument object, and an already-constructed DTO instance handed straight to a parameter has no member map to close.derivePlan()refuses a composite parameter type with the framework’s ownValidation\Exception\JsonSchemaException::compositeType()rather than binding it asmixed, so live binding andMcpRegistry’s schema generation admit exactly the same method declarations;$bindingPlans/$hydrationPlansremain an optional constructor seam nothing currently fills.ProgressReporter— the Kinetis-facing progress API, injected by type into a tool method so an application controller imports nothing from the wire layer. It wraps the protocol package’s ownProgressEmitterand delegates; without_meta.progressTokenon the request there is no emitter andreport()does nothing, so tool code calls it unconditionally.McpDiscovery::discover(Kinetis\Cache\DiscoveryContext $context, ?array $paths = null): McpRegistry— builds a registry from every class found anywhere under a project’s own PSR-4 root(s), plusKinetis\Mcpitself, read through the context (see Appendix: System Layout’sKinetis\Cache), rather than an explicit registration file.$paths, orMCP_DISCOVERY_PATHSwhen omitted, restricts the project-side scan. This is the same live-discovery pathMcpRegistry::compile()calls for the AOT-cache build, not a separate mechanism.
packages/mcp-docs (kinetis/mcp-docs)¶
Separate Composer package, framework-agnostic — kinetis/mcp-protocol is the only Kinetis package it depends on, and that one registers nothing — the owner of this documentation site’s catalogue and fetch, described in full in MCP Documentation Server. Runs as a server of its own, and is also what kinetis/orbitron requires and composes to publish the same kinetis://docs/* resources and the same window and search tools from its own connection; no copy of the catalogue or of either tool exists, since Orbitron holds a DocsApplication and delegates to it. Namespace Kinetis\McpDocs, one binary (bin/kinetis-mcp-docs), setup.sh, which installs and registers it with Claude Code or Codex, and start.sh, the command that registration spawns.
DocsCatalogue— the fixed list of pages this server serves, asDocsPageentries, plusfind()for the URI aresources/reador a window call names. Three constants fix the resource URI prefix, the raw source base URL, and thetext/markdowntype; nothing about any of them is configurable.tests/DocsCatalogueTest.phppairs the list against the repository’s owndocs/*.md, so a page added without an entry, or an entry naming a page that no longer exists, fails the suite.DocsPage— one entry:slug,name,description, and theuri()/sourceUrl()both derived from the slug.DocsFetcher— reads one page over HTTPS through Symfony’s HTTP client. The client is the constructor’s only argument, so the suite can pass aMockHttpClient; every property of the request is fixed here — TLS verified peer and host at TLS 1.2 or better, no redirect followed, an idle timeout and a total deadline, and a streamed body abandoned past 4 MB. A non-200 status, a transport failure, or an oversized body isException\DocsFetchException.DocsApplication— the whole consumer adapter overKinetis\McpProtocol\McpServer: the catalogue asResourceDescriptions, one read perresources/read, thekinetis_read_docandkinetis_search_doctools, andserverInfo(), the one authority for the server’s name, version andinstructions.READ_TOOLandSEARCH_TOOLare the exported tool names, andreadTool()andsearchTool()the one authoring of each tool’s description, schema and annotations — both read-only, non-destructive, idempotent, open-world — whichkinetis/orbitronincludes rather than restating. A call validates the whole closed schema (uri, optionalstartLine, optionallineCountof 1..MAX_LINE_COUNT) into-32602before the catalogue is consulted; a well-typed URI the catalogue does not carry, and astartLinepast the page, areisError: truedocuments carryingresource_unknownandline_out_of_range. A window splits the fetched page after every newline, so terminators, CRLF and a missing final newline survive and successive windows concatenate back to the page exactly as long as it has not changed on the remote between calls, and it ends at the requested line count orMAX_CONTENT_BYTES, whichever comes first — never mid-line, and never empty, so a single line past the ceiling is served whole. Every window, search and refusal document is returned throughToolResult::structured()with the array it was encoded from, so the text andstructuredContentcarry one document. A search validates its closed schema (uri, aqueryof 1..MAX_QUERY_LENGTHcode points, optionalstartLine) the same way, shares the window’s page lookup, refusals and fetch, compares each line literally and case-sensitively without its terminator, and returns at mostMAX_MATCH_COUNTmatches, withhasMoreset when a later line matches. A page whose fetch failed, or whose bytes are not valid UTF-8 — which could not go into a JSON frame at all — is reported to the diagnostics stream and raised as-32603with the fixed messageCould not read "<uri>".for a resource read, a window and a search alike; the URL and the real reason never enter the response. Nothing is cached, so every call fetches the page again andhasMoredescribes only the response carrying it.SERVER_VERSIONis paired against this package’s manifest version by the suite.bin/kinetis-mcp-docs— locates the autoloader through Composer’s generated bin proxy, then runs the sharedStdioLoopover anMcpServerbuilt fromDocsApplication, with stderr as the diagnostic stream. Nothing but JSON-RPC frames reaches stdout, and a failure that ends the loop is reported on stderr with exit code1.Depends on
kinetis/mcp-protocol,symfony/http-clientandsymfony/http-client-contracts. Owncomposer.json/phpunit.xml/phpstan.neon— the last without core’sNoStaticPropertiesRule, which ships inkinetis/framework.
packages/orbitron (kinetis/orbitron)¶
Separate Composer package, installed with composer require --dev. The development-only construction harness described in full in Orbitron: four documents for the coding agent a developer brings, three of which only read, reachable as four commands or as four MCP tools; three further MCP-only tools reach one real installed, non-root package’s own source, Kinetis first and any other dependency the project installed the same way — one reads a bounded line window of a file, one searches a file for a literal string, one lists the direct children of a directory; plus its context resource and — over MCP only — this documentation’s kinetis://docs/* resources, served through the kinetis/mcp-docs it requires. Namespace Kinetis\Orbitron, one binary (bin/kinetis-orbitron-mcp, launched by an MCP client rather than registered with the framework), no bootstrap, no discovery plugin — extra.kinetis.scan naming Kinetis\Orbitron\Console\ is the whole framework registration.
PackageFact— one record from Composer’s installed set:name, the nullableversion/installPath, and arootflag. The first two are nullable becauseComposer\InstalledVersions::getInstalledPackages()also lists every name an installed package replaces or provides, reporting null for each on those;rootis the other distinction that list does not draw, read fromInstalledVersions::getRootPackage(), the one place the root project’s name is stated.InstalledPackages— the retention rules, and the seam the suite constructs directly. A list ofPackageFactobjects is read as given; null reads Composer’s own installed set instead, which is what the container’s autowiring builds for a command.fromProject(projectRoot)is the third construction, for the MCP server: it evaluates that project’s generatedvendor/composer/installed.php— the shapeComposer\InstalledVersionsdocuments and itself requires — and converts itsrootname and itsversionsentries’pretty_versionandinstall_pathinto the same records, so a server outliving acomposer requireis not bound to Composer’s process-global cache and never mutates it; an absent or malformed inventory is aRuntimeExceptionrather than an older set reported as current. A record is kept only when it carries both a version and an install path, so a replaced- or provided-only name, and a metapackage with no files of its own, never reaches a document; vendor is not part of that test, because an exact installed dependency is the authority for its own behavior and its install root has to reachsource()whoever published it. Entries are keyed by name — one per name, first record winning, as Composer’s own lookup does — andksorted.records()returns{name, version}pairs, and is the one Kinetis-only view: it applies thekinetis/filter, because the reported inventory is the harness’s own, and leaves out the Composer root project, which is what is being developed rather than something the project installed, so a root under thekinetis/vendor is not a dependency to report; install paths never reach these records.source(name)is the one other accessor:{version, root}for a real installed, non-root package of any vendor, null otherwise, and the one place an install path leaves this object — handed toPackageSourceReaderalone, which reports neither the root it resolved nor the path it opened.orbitronVersion()is the detectedkinetis/orbitronversion, the single authority every document reports; it answers from the retained set rather than fromrecords(), so a checkout of this package developing itself still names its version, and it throws rather than inventing one when no such record exists. Nothing is memoized between instances.Context— the context document.toArray()is the document: the harness identity and its limits, the guide links, the workflow, each command’s own effect boundary, the launcher note, and the package facts.toMarkdown()renders that same array, so the two formats cannot state different things.LayoutState/LayoutCheck— the state one layout check concluded (pass,error,skip), and the check itself: reported name, state, and the stable machine code for why.skipis the state of a check that was never performed, not a weakererror.ProjectLayout— the layout reader and its immutable result.read()takes the already-detected project root and appends the fixedcomposer.jsonitself, so no caller-selected path is opened. The manifest is read throughfopen()/stream_get_contents()bounded atMAX_MANIFEST_BYTES(1 MiB) plus one byte, the single byte that separates an admitted manifest from an oversized one;file_get_contents()is never used. A JSON object root is required — decoding cannot tell{}from[], so the first non-whitespace byte is checked too. Each ofautoload.psr-4andautoload-dev.psr-4must carry exactly one prefix whose single string mapping is exactlysrc/ortests/, with a non-empty PSR-4 prefix ending in\; two prefixes on a fixed path, or an array-valued mapping reaching it, are errors. Unrelated mappings, array-valued ones included, are left alone. A manifest that could not be used makes both namespace checksskip.namespaces()answers only when both are valid. Codes name an outcome and never carry file contents, exception text, or a path.ScaffoldMode/ScaffoldStatus/ScaffoldOutcome— which operation ran (preview,apply), what it concluded (ready,created,refused,failed), and the protocol-neutral result: mode, status, the stable codes in a fixed order, and the project-relative files a failed rollback may have left. The two targets are not carried on the result because they are fixed;HealthScaffold::TARGETSis the one authority.succeeded()isreadyorcreated.ScaffoldWriter/FileScaffoldWriter— the exclusive-create write set and nothing wider:create()/write()/flush()/close()/remove(), each reporting failure as a value and none throwing.FileScaffoldWriteropens withfopen($path, 'x+b')only, so an occupied path — regular file, directory, symlink, or symlink pointing at nothing — is never opened and never truncated. The interface exists so the suite can drive a short write, a write that makes no progress, a failing flush, close, second create or removal.HealthScaffold— the one construction workflow, protocol-neutral, with no input beyond the project root each call is given.preview()reads;apply()re-reads everything and is the only write. Both resolve the layout throughProjectLayout, then the four fixed directories (src,src/Http,tests,tests/Http: each a real directory, resolving under the physical root, none a symlink) and both targets (occupied isfile_exists()oris_link()). Refusal codes are deduplicated and emitted in one declared order. The write set isTARGETS, written in that order, each byte looped until written, flushed and closed with both outcomes checked; a failed create, write, flush or close removes every file this invocation created and never a pre-existing one, and a removal that fails is reported with exactly the relative paths that may remain. It creates no directory, reads no application source, and therefore cannot pre-detect aGET /healthroute declared elsewhere — the generated test surfaces that through route discovery. Nothing per-invocation survives the call; the object holds only its writer.PackageSourceReader— one bounded line window of one real installed, non-root package’s own file, a literal search of such a file or of a bounded directory tree of such a package, or a directory listing, read live on every call:InstalledPackages::source()resolves the package to its install root and version;pathis admitted by syntax alone before anyrealpath()runs — relative,/-separated, no empty segment, no segment beginning with.(which covers.,..and every hidden name) and no first segment ofvendor— so the install root, not a fixed location list, is the read boundary; that is what makes a root-mapped production class, alibtree, a generated or classmap directory and the package’s own tests readable. The resolved target isrealpath()d and must both remain inside the admitted root with the directory separator checked (so a sibling merely sharing a name prefix is not treated as inside) and be a regular file; the path a symlink resolved to is re-admitted against the same syntax rule, so one pointing out of the package, or onto a hidden name or the package’s own top-levelvendor/, is refused rather than followed. The read is onefopen()/stream_get_contents()bounded atMAX_SOURCE_BYTES(1 MiB) plus one byte, the single byte that separates an admitted file from an oversized one; a NUL byte or invalid UTF-8 is refused rather than returned. Lines are split keeping each own ending,startLinepast the last line isline_out_of_range, and the window andhasMoreare computed from the total line count. One privateresolve()owns the lookup, the admission, therealpath(), the root-prefix check and the resolved-target re-admission, andlines()the file work behind them, soread(),search(),searchTree()andlist()are admitted and confined by the same code rather than by copies of it. Because a directory name is admitted syntax, a read or a search naming one reaches the regular-file check and issource_unreadable.search()adds only the scan: a case-sensitivestr_contains()over each line as it is reported — without the\nor\r\nthe file stores after it — fromstartLine, stopping at the first match pastMAX_MATCH_COUNT(50), which is whathasMorereports; a query carrying a line terminator therefore matches nothing, finding none is a success with an emptymatches, and no cursor is returned because the next one is the last reported line plus one.list()adds only the directory work: the literal.is the one path naming the install root itself, the target must be a directory (source_not_directoryotherwise, which is what a root file is), each child is admitted by its own name and thenrealpath()d and re-admitted against the same root before it is classified withis_file()/is_dir()— so a hidden, vendor, escaping, dangling or special child is skipped rather than named, and noisLink()branch decides anything — a name that is not valid UTF-8 refuses the listing assource_unreadable, the child pastMAX_ENTRY_COUNT(200) refuses it asdirectory_oversizewith no partial names, and the accepted entries are sorted bytewise by name withstrcmp()rather than inheriting the filesystem’s order.searchTree()takes the same directory path aslist()and walks it through the same child admission, visiting each resolved directory once so a link back to an ancestor cannot loop. The walk spends two budgets before any file is opened: the reportable regular file pastMAX_TREE_FILE_COUNT(512), or the size that takes the files it would read pastMAX_TREE_BYTES(8 MiB), refuses the call aspackage_search_oversizewith no partial matches. A file pastMAX_SOURCE_BYTESis left out unread, and onelines()classifies assource_not_textis skipped, so an asset beside the source does not stop the search. Matches are ordered bytewise by root-relative path and then by line, compared exactly assearch()compares them, and capped atMAX_MATCH_COUNT; the match past the cap setshasMore, which asks for a narrower query or path because no cursor is returned. A refusal carries only its fixed code — no resolved path, no exception text, no content. Both searches report a whole matching line only throughMAX_MATCH_CONTENT_BYTES(2048). A longer line becomes a UTF-8-boundary-safe excerpt containing the first literal occurrence and carriestruncated: true, so a minified line cannot defeat the response bound.JsonDocument::render()— the one JSON encoding every Orbitron document is written in: key and list order as built, slashes and unicode unescaped, one trailing newline. Shared by the commands and the MCP server, which is why it sits beside the services rather than underConsole\.Document/Documents— the document layer both adapters sit on.Documentcarries one document’s body and whether the operation it reports failed; the two travel together because neither can be derived from the other, and the CLI turns the failure into exit3while MCP turns it intoisError: trueon a result still carrying the same document.Documentsis the one place a document’s shape is decided:context()/contextBody(),inspect(string $projectRoot, ?string $checkoutRoot = null),verify(string $projectRoot)andscaffold(string $projectRoot, ScaffoldMode $mode), each with its own*_SCHEMA_VERSION. Each$projectRootis the detected consumer root;inspect()reports it asprojectRootafterrealpath()and throws aRuntimeExceptionwhen it does not resolve. The optional$checkoutRootis reported ascheckoutRootexactly as given and never reaches the filesystem; null, the command’s case, reports the physical root again. Nothing is memoized, and every call re-reads the bounded project inputs it needs. The installed inventory is the snapshot the object was handed and is never refreshed here: a command builds one per invocation, the MCP adapter one per operation.Console\Invocation::format()/Console\Invocation::apply()— the whole invocation surface:--format, a bare--apply, no positionals.format()returns the command’s default when--formatis absent, and null for a positional value, a bare--format, or a format the command does not render.apply()returns false when the flag is absent, true when it is bare, and null when it carries a value. An option no command reads is invisible toCommandArgumentsand is left alone rather than met with a second parser.Console\ContextCommand/Console\InspectCommand—orbitron:context(--format=markdown|json, markdown default) andorbitron:inspect(--format=jsononly, and the default). Bothbootstrap: false, both writing one document to STDOUT and returning0, or writing a diagnostic to STDERR and returning2for a rejected invocation with STDOUT left empty.InspectCommandresolves the consumer root the same wayVerifyCommanddoes.Console\VerifyCommand—orbitron:verify(--format=jsononly, and the default),bootstrap: false, and the same2for a rejected invocation. Resolves the consumer root withKinetis\Runtime\ProjectRoot::detect(dirname(__DIR__)), past an optional constructor override that exists only as a test seam. Writes the documentDocuments::verify()built and returns3when it reports an error — distinct from the launcher’s1.Console\ScaffoldCommand—orbitron:scaffold(--format=jsononly, and the default;--applybare),bootstrap: false, and the same2for a rejected invocation, an--applycarrying a value included. Resolves the consumer root the same wayVerifyCommanddoes, and returns3when the completed operation refused or failed.Mcp\OrbitronMcpApplication— the MCP adapter, reaching the sameDocumentsthe commands do rather than invoking a command or parsing its output. Ten tools —orbitron_inspect,orbitron_verify,orbitron_scaffold_plan,orbitron_scaffold_apply,orbitron_read_package_source,orbitron_search_package_source,orbitron_search_package_source_tree,orbitron_list_package_source,kinetis_read_docandkinetis_search_doc, which areDocsApplication::readTool()andDocsApplication::searchTool()themselves, included rather than restated, with every call to either handed straight back to that application — and, as resources,kinetis://orbitron/contextas Markdown plus theDocsApplicationit was constructed with:resources()appends that server’s own descriptions rather than restating them, so a page added tokinetis/mcp-docsappears with no change here, andreadResource()answers the context URI locally and hands every other one — the refusal for an unknown URI included — to that application. Reading a documentation page — as a resource, a window or a search — is the one operation that leaves the machine; its diagnostics go to the stream the binary passed, never stdout. Four tools publish a closed, empty input schema, and a call carrying any argument at all is-32602before anything runs; the four installed-source tools are the only ones this package gives a schema, each validated member by member — presence, type, range and length — beforePackageSourceReader::read(),::search(),::searchTree()or::list()is reached, and an unknown key is the same-32602. They share those checks, so none admits a member, a type or a length another refuses, and each schema names only its own members: the window takeslineCount, the file search takesqueryandstartLine, the tree search takesqueryand an optionalpathdefaulting to., and the listing takes neither. No message can otherwise name a project root, a source body, a URL, a template or a command. The document-reading tools are annotated read-only, non-destructive, idempotent and closed-world, all four installed-source tools included — the two documentation tools carry kinetis/mcp-docs’ own annotations instead, open-world because they reach that origin;orbitron_scaffold_applyis annotated destructive and explicitly not idempotent, because a second apply refuses rather than overwriting. Selecting that tool is the whole mutation request — it has no boolean to set, and the MCP client’s configured approval policy controls whether it runs. A refusal or a failed write is anisError: trueresult still carrying the document, never a transport error that would leave the codes unreadable. Every document result is built withToolResult::structured()from theDocumentbody its text encodes, sostructuredContentand the text carry one document. The project root, thatDocsApplicationand an optional checkout root are the only things this object holds, and the root is detected once at construction; the checkout root reachesDocuments::inspect()alone, and every read, verification and write goes through the project root; every operation that reports or uses installed package facts builds oneInstalledPackages::fromProject()snapshot and theDocumentsorPackageSourceReaderthat reads it, then discards both with the response, so a completed Composer dependency change is visible to the next such call while one call’s reported versions and resolved source still come from one inventory. Documentation reads, resource, window and search alike, remain delegated toDocsApplicationand do not need the Composer inventory.serverInfo()reports Orbitron’s own installed version.bin/kinetis-orbitron-mcp— detects the consumer root through Composer’s generated bin proxy (Kinetis\Runtime\ProjectRoot::detect()), falling back inside this repository to the package’s own root, reads the checkout root a containerized launcher hands over inKINETIS_ORBITRON_CHECKOUT_ROOT(OrbitronMcpApplication::CHECKOUT_ROOT_ENV) — an empty or relative value writes one line to stderr and exits1before the loop, with nothing on stdout — then runs the sharedStdioLoopover anMcpServerbuilt fromOrbitronMcpApplicationand aKinetis\McpDocs\DocsApplicationconstructed withSTDERRas its diagnostic stream — the one place a failed documentation fetch is reported, and never stdout. It never boots the Kinetis application, so no route, listener or package bootstrap runs; nothing but JSON-RPC frames reaches stdout, and a failure that ends the loop is reported on stderr with exit code1.Depends on
kinetis/frameworkfor the command attribute,CommandArgumentsandKinetis\Runtime\ProjectRoot, onkinetis/mcp-protocolfor the MCP server, onkinetis/mcp-docsfor the documentation catalogue and fetch it publishes — never onkinetis/mcp, whose installation would register a bootstrap and a discovery plugin in the consumer application — and oncomposer-runtime-apiforComposer\InstalledVersions. Owncomposer.json/phpunit.xml/phpstan.neon/psalm.xml.
packages/migrations (kinetis/migrations)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\Migrations\Migration— the interface a migration file’s anonymous class implements:up(MysqlLink|PostgresLink $db): void/down(...): void, raw SQL only, issued via$db->execute().Kinetis\Migrations\MigrationFile— discovers the<timestamp>_<description>.phpfiles directly in one directory, sorted by filename;load()is a barerequireof the file, andchecksum()is the SHA-256 of it, which is what the ledger records and every later command compares against.Kinetis\Migrations\MigrationRepositoryInterface/SqlMigrationRepository—ensureTableExists()/applied()/markApplied()/markRolledBack()over akinetis_migrationstable of exactly three columns:migration(primary key),checksum(the SHA-256 of the file that ran), andapplication_order(NOT NULL UNIQUE).applied()returns name => checksum in ascendingapplication_order.markApplied()assigns that order with oneINSERT ... SELECT ?, ?, COALESCE(MAX(application_order), 0) + 1 FROM kinetis_migrations, which compiles identically on MySQL and Postgres — the runner’s advisory lock is what keeps two writers off the same number, and the unique constraint is what fails the insert if one ever reaches it without the lock. Typed against the genericKinetis\Persistence\Contract\SqlLinksince its own bookkeeping SQL is dialect-agnostic.Kinetis\Migrations\MigrationRunner—pending()/migrate()/rollback()/status(). Never wraps a migration in a transaction. All four verify the ledger against the migrations directory before acting on it — every applied migration still has a file, and that file still hashes to the checksum recorded when it ran — throwingException\MigrationIntegrityExceptionon the first missing or changed source, before anyup(),down()or ledger write;migrate()/rollback()verify inside the lock,pending()/status()directly.migrate()hashes each pending file immediately before it loads and runs it, and records that checksum only onceup()has returned.rollback()undoes the migration with the highestapplication_order, so a migration merged from another branch and applied after a later-timestamped one comes back off first, and one rolled back and applied again is the newest from then on.migrate()/rollback()hold a cross-process advisory lock (MySQLGET_LOCK(), Postgrespg_advisory_lock()) for their whole duration, throwingException\MigrationLockTimeoutExceptionif it can’t be acquired within$lockTimeoutSeconds(10 by default). Its link must hold one session for the whole run — the lock is session-scoped — which is whatSqlConnectionFactory::singleSession()builds.Kinetis\Migrations\MigrationScaffolder— writes a new timestamped migration file with theup()/down()stubs filled in, via an exclusive (x) file create rather than an unconditional overwrite; a same-second name collision retries with a random suffix. ThrowsException\MigrationScaffoldExceptionon a real I/O failure creating the directory or writing the file.Kinetis\Migrations\Console\{MigrateCommand, RollbackCommand, StatusCommand, MakeCommand}— themigrate/migrate:rollback/migrate:status/migrate:make <description>commands onvendor/bin/kinetis, registered through this package’sextra.kinetisscan root and all#[Command(bootstrap: false)].Console\MigrationContext(@internal) is their shared partition/connection holder. The project-rootmigrations/*.phpfiles are thedefaultconnection’s partition, and each direct child directorymigrations/<name>/is connection<name>’s; deeper directories are not scanned. Every child directory must match^[a-z][a-z0-9]*$, withappreserved (itsDB_APP_NAMEis a default-connection key) anddefaultrefused as a directory, and any other name throwsInvalidArgumentExceptionnaming it before any database configuration is read.--connection=<name>(validated the same way;defaultmaps to the root, and a bare--connectionis refused), else a non-emptyMIGRATE_CONNECTION_NAME, selects one partition; otherwisemigrate/migrate:statusselect every partition,defaultfirst, then the named ones inSORT_STRINGorder. Each partition runs its ownMigrationRunnerover its ownKinetis\DatabaseBridge\ConnectionFactory::singleSession()client — always PDO whateverDB_DRIVERsays and never on a replacement session, reading that connection’sDB_CONNECTION/DB_{NAME}_CONNECTION(mysql|pgsql, required) plus the rest of its keys — closed infinallybefore the next opens. A multi-partitionmigratefirst runsstatus()on every partition, so an unconfigured connection or a failing ledger check stops it before anyup()— printing only the failing partition’sConnection: <name>line before rethrowing the unchanged exception — then migrates the partitions sequentially, failing fast with earlier partitions left migrated; with more than one partition,migrate/migrate:statusprintConnection: <name>before each partition’s lines.migrate:rollbackcovers exactly one partition, and with several selected writes its usage to STDERR and exits 1.migrate:makewrites to the root, or tomigrations/<name>/for--connection=<name>;MIGRATE_CONNECTION_NAMEdoes not move it. Two connections on the same physical database share one ledger and are unsupported.MigrateCommand/RollbackCommandconstructor-injectEventDispatcherand dispatch the two events below once per migration actually run/rolled back.Kinetis\Migrations\Events\MigrationApplied/Events\MigrationRolledBack—nameandconnectioneach — see Events.Depends on
kinetis/framework,kinetis/persistenceandkinetis/database-bridge(viapathrepositories to this monorepo’s root); suggestsext-pdo_mysqlandext-pdo_pgsql, since everymigrate*command needs the PDO driver matchingDB_CONNECTIONeven when request work selects a native driver;SqlMigrationRepositorytypes against the genericKinetis\Persistence\Contract\SqlLink. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/query-builder (kinetis/query-builder)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\QueryBuilder\Query— a mutable, parameterized SQL builder over the SQL shared by MySQL 8.4, MariaDB 11.4 and PostgreSQL 16; not an ORM.new Query(MysqlLink|PostgresLink|SqlTransaction $link): the link’sMysqlLinkorPostgresLinkmarker is the only dialect authority, and aSqlTransactionlink runs the statement inside that transaction. ASqlTransactioncarrying neither marker throwsQueryBuilderExceptionat construction. Sources:table(string $table, ?string $as = null),fromSub(Query, string $as),with(string $name, Query, array $columns = []),withRecursive(...). Projection:select(string ...),selectAs(string $column, string $as),selectRaw(string, array $params = []),selectSub(Query, string $as),selectExists(Query, string $as)(1or0under$as),distinct(). Predicates, each delegating toConditions:where()/orWhere(),whereColumn()/orWhereColumn(),whereIn()/whereNotIn()(a list or aQuery),whereBetween()/orWhereBetween()/whereNotBetween()/orWhereNotBetween(),whereExists()/orWhereExists()/whereNotExists()/orWhereNotExists(),whereRaw(string, array $params = [], string $boolean = 'AND'),whereGroup(Closure)/orWhereGroup(Closure). Joins:join(string $table, string $first, string $operator, string $second, string $type = 'INNER', ?string $as = null),leftJoin(),joinOn(string $table, Closure $on, string $type = 'INNER', ?string $as = null),joinSub(Query, string $as, Closure $on, string $type = 'INNER'),crossJoin(string $table, ?string $as = null); typesINNER/LEFT/RIGHT. Grouping:groupBy(),groupByRaw(),having()/orHaving(),havingRaw(). Ordering:orderBy(),orderByRaw(string, array $params = []),limit(),offset(). Set operations:union()/intersect()/except()(Query, bool $all = false), applied in call order, with the outer order, limit and offset applying to the combined result. Locks:lockForUpdate(LockWait $wait = LockWait::Wait),lockForShare(). Reads:get(?string $dtoClass = null)(list<array>, orlist<T>through oneRowMapperper result set),first()(array|T|null),value(string $column): mixed,pluck(string $column): list<mixed>,exists(): bool,count(): int,sum()/min()/max()/avg(): int|float|string|null,paginate(),cursorPaginate(). Writes:insert(array $values): void(a row or a batch of identically keyed rows, at most 65,535 bound values),insertGetId(array $values, string $primaryKey = 'id'): int|string|null,insertUsing(array $columns, Query $select): int,insertOrIgnore(array $values): int,upsert(array $values, array $uniqueBy, array $update): int,update(array $values): int,increment()/decrement()(string $column, int|float $amount = 1, array $extra = []): int,delete(): int. Compile without running:toSelectSql(),toUpdateSql(array),toDeleteSql(). Bindings follow the emitted SQL: CTEs, select expressions,FROMsubquery, joins,WHERE, cursor predicate,GROUP BY,HAVING, set operands,ORDER BY. AQuerypassed as a subquery, CTE, operand or insert source is compiled into an immutable snapshot when passed, must share the dialect, and cannot carrywith()(an insert source excepted) or a lock; its raw state carries to the parent.__clone()copies the predicate state.run()writesint/boolvalues as literals on a link withoutContract\PrefersPreparedStatementsand binds otherwise; a raw fragment containing?anywhere in the statement binds everything. Refusals, all before execution: an update, increment, decrement or delete with no effective predicate or with any clause besides the table andWHERE; an insert with any clause besidestable(); a lock outside an activeSqlTransaction, on a terminal other thanget()/first()/value()/pluck(), or combined withdistinct(), grouping,having, set operations, CTEs, derived tables, orLEFT/RIGHT/CROSSjoins; on the MySQL family, anINsubquery carryinglimit()/offset().count()and the aggregates read a distinct, grouped,havingor set-operation query as a derived table, and every other query directly without its projection. Dialect spellings and behavior differences: Appendix: Query Builder’s “Dialect spellings”.Kinetis\QueryBuilder\Conditions— the predicate-only objectwhereGroup()/orWhereGroup()/joinOn()/joinSub()callbacks receive andQuery’sWHERE/HAVINGdelegate to; the predicate methods above and nothing else. Each call renders its fragment and bindings together; an empty group adds nothing. Operators (=,!=,<>,<,<=,>,>=,LIKE,NOT LIKE) and booleans (AND,OR) are allow-listed. A null value compiles toIS NULL/IS NOT NULLfor=/!=/<>and throws for other operators; a nullINmember orBETWEENbound throws; an emptyINlist is1 = 0and an emptyNOT INlist1 = 1;whereRaw()refuses an empty fragment.Kinetis\QueryBuilder\LockWait—Wait,NoWait,SkipLocked:FOR UPDATE,FOR UPDATE NOWAIT,FOR UPDATE SKIP LOCKEDon every target.Kinetis\QueryBuilder\RowValues::fromObject(object $object, array $columns = [], array $except = []): array— a stateless map of the object’s initialized public properties:nullkept, a backed enum as its value, anything butnull/bool/int/finitefloat/string— a unit enum such asKinetis\Validation\Absentincluded — refused withInvalidArgumentExceptionnaming the property but not the value.$columnsrenames and$exceptomits; unknown names, a name in both, and two properties mapping to one column throw. It recognizes no presence marker: an application omitsAbsentfields through$except(Query Builder’s “Partial updates”).Kinetis\QueryBuilder\RowMapper—RowMapper::for(class-string<T> $class): RowMapper<T>reflects and validates one instantiable constructor DTO into an immutable plan held by that instance, with no static or process cache;map(array $row): Tpasses each constructor parameter the column of exactly its name, ignores other columns, leaves a missing optional parameter to its default, and checks every value before invoking the constructor, whose own exceptions propagate unchanged. Admitted parameter types: untyped ormixed(the raw value),string,int(an int or its canonical decimal string),float(a finite int, float or numeric string),bool(a bool,0/1or"0"/"1"), and a backed enum (a case, or a backing value under its backing type’s rule), each also nullable.Query::get()/first()/paginate()andcursorPaginate()build one per result set. The full domain: Appendix: Query Builder’s “Row mapping”.Kinetis\QueryBuilder\Exception\RowMappingException— final, extendsInvalidArgumentException.unsupportedDefinition()for a classfor()cannot fill (not instantiable; a variadic or by-reference parameter; an intersection, a union other thanT|null, or any other builtin or class type),missingColumn()for a parameter with no default,invalidValue()for a value its type does not admit,nullfor a non-nullable one included, andunknownEnumCase()for a backing value naming no case. A message names the class, the parameter or column and the expected shape, and the value only by itsget_debug_type()kind.Kinetis\QueryBuilder\Dialect(+Dialect\MySqlDialect/Dialect\PostgresDialect) — the spellings that differ: identifier quoting,limitOffset()(MySQL family:LIMIT 18446744073709551615 OFFSET nfor an offset alone),sharedLock(),admitsLimitedInSubquery(),insertOrIgnoreClause(),upsertClause(),insertGetIdClause()/extractInsertedId(), andliteralFor().Kinetis\QueryBuilder\CompiledQuery— the{sql, params}output of theto*Sql()methods.Kinetis\QueryBuilder\Exception\QueryBuilderException— aRuntimeExceptionfor a query built into a shape it cannot compile truthfully.Kinetis\QueryBuilder\Exception\InvalidPaginationException— final, extendsInvalidArgumentException: a caller’s pagination argument outside its domain, or a clausecursorPaginate()would contradict, refused before any SQL runs. See the two entries below.Query::paginate(int $perPage, int $page = 1, ?string $dtoClass = null): Paginator—count()fortotal/lastPageplus a limitedget(). Requires anorderBy()/orderByRaw()and throwsException\QueryBuilderExceptionwithout one. A page past the last returns emptydatawith the realtotal.Query::cursorPaginate(int $perPage, ?string $cursor, string $cursorColumn = 'id', ?string $dtoClass = null, ?string $cursorAlias = null): CursorPaginator— orders by$cursorColumn, filters(existing predicate) AND $cursorColumn > ?, and readsnextCursorfrom the same result as the delivered rows. An omitted unqualified cursor column is selected and stripped from the rows; a qualified one requires$cursorAlias. A pre-existing order, a limit, an offset above zero, a missing alias for a qualified column, or an alias a listed column already uses throwsException\InvalidPaginationException; a lock or a set operation throwsException\QueryBuilderException.Kinetis\QueryBuilder\Paginator(data,currentPage,perPage,total,lastPage) /Kinetis\QueryBuilder\CursorPaginator(data,nextCursor,hasMore) —final readonlyenvelopes of public fields, sojson_encode()produces their flat shape. They parse no request and build no response; in a Kinetis applicationkinetis/framework’s#[PaginatedItem]describes either in OpenAPI like any other wrapper (Appendix: System Layout).One
Queryinstance is one query — nothing resets between fluent calls; construct a fresh instance per query.Depends at runtime on
kinetis/persistencealone, and no production source imports akinetis/frameworkclass;kinetis/frameworkis a development dependency that supplies the PHPStan rule (both viapathrepositories to this monorepo’s root).kinetis/database-bridgesuggests it for Kinetis applications. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/orm (kinetis/orm)¶
Separate Composer package, not part of kinetis/framework core, and not dependent on it. Its README is the mapping, value and lifecycle contract; ORM covers the Kinetis wiring.
Kinetis\Orm\Attributes\Entity(?string $table = null, string $connection = 'default'),Attributes\Column(?string $name = null),Attributes\Id(bool $generated = false)— the mapping attributes.#[Entity]is required; the table defaults to the short class name in snake case, the connection — lowercase ASCII letters and digits, starting with a letter, and not the reservedapp, whose scopedDB_NAMEwould be the default connection’sDB_APP_NAME— todefault, a column to the property name in snake case, and the identifier to the property namedidwhen no property carries#[Id]. An identifier is assigned by the application unlessgenerated: true, which requires a?intproperty.Attributes\Versionmarks the optional optimistic-locking version property: at most one, typedintand not the identifier.Attributes\BelongsTo(?string $column = null)marks a relationship property typed with an entity class of the same registry, over a foreign-key column that defaults to the property name in snake case followed by_id.Attributes\HasOne(string $mappedBy, bool $owned = false)andAttributes\HasMany(class-string $target, string $mappedBy, bool $owned = false)mark inverse relationship properties — typed with the target entity class, or exactlyarray— that map no column and read the column of the target’s#[BelongsTo]propertymappedBy, whose target is the declaring class.owned: truemakes the relationship an aggregate edge:flush()discovers new targets across it,remove()takes the targets with the owner, and a target dropped from a relationship the manager loaded is deleted or moved. An entity class is the target of at most one owned inverse relationship in a registry, and one#[BelongsTo]property is named by at most one of them.Attributes\ManyToMany(class-string $target, ?string $table = null, ?string $joinColumn = null, ?string $inverseJoinColumn = null, ?string $mappedBy = null)marks an array property over a join table: an owning side namestable,joinColumn(this entity’s column) andinverseJoinColumn(the target’s) and is the only side that writes join rows, and an inverse side namesmappedBy, the owning#[ManyToMany]property of the target, alone and reads that table with its columns swapped. Neither side maps a column of its own table, neither cascades a target’s persistence or removal, and the join table’s unique pair and two foreign keys stay database authority.Kinetis\Orm\Date— final readonly calendar date for a SQLDATEcolumn: publicintyear,monthandday,__construct(int $year, int $month, int $day)admitting a day that exists in the Gregorian calendar in years 0001 to 9999,fromString(string $value): selfadmitting exactlyYYYY-MM-DD, and__toString()returning that form; any other day or string throwsInvalidArgumentExceptionwithout quoting it. No time, time zone or instant. A property declared exactlyDateor?Datehas the metadata typedate, loads from and writes, snapshots, binds predicates and cursors as itsYYYY-MM-DDstring, and is never the identifier or the version: the package README’s “Dates”.Kinetis\Orm\Metadata\MetadataRegistry—fromClasses(iterable $classes): self,fromArray(array $data): self,toArray(): array{entities: list<array>}. Immutable class, table, column and type names, flags, relationship targets and, apart from each entity’s column-mappedproperties, itsinversesand itsjoins, each join carrying the table and the two columns that end reads, and itsconnection, ordered by class;connections(): list<string>lists every connection an entity names once, in byte order, andconnectionFor(class-string $class): stringreturns one entity’s. A relationship of any kind between entities on two connections is refused.fromArray()accepts onlytoArray()’s output for the named classes as currently declared and reflects nothing else. Every refusal isException\MappingException. No static cache.Kinetis\Orm\OrmFactory—create(MysqlLink|PostgresLink $link, MetadataRegistry $metadata, string $connection = 'default'): selfbehind a private constructor, mapping only the entities on$connection(none for a connection no entity names), refusing aSqlTransactionwithInvalidArgumentException;open(): EntityManager;transaction(callable(EntityManager): TResult $callback): TResult, which begins a transaction on the client, passes the callback anEntityManagerbound to it, commits once the callback returns, and closes the manager on every way out, refusing a nested call from the same Fiber on the same factory withException\InvalidEntityStateExceptionbefore it begins. Request-neutral: one runtime plan per entity, built once, and a per-Fiber session mark cleared when eachtransaction()call ends.Kinetis\Orm\OrmFactoryRegistry— final, request-neutral.create(array<string, MysqlLink|PostgresLink> $links, MetadataRegistry $metadata): selfbehind a private constructor builds oneOrmFactoryper link, keyed by connection, and refuses a connection an entity names without a link withInvalidArgumentException;factory(string $connection): OrmFactory(InvalidArgumentExceptionwithout a link),factoryFor(class-string $class): OrmFactory(Exception\MappingExceptionoutside the metadata).Kinetis\Orm\EntityManagerRegistry— final, one unit of work’s managers.create(OrmFactoryRegistry $factories): selfbehind a private constructor, owned by the calling Fiber;manager(string $connection): EntityManagerandmanagerFor(class-string $class): EntityManageropen that connection’s manager on first use and return it after, refusing another Fiber (Exception\CrossFiberAccessException) and use afterclose()(Exception\ClosedEntityManagerException);close(): voidcloses every opened manager without flushing, from any Fiber, idempotently. No flush or transaction spans two connections.Kinetis\Orm\EntityManager— non-public constructor.repository(class-string<T> $class): EntityRepository<T>,contains(object $entity): bool,persist(object $entity): void,remove(object $entity): void,flush(): void,builder(): Query(a fresh query builder on the manager’s link, its results unmanaged),clear(): void,close(): void(idempotent, never flushes, closes a running flush’s or a session’s transaction, the link left open),isClosed(): bool. Holds the identity map, keyed by class and identifier, a snapshot of each managed entity, the membership of each owned inverse relationship and owning join collection it loaded, a weak set of the entities whose deletion it committed, and the inserts and deletions scheduled forflush(), which writes them and every changed column in one transaction — one it begins on the client, or a session’s — and changes nothing before its COMMIT returns. A versioned entity’s UPDATE and DELETE also match the version the manager holds, and its UPDATE sets the next one; either affecting no row throwsException\OptimisticLockException. A#[BelongsTo]relationship it writes holds null or an entity it holds — managed, or awaiting insert in the same flush, whose generated key that flush carries from one statement to the next without writing it into a property before COMMIT; anything else is refused before SQL withException\InvalidEntityStateException.flush()walks every initialized owned inverse relationship once before its transaction, schedules the new entities it reaches, deletes or moves the targets a loaded relationship no longer holds, orders every statement behind the rows its foreign keys name, and breaks a loop of references through a nullable foreign key or refuses it; it reads no inverse relationship otherwise and never writes an owner column from one. It writes an owning#[ManyToMany]collection as join rows alone: every link of a new owner’s collection, the pairs a loaded one gained and lost, and one DELETE by the join column before a removed owner’s own; a managed owner’s collection the manager never loaded is refused before SQL, an inverse collection is inert, and a link DELETE admits any affected-row count. A managed owner carrying#[Version]whose membership changed advances that version once, through the UPDATE its changed columns send or through one writing the version column alone, ordered after its link DELETEs and before its link INSERTs. Owned by the Fiber that opened it: every other method, and every repository, query and terminal it creates, refuses a closed manager (Exception\ClosedEntityManagerException), another Fiber (Exception\CrossFiberAccessException), a call whileflush()runs, and in a session a call after a flush that wrote or an ORM failure (Exception\InvalidEntityStateException) before SQL;close()accepts any Fiber. Its@internalmembers serve the factory, repository and query. Relationship, write, locking, session and failure semantics: the package README’s “Relationships”, “Many-to-many relationships”, “Writing”, “Flushing”, “Optimistic locking” and “Transaction sessions”.Kinetis\Orm\EntityRepository<T>— final.find(int|string $id): ?T,findOrFail(int|string $id): T(Exception\EntityNotFoundException),findBy(array<string, mixed> $criteria): list<T>,query(): EntityQuery<T>.Kinetis\Orm\EntityQuery<T>— final, over a freshKinetis\QueryBuilder\Queryselecting every mapped column.where(string $property, string $operator, mixed $value),whereIn(string $property, array $values),orderBy(string $property, string $direction = 'ASC'),limit(int),offset(int),lockForUpdate(LockWait $wait = LockWait::Wait),lockForShare()(a session’s manager only;Exception\InvalidEntityStateExceptionbefore SQL otherwise),with(string ...$relations)(relationship paths such asauthor.organizationorcomments.author, checked before SQL and loaded after the root statement byget(),first(),paginate()andcursorPaginate()through unlocked selects on the manager’s link of at most 1,000 foreign keys each, or for an inverse relationship 1,000 identifiers against the target’s foreign-key column, ordered by the target’s identifier, or for a#[ManyToMany]1,000 identifiers against that end’s join column ordered by both, then the targets those rows name, with every collection buffered in full;exists()andcount()load nothing); an inverse or#[ManyToMany]relationship maps no column of its own table, so a predicate, order or cursor naming one throwsException\MappingExceptionbefore SQL;get(): list<T>,first(): ?T,exists(): bool,count(): int,paginate(int $perPage, int $page = 1): Paginator,cursorPaginate(int $perPage, ?string $cursor, string $property = 'id'): CursorPaginator, each envelope carrying managed entities;builder(): Query, a copy whose terminals return unmanaged arrays or DTOs.Exception\MappingException(RuntimeException) — metadata, property names, relationship paths, predicate values, loaded rows, relationship targets no row matches, a non-nullable#[HasOne]without a row or any#[HasOne]with more than one, a join row naming a missing target or holding no identifier, and property values to write. A mapped-value failure names its class, property, table, column and expected shape, but reports the value only by itsget_debug_type()kind because a column may hold a secret.Exception\InvalidEntityStateException(RuntimeException) — lifecycle refusals, relationship targets the manager does not manage, calls whileflush()runs, a locking read outside a session, a nested session, calls after a session’s writing flush or ORM failure, an owning join collection the manager never loaded or holding one object twice, and row-integrity failures.Exception\OptimisticLockException(RuntimeException) — a stale versioned write, rethrown unwrapped after the rollback.Exception\RollbackFailedException(RuntimeException;getPrevious()is the first failure,$rollbackFailurethe rollback’s),Exception\UnknownFlushOutcomeException(RuntimeException;getPrevious()is the COMMIT failure of aflush()on a manager fromopen()) andException\CommitNotAcknowledgedException(RuntimeException;getPrevious()is the failure of a session’s COMMIT, which may have rolled back or have an unknown outcome), each thrown with the manager closed.Exception\EntityNotFoundException(RuntimeException),Exception\ClosedEntityManagerExceptionandException\CrossFiberAccessException(LogicException, each with amanager()and aregistry()constructor).Depends at runtime on
kinetis/query-builderandkinetis/persistence, and no production source imports akinetis/frameworkclass;kinetis/frameworkis a development dependency that supplies the PHPStan rule (all viapathrepositories to this monorepo’s root).kinetis/database-bridgesuggests it for Kinetis applications. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/queue (kinetis/queue)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\Queue\Job— a marker interface (no declared methods) a job class implements.handle()is discovered and invoked by reflection, not a fixed interface method, since its parameter list varies per job.Kinetis\Queue\QueueInterface—push(Job $job, int $delaySeconds = 0, string $queue = 'default', ?int $maxAttempts = null): void,pop(int $timeoutSeconds = 0, array $queues = ['default']): ?QueuedJob,ack(QueuedJob $job): void,release(QueuedJob $job, int $delaySeconds = 0): void,fail(QueuedJob $job): void,size(string $queue = 'default'): int(jobs waiting to be popped — delayed included, reserved excluded). Only what every backend performs identically lives here; clearing is a separate capability interface (below).$queuesis checked in the given order, on every sweep — priority by list position, not a numeric score; see Appendix: Queue Contracts’s “Thepop()priority/timeout contract” for the full cross-backend behavior.$maxAttemptsnull (the default) defers to the processingQueueWorker’s own$defaultMaxAttempts, which is never itself unlimited; onceQueuedJob::$attemptsreaches the effective cap,fail()removes the job permanently instead ofrelease()retrying it.release()’s$delaySecondsis a floor, exactly aspush()’s is, and every backend holds the job with its own durable primitive rather than the worker waiting; it is validated throughQueueContract::assertValidReleaseDelay()(negative rejected) before telemetry, serialization or I/O, with no universal ceiling —SqsQueuelayersChangeMessageVisibility’s 43200-second request-field range andRabbitMqQueueits delay ladder’s.Kinetis\Queue\ClearableQueueInterface extends Kinetis\Queue\QueueInterface— addsclear(string $queue = 'default'): int, declared by a backend that can discard exactly the jobs waiting on a queue and report how many it removed. “Waiting” means unreserved, delayed jobs included; every live reservation is left alone — Queue’s “Clearing is a separate capability” gives the reasoning. The return value is what that call removed, never asize()taken alongside it: a queue accepts pushes throughout, so the two are separate observations of a moving number. It extends rather than sits besideQueueInterfacebecause clearing is a queue operation — one instance still pushes, pops and reports size.SyncQueue,RedisQueue,SqlQueue, andRabbitMqQueuedeclare it;SqsQueuedoes not, since Amazon SQS’sPurgeQueuedeletes in-flight messages too, keeps deleting messages sent while it runs, reports no count, and is rate-limited to once per 60 seconds per queue — see Queue (SQS). Same shape asKinetis\SimpleCache\AtomicCounterInterfaceagainst PSR-16: a capability named in a consumer’s own type, or checked with aninstanceofwhere only the base contract is held.Kinetis\Queue\DisposableQueueInterface extends Kinetis\Queue\QueueInterface— addsdispose(): void, declared by a backend whose factory opened a connection nobody else owns:RedisQueue,SqlQueue,RabbitMqQueue.SqsQueuedoes not, its transport being an HTTP client with no queue-owned connection to close. Ownership travels with construction, not with the type — a backend’sfromConfig()hands the queue the operation that closes what it opened, while a constructor called directly receives a caller-owned client or link and closes none of it, makingdispose()a no-op there unless the caller passes the closing operation itself as the constructor’s last argument. Idempotent, and safe before the queue’s first I/O: the disposer is dropped as it runs, so a second call releases nothing a second time and a worker that never popped anything still disposes cleanly.PackageBootstrapregisters it on theAppScopefor the backend it builds and for nothing else — Appendix: Queue Contracts’s “Connection ownership” gives the reasoning.Kinetis\Queue\RenewableQueueInterface extends Kinetis\Queue\QueueInterface— addsvisibilityTimeoutSeconds(): intandrenew(QueuedJob $job): void, declared by a backend holding a delivery for a finite window it can push forward:RedisQueue,SqlQueue,SqsQueue.RabbitMqQueuedoes not — its channel holds the unacknowledged delivery for as long as the connection lives — and neither doesSyncQueue, which has no reservation.renew()extends the deliveryQueuedJob::$handlenames, fenced on that receipt, and claims nothing about whether the delivery was still current: MySQL and Redis both report zero changed rows or members for a write that stores the value already there, which a renewal at one-second resolution routinely does, so there is noException\StaleJobHandleExceptionhere and noJobSettlementcase. It settles nothing and consumes no attempt; transport and backend errors propagate as from any other operation. Repeated calls are supported, and one failure says nothing about whether a later attempt will fail — butrenew()is not idempotent: every successful call moves the reservation window forward from that call. It may return synchronously when it needs no I/O; any I/O must suspend its Fiber rather than block the event-loop thread and must be bounded by the backend or client’s own operation timeout, since the worker joins a call still in flight before it settles and cannot abandon one.QueueWorkerresolves the capability once in its constructor and drives renewal itself — application jobs never see their receipt and get no API of their own. See Appendix: Queue Contracts’s “Reservation renewal”.Kinetis\Queue\QueueContract— the shared validation and decode helpers every backend’spush()/pop()/size()— andclear(), where a backend has it — runs before any I/O.assertValidQueueName(string)checks a logical queue name against/^[A-Za-z0-9_-]{1,80}$/D— the conservative grammar this project settled on as the intersection every backend can portably support, derived from Amazon SQS’s own standard-queue naming rule (the narrowest of the four: alphanumeric plus hyphen/underscore only, up to 80 characters, no periods, since a period is reserved for the.fifosuffix this project’sSqsQueuedoesn’t support); Redis, SQL, and RabbitMQ all permit far more, so this stays portable-safe everywhere.assertValidQueueList(list<string>)checks every name plus rejects a repeated one (an empty list is accepted, the one “nothing to check” case).assertValidPopArguments(int, list<string>)combines the non-negative timeout check with the queue-list check.assertValidQueueNamePrefix(string)checks a backend’s own$queueNamePrefixconstructor argument against the same grammar, with an empty string accepted as “no prefix.”assertValidPushArguments(int, string, ?int)is thepush()-side counterpart. Every one of them throwsException\InvalidQueueArgumentException.assertValidConnectionName(string $name, string $source)is the connection-name grammar (^[a-z][a-z0-9]*$)PackageBootstrapapplies toQUEUE_CONNECTION_NAMEandqueue:workto--connection; it throwsInvalidArgumentExceptionnaming$source. The decode side —storedInt(),storedNullableInt(),storedJsonArray(),storedClass(),storedArgs(),storedMetadata(),assertFieldPresent()— parses a raw stored field into the shapeQueuedJobrequires, throwingException\MalformedQueuedJobDataException;settleIfMalformed()is the one place every durable backend routes its decode through, so a corrupted message is settled permanently instead of replayed forever.Kinetis\Queue\QueuedJob—{class, args, handle, queue, attempts, maxAttempts, metadata}.$handleis a delivery receipt: it identifies one exact delivery of a job, not the logical job, so the same job body reaching a worker again — after arelease(), or after a reservation expired and the backend handed the work on — carries a different$handle. A backend that can tell a live reservation from a finished delivery settles only the former and answers the latter withException\StaleJobHandleException; one that cannot says which of its settlements are unfenced in its own docblock. Queue’s “When a settlement is lost” carries the per-backend table. The constructor validates$queueviaQueueContract::assertValidQueueName()— the one point everyack()/release()/fail()call’s$job->queueultimately passes through, closing the gap a hand-constructed or forged instance would otherwise leave open.$attemptsis the attempt number the currentpop()represents (1-indexed), not a raw failure count.$metadatais opaque string metadata stored at push time — the instrumentation propagation channel, carried verbatim by every backend.Kinetis\Queue\JobSettlement— a string-backed enum of the three durable transitions a popped job can end in:Ack,Release,Fail. Each case’s value is the stringKinetis\Instrumentation\TelemetryInterface::jobFinished()takes as its$outcome, so an operation named once here reaches telemetry,Exception\StaleJobHandleException, andEvents\JobSettlementLostwithout a second mapping to drift from it.Kinetis\Queue\Exception\StaleJobHandleException— a settlement found no live reservation to act on: the deliveryQueuedJob::$handlenames is over, settled through another call or reclaimed after its reservation expired. Raised forack(),release()andfail()alike, built throughforSettlement(JobSettlement $operation, string $queue)and carrying that operation on a public$operationproperty. Nothing the call wanted is left to do and nothing it wanted was written, which is whyQueueWorkertreats it as an outcome to report rather than a failure to stop on.Kinetis\Queue\Exception\QueueNotClearableException—ClearableQueueInterfacewas resolved from the container while the boundQueueInterfaceis a backend that does not declare it. Names the backend and what to do instead;Console\ClearCommandprints the same wording, via that class’s owndescribe().Kinetis\Queue\JobSerializer— converts an object (aJob, or an event —deserialize()/the general path is notJob-specific) to plain{class, args}data by reading each constructor parameter’s value off a same-named property via reflection, and back. It enforces the portable wire contract on every argument, recursively:null/bool/int/a finitefloat/a valid-UTF-8string, and a dense zero-based list or string-keyed map of those, nested up to 32 levels (any other array shape — sparse, mixed-key — is rejected, having no lossless JSON round trip; so is anything past the depth bound, which is what turns a self-referential array into apush()-time rejection instead of an exhausted worker). ABackedEnumcase and aDateTimeImmutable(the exact class, not a subclass) are accepted as a top-level argument, written as the backing value and aY-m-d\TH:i:s.uPtimestamp respectively, and restored from the constructor parameter’s declared type — which must be a singleReflectionNamedTypenaming that exact enum class or exactlyDateTimeImmutable, so a union, an intersection,mixed, an untyped parameter, an interface and a supertype are all rejected duringserialize(), before anything is stored. Nested inside an array they are rejected too, since nothing there identifies what a bare string was meant to become — see Queue’s “What a constructor argument can hold”. Everything else (a resource, aClosure, any other object,NAN/INF, invalid UTF-8) throwsException\UnserializableJobException::forUnsupportedValue(), naming the argument path but never the value; a list index is kept (items[3]) and a map entry is located by its ordinal position ({0}) rather than its key, since a key is application data. A#[Sensitive]-marked argument goes further:serialize()catches any rejection within its subtree and re-throwsforSensitiveValue(), naming only the argument, never a nested path — a map key can itself be a secret. Two reconstruction entry points:deserialize(class-string $class, array $args): object(the general path — noJobrequirement, used for event reconstruction) anddeserializeJob(class-string $class, array $args): Job(whatQueueWorkercalls — identical, plus a check that$classimplementsJob). Both validate$args’ names against$class’s current constructor signature (every required parameter present, no unrecognized extra key), wrapping any failure — a missing class, a missing/unrecognized argument, a value that cannot be restored to its declared type, or the constructor itself throwing — inException\JobReconstructionExceptionrather than letting a rawError/TypeErrorescape with no payload context. A constructor’s ownThrowableis chained; the cause behind an unrestorable value is not, because PHP’s “not a valid backing value” and date-parse messages quote the stored value, which may be a#[Sensitive]one. This defends against schema drift (a rolling deployment where the pushing and popping process disagree on a class’s shape), not a hostile payload — the queue is trusted infrastructure on the database’s own tier, not an input boundary.redact(string $class, array $args): arrayreturns those arguments with every value whose constructor parameter carriesAttributes\Sensitivereplaced byJobSerializer::REDACTED([redacted]), for logging; a class that no longer loads redacts every value rather than none.Kinetis\Queue\Attributes\Sensitive—TARGET_PARAMETER, no arguments. Marks a job constructor parameter whose value must never reach a log; affects logging only, never what is written to the backend. Redacts an array or object value whole, with no per-element redaction within one.Kinetis\Queue\JobInvoker—invoke(Job $job, ContainerInterface $container): void, reflecting and callinghandle()with each parameter resolved through the given container. Shared byQueueWorkerandSyncQueue.Kinetis\Queue\SyncQueue— runspush()’s job immediately, inline, viaJobInvoker;pop()always returnsnull,ack()/release()/fail()are no-ops —release()still validates its$delaySecondsthe way a durable backend does, so a value production rejects is not quietly accepted in development. DeclaresClearableQueueInterface, whoseclear()always reports 0 — nothing is ever stored, so nothing is ever waiting.push()first runs$jobthroughJobSerializer::serialize()thendeserializeJob(), exactly like a durable backend’s push()/worker pair — the reconstructed instance is whatJobInvoker::invoke()actually runs, never the caller’s own object; a payloadJobSerializerrejects fails here too, atpush()time — see Queue’s “What a constructor argument can hold”. For local development; not selectable viaQUEUE_CONNECTION. A freshRequestScopeperpush()fromAppScope::createRequestScope(), same asQueueWorker, so every package request-scope initializer runs on it; unlikeQueueWorker, a failing job’s exception propagates rather than being caught and logged, but the scope’s dispose hooks —kinetis/database-bridge’s dangling-transaction cleanup included — still run before it does. If disposal itself then fails too,push()rethrows the job’s exact exception, never the disposal failure — logged separately throughAppScope’s own logger instead; if only disposal fails (the job succeeded), that failure propagates normally, since it is the only thing that went wrong. See Appendix: Queue Contracts’s “A disposal failure never rewrites the outcome or stops the worker”.Kinetis\Queue\QueueWorker—__construct(AppScope $app, QueueInterface $queue, int $defaultMaxAttempts = 0, int $retryBaseDelaySeconds = 5),run()/processNext()/stop(). SIGTERM/SIGINT (viaext-pcntl, when loaded —supportsGracefulShutdown()) stoprun()’s loop after the job in flight finishes, so a deploy never truncates a job. One freshRequestScopeper job viaAppScope::createRequestScope(), so every package request-scope initializer runs on it, withhandle()’s parameters autowired through it viaJobInvoker— see Appendix: Container Lifecycle. OnlyJobSerializer::deserializeJob()/JobInvoker::invoke()decide the outcome; every call that merely describes or observes it —jobStarted()(ahead of the job), the real-failure log line (ahead of the transition on a failure), and, strictly after the one matching durable transition (ack()/release()/fail()), completion telemetry and the lifecycle-event dispatch — runs through the privaterunBestEffort(), before or after, so none of them can ever block, delay, or replace that transition; a throwing listener or telemetry backend is caught, reported through the logger (itself run the same no-throw way), and can never trigger a second transition or escapeprocessNext()— see Appendix: Queue Contracts’s “Observers never decide or rewrite the outcome”. The job’s ownRequestScopeis disposed last, in its own contained block, after that transition and every observer above have already run: a disposal failure there is logged (throughAppScope’s own logger, not the now-disposed scope) and, like every other observer failure, can never trigger a second transition or escapeprocessNext()/stoprun()’s loop — see Appendix: Queue Contracts’s “A disposal failure never rewrites the outcome or stops the worker”. TheJobSerializer::redact()call behind the failure log line is contained the same way in spirit but through its own dedicated fail-closedtry/catch, notrunBestEffort()— a reflection failure there falls back to every argument redacted, with no separate report of its own, sofail()still runs regardless. While a renewable backend’s job runs (RenewableQueueInterface, resolved once in the constructor), a package-internalDeliveryHeartbeatowns one unreferenced Revolt repeat watcher at half the backend’s visibility window and renews that delivery’s reservation — unreferenced so it never keeps the event loop alive and never hides the empty-loop conditionKinetis\Async\ConcurrentBatchreads as a task deadlock. At most one renewal is in flight; a tick arriving while the previous call has not answered is dropped. Before any settlement the watcher is cancelled and an in-flight renewal is waited out through a Revolt suspension, bounded by that adapter’s own operation timeout — mandatory, since SQS renews and releases with the sameChangeMessageVisibilitycall and a late renewal would replace a delayedrelease()’s backoff. A failure of that wait is not contained: the renewal is still suspended and can resume, so the error propagates and noack()/release()/fail()is attempted for that delivery at all. A failed renewal call is caught, counted, and retried on later ticks; once the settlement has been attempted — and its lifecycle event dispatched, when it succeeded — oneerrorline reports the count and the last exception, still written when the settlement itself threw and never in place of that exception. Nothing else follows from it: no event, no exception, no retry policy, no worker restart. A handler that never yields to the event loop cannot be renewed. A throwing job is always logged (job class, queue, attempt number, and the exception, plus the arguments — redacted perAttributes\Sensitive— only when the job is being given up on, since a job about to be retried still holds its payload in the backend); the effective cap isQueuedJob::$maxAttempts ?? $defaultMaxAttempts— released while$attemptsis below it,fail()ed once reached.$defaultMaxAttemptsis non-nullable: there is no configuration on this class that produces unlimited retries by default. A release carries a delay the worker computes privately —min(900, $retryBaseDelaySeconds * 2 ** min($attempts - 1, 10)), admitted range0–900viaassertValidRetryBaseDelay(), with the 900-second ceiling a code constant rather than a second setting — and the backend holds the job for it: the worker never sleeps, keeps no timer, and retains no request scope during a backoff.fail()takes no delay, so nothing about the schedule reaches a final attempt.Events\JobReleasedcarries no delay; the failure log line and itsjobcontext report the computed delay instead. Dispatches, through the job’s ownRequestScope:Events\JobSucceededonack(),Events\JobReleasedonrelease(), andEvents\JobFailedPermanentlyonfail()— see Events for the full catalog. A settlement the backend rejects withException\StaleJobHandleExceptionis caught on all three paths: the loop continues, none of those three events fires (each asserts a durable transition that did not happen), andEvents\JobSettlementLostplus a warning-level log line report the loss instead. Telemetry still closes the job’s span — a staleack()closes as a settlement failure carrying the stale exception, a stalerelease()/fail()keeps the job’s own exception, which is what the span was opened to describe. Every other exception fromack()/release()/fail()propagates and stops the loop: a backend refusing writes is not a settled job.Kinetis\Queue\QueuedListenerInvoker— implements core’sKinetis\Events\ListenerInvokerInterface.invoke()receives the listener as a class-string, never a resolved instance —EventDispatcherchecks the registry’s ownqueuedflag before ever constructing one, so nothing about the listener runs in the process that dispatched the event. Serializes the event (viaJobSerializer, generalized to accept anyobject, notJobspecifically) and pushes anInvokeListenerJobcarrying the listener’s class/method as plain strings; the givenRequestScopeis accepted only to satisfy the shared interface, never used.Kinetis\Queue\InvokeListenerJob— the jobQueuedListenerInvokerpushes. Its$eventArgsholds an earlierJobSerializer::serialize()call’s output, so serializing this job walks values that are already wire values and passes them through unchanged.handle(RequestScope $scope)resolves the listener through the given scope and reconstructs the event viaJobSerializer::deserialize($this->eventClass, $this->eventArgs), invoking the original method by name.Kinetis\Queue\QueueFactory::fromConfig(Config $config, string $connection = 'default'): QueueInterface— builds the backend$connection’s own selector names:Config::scopedKey('QUEUE_CONNECTION', $connection), soQUEUE_CONNECTIONfordefaultandQUEUE_JOBS_CONNECTIONforjobs(redis|sql|sqs|rabbitmq, required, with no fallback from a named selector toQUEUE_CONNECTION), and passes$connectionunchanged to that backend’s factory. The name is not validated here; a caller taking it from outside the application validates it. An unknown value throwsInvalidArgumentExceptionnaming the selector and the four accepted values. Every one of the four isclass_exists()-gated against its own package’sXxxQueueFactory::fromConfig()—kinetis/queueitself depends on none of them,redis/sqlexactly as optional assqs/rabbitmq; throwsException\QueueUnavailableExceptionnaming the selector and the missing package when the selected one isn’t installed. The return type isQueueInterface; capabilities beyond it vary by backend, so a caller needing one checks the returned instance for it.Kinetis\Queue\PackageBootstrap— declared viaextra.kinetis; readsQUEUE_CONNECTION_NAME(unset or blank means'default') as the connection to bind, validates any other value throughQueueContract::assertValidConnectionName()before deriving the selector — a malformed name throwsInvalidArgumentExceptionnamingQUEUE_CONNECTION_NAMEinstead of leaving the bootstrap inert — and, with that connection’s scopedQUEUE_CONNECTIONselector set, binds three things.QueueInterfaceto a factory callingQueueFactory::fromConfig($config, $name), which also registers that backend’sdispose()on theAppScopewhen it declaresDisposableQueueInterface, so the connection this binding opened is closed when the worker ends and an application’s own queue — which never reaches the factory at all — is left to whoever opened it;ClearableQueueInterfaceto a factory that resolvesQueueInterfaceand returns it when it declares the capability, throwingException\QueueNotClearableExceptionnaming the backend when it does not; and core’sKinetis\Events\ListenerInvokerInterfaceto a factory wrapping that same resolved queue inQueuedListenerInvoker, so a listener markedKinetis\Events\ShouldQueuequeues with no second registration —AppScope::boot()registers its ownSynchronousListenerInvokeronly where nothing is bound yet, and runs after every package bootstrap. All three are resolved on first use, so an application that never injects a queue builds no backend and an application whose ownbootstrap.phpbindsQueueInterface(running after this and winning on the binding) never builds the one the selector names either — and both derived bindings always answer with the queue the application actually runs, not the one the factory would have produced. An application binding either derived interface itself wins on that binding too. Inert when that selector is unset, leaving core’s synchronous invoker in place; a namedQUEUE_CONNECTION_NAMEwith onlyQUEUE_CONNECTIONset stays inert too.Kinetis\Queue\Console\WorkCommand— thequeue:work [--queue=high,default] [--connection=<name>]command onvendor/bin/kinetis, registered through this package’sextra.kinetisscan root. Constructor-injectsRequestScopeandConfig; readsQUEUE_POLL_TIMEOUT,QUEUE_MAX_ATTEMPTS(passed through asQueueWorker’s$defaultMaxAttempts) andQUEUE_RETRY_BASE_DELAY_SECONDS(its$retryBaseDelaySeconds), defaulting to5/0/5respectively and each validated throughQueueWorker’s own shared assertion before any queue is resolved or built. Without--connectionit resolvesQueueInterfacethrough theRequestScope(thePackageBootstrapbinding, or the application’s override).--connection=<name>takes a value that passesQueueContract::assertValidConnectionName()— a bare or empty option throwsInvalidArgumentException(--connection needs a value: --connection=<name>.) — and builds that connection throughQueueFactory::fromConfig()without resolving the binding, so--connection=defaultbypasses both an application override andQUEUE_CONNECTION_NAME; a queue built that way that declaresDisposableQueueInterfacehas itsdispose()registered on theAppScope, which the CLI disposes on every exit path. Startup output follows option validation, setting validation and queue resolution. Warns on STDERR at startup whenext-pcntlis missing, since graceful shutdown is impossible without it.Kinetis\Queue\Console\StatsCommand/ClearCommand—queue:stats [--queue=high,default](waiting counts per queue, with a total) andqueue:clear --queue=<name> --force(discards waiting jobs; refuses without--force, exit 1). Both drive theQueueInterfacebinding, so they report on whichever backend that binding resolves to.ClearCommandis the one runtime capability check in the package: a backend not declaringClearableQueueInterfaceis named along with the missing interface and the command exits 1 having touched no queue. It then parses--queueinto a complete list and runsQueueContract::assertValidQueueList()over the whole of it before the firstclear(), so a malformed or repeated name anywhere in the list leaves every queue in it untouched rather than discarding the ones ahead of it first.Depends on
kinetis/framework(via apathrepository to this monorepo’s root) pluspsr/log(QueueWorker’s failure logging),psr/container(JobInvoker’s container parameter). No backend dependency at all — Redis, SQL, SQS, and RabbitMQ each live in their own separate package below. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/queue-redis (kinetis/queue-redis)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\QueueRedis\RedisQueue implements Kinetis\Queue\ClearableQueueInterface, Kinetis\Queue\DisposableQueueInterface, Kinetis\Queue\RenewableQueueInterface— backed byAmp\Redis\RedisClientoverkinetis/redis’s non-replaying link, so a command whose reply never arrived is reported asKinetis\Redis\Exception\OutcomeUnknownrather than being sent a second time. Reserves under a finite lease rather than a plain destructive pop: each queue has apendinglist, adelayedsorted set scored by ready-at time, and aleasedsorted set scored by lease expiry, where the member is the exact envelope handed back asQueuedJob::$handle.pop()’s Lua script reads the pending tail,ZADDs that exact member toleasedwith an expiry of$visibilityTimeoutSeconds(second constructor argument, default 300, rejected below 1) from Redis’s ownTIME, and only thenLREMs it from pending — that order is what keeps a wrong-typed or erroring leased key from destroying the sole pending copy. Expiries are read from Redis’sTIMEthroughout, so every worker shares one lease clock. Eachpop()promotes due delayed jobs and reclaims expired leases for every queue it is given, in priority order, in bounded batches (DELAYED_PROMOTION_BATCH_SIZE,LEASE_RECLAIM_BATCH_SIZE, both 100), so a large backlog can’t stall other Redis clients for one script’s whole duration; there is no reaper process. Between sweeps it paces withAmp\delay()capped atPOLL_INTERVAL_SECONDS(1.0) and clipped to what is left of the caller’s deadline — no blocking Redis command, so the queue’s operation budget is plainlyREDIS_TIMEOUT. A reclaim incrementsattemptsand encodes a new envelope, so a crashed worker’s job is redelivered as a different member string;release()and reclaim share one conditional Lua script (ZSCOREthe old member, write the replacement —LPUSHonto pending, orZADDinto the delayed set with a due score whenrelease()carried a delay — thenZREMthe old, returning whether it won), so two sweepers racing, or a sweep racing a settlement, yield one winner rather than a duplicate, and a delayed retry is as indivisible as an immediate one. A reclaim always calls it with no delay: a crashed delivery is not a handled failure.ack()/fail()ZREMonly that exact member and read the count back, so a settlement for a delivery already settled or reclaimed raisesKinetis\Queue\Exception\StaleJobHandleExceptionand writes nothing. An abandoned lease whose envelope no longer decodes is settled throughQueueContract::settleIfMalformed()rather than reclaimed forever.renew()is one further Lua script: it reads RedisTIMEand resets the exact leased member’s expiry toTIME + $visibilityTimeoutSecondswithZADD ... XX, so a member the leased set no longer holds is never added back, and the changed count is ignored becauseZADDanswers 0 for a score it did not change — which a renewal inside the same second produces.QueueWorkerdrives it while a job runs, so the window sizes crash recovery rather than job duration; a job whose worker died, or whose handler never yields, still outlives its expiry and can execute alongside its replacement, andmaxAttemptsbounds a handler that throws, not a succession of processes that each die during execution.size()counts pending, delayed and expired leases but not live ones;clear()removes pending and delayed entries in one script and leaves the leased key alone. Every envelope carries a randomid(and apushedAttimestamp), generated fresh only on an independentpush()—release()preserves theid/pushedAtit reads back off the envelope it’s replacing, keeping the job’s own logical identity and original enqueue time stable across retries.idis what keeps two byte-identical jobs from colliding into one member when both land in the delayed sorted set — sorted-set members are unique, plain strings are not. The envelope is exactly{id, pushedAt, class, args, attempts, maxAttempts, metadata}and all seven keys are required: the decoder checks each one against the shapeRedisQueueitself writes rather than the widest shape a cross-backend coercer accepts —idis 32 lowercase hexadecimal characters, andpushedAta positive Unix timestamp thatjson_decode()returned as a native integer, never a numeric string — all before aQueuedJobexists, so an envelope missing or corrupting any one of them settles throughKinetis\Queue\QueueContract::settleIfMalformed()rather than reachingack()/release()as a partially accepted job.Kinetis\QueueRedis\RedisQueueFactory::fromConfig(Config $config, string $connectionName = 'default'): RedisQueue— reads the sameREDIS_*convention the cache does, includingREDIS_TLS*, and buildsnew Amp\Redis\RedisClient($client->link())over aKinetis\Redis\Clientof its own, keeping that client and handing the queue itsclose()as the disposer — the Amp facade exposes no close, so theKinetis\Redis\Clientis the only object that can end the connection; first throwsInvalidArgumentExceptionnaming the connection’s scopedREDIS_CLUSTERkey when it is true, since queue scripts span keys with no shared hash tag and only standalone Redis is supported; throws when neitherREDIS_URLnorREDIS_HOSTis set, and rejects aQUEUE_VISIBILITY_TIMEOUT_SECONDS(read viaConfig::scopedKey(), defaultDEFAULT_VISIBILITY_TIMEOUT_SECONDS= 300) below 1. The queue’s own connection is never shared with the cache, so its lifetime and itsREDIS_TIMEOUTbudget are its own.kinetis/queue’sQueueFactorydispatches to this package for a connection whose selector isredis(QUEUE_CONNECTION=redisfor the default connection).Depends on
kinetis/framework,kinetis/queue, andkinetis/redis(all viapathrepositories), plusamphp/redisandamphp/socket. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/queue-sql (kinetis/queue-sql)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\QueueSql\SqlQueue implements Kinetis\Queue\ClearableQueueInterface, Kinetis\Queue\DisposableQueueInterface, Kinetis\Queue\RenewableQueueInterface— backed by the genericKinetis\Persistence\Contract\SqlLink(dialect-agnostic SQL, including priority ordering viaCASE queue WHEN ... END). Dequeues viaSELECT ... FOR UPDATE SKIP LOCKEDinside a transaction;pop()’s blocking contract is a poll loop suspended withKinetis\Async\Timer::delay(), since SQL has no native blocking-wait primitive — bounded by whatever’s left of the overall deadline rather than always the full poll interval. It needs no per-queue sweep at all: its own single, priority-ordered SQL query already checks every queue as one atomic operation. Requires thekinetis_queue_jobstable (withqueue,attempts,max_attempts,reserved_at,reserved_tokencolumns and a composite(queue, available_at, reserved_at)index) — seeresources/migrations/create_kinetis_queue_jobs_table.{mysql,pgsql}.php.stub, not auto-created.fail()deletes the row, the same asack(). Its second constructor argument,$visibilityTimeoutSeconds(default 300, rejected below 1), reclaims a crashed worker’s reserved row after that many seconds, incrementingattemptsat that point.clear()deletes only rows whosereserved_atis null, which is narrower than the predicatesize()andpop()share: that one treats a reservation past$visibilityTimeoutSecondsas available again, and reclaiming is a per-row handoverpop()makes under the row lock, while a clear has none to make and a slow worker still owns the row it is running. Every reservation and reclaim writes a freshbin2hex(random_bytes(16))reserved_tokenunder the row lock, andQueuedJob::$handleis aKinetis\QueueSql\Reservationcarrying the row id and that token;release()also setsavailable_attonow + $delaySeconds, the same column andY-m-d H:i:sformatpush()writes a delayed enqueue with, so a delayed retry needs no migration or schema change.ack()/release()/fail()match on both and require an affected-row count of exactly 1, so a settlement from a delivery a reclaim has superseded writes nothing and raisesKinetis\Queue\Exception\StaleJobHandleExceptioninstead of settling the reservation another worker now holds — the malformed-row cleanup included, which is fenced by the same predicate and surfaces that exception frompop()rather than deleting a live delivery’s row.renew()is oneUPDATEsettingreserved_at = ?under that sameid/reserved_tokenpredicate, leavingattemptsandavailable_atalone; its affected-row count is not read, since MySQL reports 0 for anUPDATEwriting the value already stored, which a renewal inside the same second does.reserved_atis written and compared against the worker process’s owntime(), not the database’s clock — a renewal included — so skew between workers shifts when a reservation looks expired. The MySQL stub declaresqueueandreserved_tokenascii_bin, since MySQL’s default collation compares case-insensitively and both are matched for exact equality; Postgres compares byte-exactly already.Kinetis\QueueSql\SqlQueue::pushOn(Kinetis\Persistence\Contract\SqlTransaction $transaction, Kinetis\Queue\Job $job, int $delaySeconds = 0, string $queue = 'default', ?int $maxAttempts = null): void— enqueues through a transaction the caller already owns. It shares one private insertion path withpush(), so argument validation, serialization, telemetry metadata, timestamps and the INSERT shape are the same on both routes. The row becomes visible and durable only if the caller commits; a throw before the commit rolls it back with the caller’s other work, and aCOMMITthat fails leaves the unknown outcomeSqlTransactiondescribes. Push telemetry closes when the INSERT statement completes, so the span reports the enqueue statement rather than the later commit.$transactionis the only thing SQL runs on: nothing is committed, rolled back, nested or retained, and the constructorSqlLinkis untouched — so the caller’s transaction must address the database holdingkinetis_queue_jobs, since the queue’s connection name scopes only the connection behindpush(). Concrete on this class rather than a capability interface: there is one implementation, andQueueInterfacecarries only what every backend delivers identically, soQueueInterface::push()is not enlisted in a caller’s transaction on any backend. It takes a raw persistence transaction; akinetis/ormtransaction session hands its callback anEntityManagerand does not expose a transaction.Kinetis\QueueSql\SqlQueueFactory::fromConfig(Config $config, string $connectionName = 'default'): SqlQueue— builds aSqlQueuefromKinetis\DatabaseBridge\ConnectionFactory::fromConfig()’s result, readingQUEUE_VISIBILITY_TIMEOUT_SECONDS(viaConfig::scopedKey()) for the second constructor argument — absent meansDEFAULT_VISIBILITY_TIMEOUT_SECONDS(300), and a value below 1 is rejected. The link is opened here, so itsclose()becomes the queue’s disposer. The return type is the concrete class rather thanClearableQueueInterface:pushOn()sits on no interface, so this is what lets a caller who has already named the backend reach it, and lets an application bindSqlQueue::classto the result without a runtime narrowing check.SqlQueueimplementsQueueInterfaceandClearableQueueInterface, andQueueFactory’s connection-driven dispatch still hands backQueueInterface.kinetis/queue’sQueueFactorydispatches to this package for a connection whose selector issql(QUEUE_CONNECTION=sqlfor the default connection).Depends on
kinetis/framework,kinetis/queue,kinetis/persistence(SqlQueue’sTransactionGuarduse and theSqlTransactioninpushOn()’s signature), andkinetis/database-bridge(ConnectionFactory) — all viapathrepositories. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/queue-sqs (kinetis/queue-sqs)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\QueueSqs\SqsQueue implements Kinetis\Queue\RenewableQueueInterface— backed byAsyncAws\Sqs\SqsClient.push()/pop()map ontoSendMessage/ReceiveMessage;ack()/fail()ontoDeleteMessage;release()ontoChangeMessageVisibility, carrying its own$delaySecondsas the newVisibilityTimeout— on a call SQS accepts, that timeout counts from the call, so0makes the message available again immediately rather than waiting out the normal timeout. The request field accepts 0 to 43200 seconds, a separate and wider limit fromSendMessage’s 900-secondDelaySeconds, and this backend raises against it before any transport. Being in range is not acceptance: SQS refuses a timeout longer than the time left in that received message’s own 12-hour maximum and documents that it does not recalculate down to that remaining time, which is service state this package cannot see — so it makes no local guess and the refusal propagates as SQS’s own error with nothing settled. A queue name resolves to an SQS queue of that name (optionally prefixed) viaGetQueueUrl, cached per instance — never auto-created.delaySecondsuses SQS’s own nativeSendMessagedelay, capped at 900 seconds — a longer value throws before any network call.QueuedJob::$attemptscomes directly from SQS’s ownApproximateReceiveCountmessage attribute;$maxAttempts(no native SQS equivalent) travels as a custommaxAttemptsmessage attribute; instrumentation propagation metadata travels the same way, as one JSON-encodedmetadataattribute (see the telemetry package’sOtelTelemetryabove).pop()sweeps every named queue in priority order withWaitTimeSeconds: 0, then long-polls the highest-priority queue for up to five seconds before sweeping again —0means “don’t block” on SQS, so one operation covers both, and the long poll is capped by what is left of the caller’s deadline, rounded up to a whole second becauseWaitTimeSecondsaccepts no finer unit.pop()rechecks the deadline the moment that poll comes back empty, so no further receive is issued after it has passed. NoKinetis\Async\Timer::delay()orconcurrently()wrapper, since the injectedAmpHttpClienttransport tolerates being called from plain top-level code. Standard SQS queues only; FIFO is not supported. Its third constructor argument,$visibilityTimeoutSeconds(default 300, admitted range 1 to 43200), is sent asVisibilityTimeouton everyReceiveMessage, so it overrides the queue’s own attribute for the messages this application takes, andrenew()restores that same window with oneChangeMessageVisibility;SqsQueueFactoryreads it from the scopedQUEUE_VISIBILITY_TIMEOUT_SECONDSand validates it before an AWS client is built. AWS counts a message’s own 12-hour maximum from the receive rather than from the last renewal, so a job running past it is redelivered whatever the worker sends.QueuedJob::$handleis the message’sReceiptHandle, which SQS scopes to the receive that produced it; whatever SQS answers a settlement with propagates as its own error rather than asKinetis\Queue\Exception\StaleJobHandleException. Does not declareKinetis\Queue\ClearableQueueInterface, has noclear()at all, and never callsPurgeQueue: that operation deletes the messages a worker holds in flight along with the waiting ones, keeps deleting messages sent during the up-to-60-second window it takes to finish, and reports no count — andsize(), which excludes in-flight work and is an estimate, could not report what it destroyed either. Emptying an SQS queue stays an infrastructure step; see Queue (SQS).Kinetis\QueueSqs\SqsClientFactory::fromConfig(Config $config, string $connection = 'default'): SqsClient— buildsSqsClientwithKinetis\RevoltHttpClient\AmpHttpClientFactory::create()injected as its transport.QUEUE_SQS_REGIONrequired;QUEUE_SQS_ENDPOINT/QUEUE_SQS_PLAINTEXT/QUEUE_SQS_TIMEOUT/QUEUE_SQS_QUEUE_PREFIXoptional, all viaConfig::scopedKey(). An explicit endpoint is validated down to one origin; without one, an ambientAWS_ENDPOINT_URLis refused. Credentials are never read fromKinetis\Config: the factory composes AsyncAws’s standard providers in its standard order behindKinetis\QueueSqs\CredentialChain, handing that same transport to every provider in it, including the one that assumes anAWS_ROLE_ARNrole through STS. See Queue (SQS) for what stays blocking.Kinetis\QueueSqs\SqsQueueFactory::fromConfig(Config $config, string $connectionName = 'default'): SqsQueue— theclass_exists()-gated entry pointkinetis/queue’s ownQueueFactorycalls: buildsSqsQueuefromSqsClientFactory::fromConfig()plus the optionalQUEUE_SQS_QUEUE_PREFIXand the scopedQUEUE_VISIBILITY_TIMEOUT_SECONDS(default 300, admitted range 1 to 43200, read and validated before the client is built). Returns the concrete class, the narrowest type this backend declares, like every other backend’s own factory.kinetis/queue’sQueueFactorydispatches to this package for a connection whose selector issqs(QUEUE_CONNECTION=sqsfor the default connection).Depends on
kinetis/framework,kinetis/queue, andkinetis/revolt-http-client(all viapathrepositories), plusasync-aws/sqs. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/queue-rabbitmq (kinetis/queue-rabbitmq)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\QueueRabbitMq\RabbitMqQueue implements Kinetis\Queue\ClearableQueueInterface, Kinetis\Queue\DisposableQueueInterface— backed byThesis\Amqp\Client/Channel. A queue is declared durable on first touch by any method, never auto-created ahead of that.push()publishes to the queue directly; a delayedpush()publishes into a ladder of internal holding queues —{queue}.delay.{2^i}s, each with anx-message-ttlof 2^i seconds and dead-lettering into the tier below, so a delay is spent as the binary sum of its tiers and every message in a tier owes that tier’s own wait rather than queueing behind a longer one (DelayLadder,@internal, carries the topology and the 4,194,303-second ceiling the client’s signed 32-bit encoding of anx-message-ttlimposes; Queue (RabbitMQ) documents what a caller sees).size()/clear()cover every tier, so a delayed job counts as waiting and is purged like any other, from any process — queue by queue, not as one atomic snapshot.attempts/maxAttemptstravel as plain message headers (AMQP 0-9-1 has no native attempt count, only a booleanredeliveredflag), and instrumentation propagation metadata as a JSON-encodedmetadataheader carried forward byrelease(). Every publish runs on a channel in confirm mode,mandatory, and waits for the broker’s acknowledgement, throwingException\PublishNotConfirmedExceptionfor anything else;release()republishes with an incrementedattemptsheader (nack’s ownrequeueflag redelivers the message unchanged, so it cannot carry the new count) — through the same real-queue-or-ladder publicationpush()uses, so arelease()carrying a delay enters the ladder and is subject to the same ceiling — waits for that acknowledgement, and only then discards the original delivery vianack(requeue: false)— an unconfirmed publish settles nothing and the job stays queued, while the crash window between a confirmed publish and the nack is the duplication window Queue’s own backend comparison records against this backend.QueuedJob::$handleis theThesis\Amqp\DeliveryMessageitself; a delivery tag is scoped to the channel that produced it and the broker answers a second settlement of one with a channel-level error, so this backend raises noKinetis\Queue\Exception\StaleJobHandleExceptioneither.pop()sweeps every named queue in priority order withbasic.get, each a single immediate request (AMQP has no native blocking-wait-with-timeout primitive), and paces between sweeps withAmp\delay()— a one-second pause, cut short by whatever is left of the caller’s deadline. One channel per instance, opened lazily and reused.Kinetis\Async\concurrently()composes correctly with a still-open connection, confirmed against a real broker —ConcurrentBatchparks on a targeted Revolt suspension resumed once its own tasks finish, unaffected byThesis\Amqp\Channel’s permanent background reader.Kinetis\QueueRabbitMq\Exception\PublishNotConfirmedException— a publish RabbitMQ did not acknowledge (Nacked,Unroutedfor amandatorypublish with nowhere to go,Canceled,Waiting, or a channel that is not in confirm mode at all). Raised before anything is settled, which is what keepsrelease()from discarding a job against a publish that never landed.Kinetis\QueueRabbitMq\RabbitMqClientFactory::fromConfig(Config $config, string $connection = 'default'): Client— buildsThesis\Amqp\ClientfromThesis\Amqp\Config::fromURI(), re-created with its username, password and vhostrawurldecode()d — the vendor parser leaves those three URI components percent-encoded, so/%2fwould otherwise name a vhost literally called%2f(Queue (RabbitMQ) documents what a caller writes).QUEUE_RABBITMQ_URLrequired, viaConfig::scopedKey().Kinetis\QueueRabbitMq\RabbitMqQueueFactory::fromConfig(Config $config, string $connectionName = 'default'): RabbitMqQueue— theclass_exists()-gated entry pointkinetis/queue’s ownQueueFactorycalls: buildsRabbitMqQueuefromRabbitMqClientFactory::fromConfig()plus the optionalQUEUE_RABBITMQ_QUEUE_PREFIX.kinetis/queue’sQueueFactorydispatches to this package for a connection whose selector israbbitmq(QUEUE_CONNECTION=rabbitmqfor the default connection).Depends on
kinetis/frameworkandkinetis/queue(both viapathrepositories) plusthesis/amqpandamphp/amp. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/storage (kinetis/storage)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\Storage\AmpFileAdapter— aLeague\Flysystem\FilesystemAdapterfor local disk backed byAmp\File\Filesystem, so a driver call suspends the calling Fiber via Revolt rather than blocking the worker.write(),writeStream()andcopy()publish through one primitive: a private0700directory beside the destination, an exclusive staged file, a post-close length check, the mode to publish under, and one rename.readStream()buffers the whole object into aphp://tempresource, inside a boundary of its own that keeps a converted spill-disk warning anUnableToReadFileand closes the resource;writeStream()transfers the caller’s resource in bounded chunks on the calling thread, never holding the whole input, and restores its blocking mode without closing it. Both block the thread wherever their native stream calls reach a disk.copy()reads the source in the same bounded chunks.deleteDirectory()plans the whole subtree before deleting anything. Confinement, the root-destination refusal, the symlink check and everyAmp\Filecall run inside one typed boundary per operation, so a driver failure arrives as that operation’s ownUnableTo*while a policy outcome and a programmer error each keep their type. Requires a non-empty root, and refuses a publication whose destination names that root. See Appendix: Storage Reference for the confinement rules, the publication guarantees, the resource-method boundaries, and the symlink rules.Kinetis\Storage\ConfinedPath—@internal; the value every operand of everyAmpFileAdapteroperation is admitted through before a location is built from it, and whose segments the symlink walk steps through. See Appendix: Storage Reference for the rules it enforces.Kinetis\Storage\StagingName—@internal; the name grammar of the private directoryAmpFileAdapterpublishes through, matched whole, and the one definitionpublish()’s naming,listContents()’s hiding andConfinedPath’s refusal (Kinetis\Storage\Exception\ReservedPathDetected) all read. See Appendix: Storage Reference.Kinetis\Storage\PackageBootstrap— declared viaextra.kinetis; withFILESYSTEM_DRIVERset, lazily bindsLeague\Flysystem\FilesystemOperatortoFilesystemFactory::fromConfig()’s result before the application’s ownbootstrap.phpruns (which wins on the same binding). Inert whenFILESYSTEM_DRIVERis unset; named connections stay explicit app-side wiring.Kinetis\Storage\FilesystemFactory::fromConfig(Config $config, string $connection = 'default'): League\Flysystem\Filesystem—FILESYSTEM_DRIVER(default'local') andFILESYSTEM_ROOT(required and non-empty for the local driver), both viaConfig::scopedKey()for named connections. The local driver isAmp\File\createDefaultDriver(), notAmp\File\filesystem()’s status-caching wrapper, so each filesystem owns its driver — and, withoutext-uv/ext-eio, that driver’s own worker-process pool — and reports what other threads, processes and external writers have done. One instance per process or thread, kept for the application’s lifetime; every named connection is another pool.FILESYSTEM_DRIVER=s3dispatches topackages/storage-s3(below) if installed, else throwsException\StorageUnavailableException.Depends on
kinetis/framework(via apathrepository),league/flysystem,league/mime-type-detection(FinfoMimeTypeDetector),amphp/file,amphp/byte-stream(AmpFileAdapter’swriteStream(), viaAmp\ByteStream\ReadableResourceStreamandAmp\ByteStream\pipe()). Suggestsext-uv/ext-eio: without either,amphp/fileruns every call in a pool of worker processes, one pool per filesystem instance built. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/revolt-http-client (kinetis/revolt-http-client)¶
Separate Composer package, not part of kinetis/framework core — and
not dependent on it either: kinetis/framework appears only in
require-dev (for tests and the NoStaticPropertiesRule dogfooding),
never in require. Installable and usable with no Kinetis framework
present at all — the same standing kinetis/redis and
kinetis/aws-sigv4 have, and kinetis/mcp-protocol, which requires nothing
from this monorepo at all.
Kinetis\RevoltHttpClient\Http— the client application code uses. Immutable:withBaseUrl()/withToken()/withBasicAuth()/withHeaders()/withQuery()/withTimeout()/withRetries()/withMaxResponseBytes()/asForm()each return a new instance.get()/post()/put()/patch()/delete()take arrays (query forget(), JSON body for the rest);send()is the general form. Constructed with no argument it defaults toAmpHttpClientFactory::create(), so it autowires; pass anyHttpClientInterface(Symfony’sMockHttpClient, for one) to substitute the transport, which is then the caller’s to hold to one wire attempt per request and to no credentials or base URI of its own.Preflightvalidates what the package’s own guarantees rest on, before a transport object exists: an absolute http(s) base URL with no userinfo, query string or fragment, printable-ASCII and backslash-free, with a path free of./..segments and of percent-encoded separators and normalized into a prefix a relative target extends; the same URL rules on each request URL, which must be relative under a base URL and absolute without one; header names as RFC 9110 tokens given once per array, values as a string or a non-empty list of strings with no CR/LF/NUL, and none ofAccept-Encoding,HostorProxy-Authorization, which the client owns or refuses; a positive finite timeout and response-byte ceiling, and a retry count of 0 to 10; andsend()’s options as a closed set ofheaders/query/json/body/timeout, everything else refused. Method, body and query value types are Symfony’s to validate: a transport that refuses to construct the request fails as a fixedInvalidRequestcarrying neither the value nor the vendor message. Refused input performs no transport call. A client carrying anAuthorizationorCookieheader requireswithBaseUrl(), which is what confines a credential to one origin;max_redirectsis 0 on every request, so no credential is ever forwarded to aLocationorigin.withRetries()is the package’s own retry loop rather than a decorator, retrying transport failures and 429/500/502/503/504 for a method exactlyGET/HEAD/OPTIONS/TRACE/PUT/DELETEwith backoff doubling from 100 ms inside the one deadline, returning the last response when retries or budget run out, refusing a stream orClosurebody on a method it could retry, and releasing every response it abandons — any other method makes one deferred attempt, as on a client without retries, because aTransportfailure is acknowledgement-unknown and a retryable status does not make aPOSTsafe to repeat;withTimeout()(30 s by default) is a single monotonic deadline covering every attempt, backoff, and response read, handed to each attempt as what remains of it;withMaxResponseBytes()(8 MiB by default) is the ceiling a response body may reach, made a bound on memory by the identityAccept-Encodingevery request carries.Kinetis\RevoltHttpClient\HttpResponse—status()/successful()/failed()/redirect()/clientError()/serverError()/body()/json()/jsonPath()/header()/headers()/discard(). An error status is returned rather than thrown;throw()opts into raisingException\HttpRequestExceptionand returns the response otherwise, so it chains.json()decodes withJSON_BIGINT_AS_STRING, so an integer too wide for PHP’s int type keeps its digits instead of becoming a float. The body is bounded by the client’s ceiling at three points — a declaredContent-Length, the transfer itself through the transport’s progress hook, and the bytes that arrived — so no path buffers a complete untrusted reply first; the same progress hook enforces the operation’s deadline, which is rechecked after every read that answers. Which failure a raised read becomes is read from the budget’s state rather than the vendor exception’s type, so a progress-guard abort the transport wrapped is still reported as the ceiling or the deadline it was. The object owns the transport response:discard()releases it, never throws, never blocks, and is repeatable, after which every read fails with theDiscardedcategory; a response nobody discards releases the same way when PHP collects it, and one read to its end has nothing left to release. The body is read once and cached, and reading is deferred until asked for, which is what lets requests started insideconcurrently()overlap.Kinetis\RevoltHttpClient\Exception\HttpRequestException— the only exception the package throws, over a fixedHttpFailurecategory (InvalidRequest,Conversion,Transport,Timeout,ResponseTooLarge,ErrorStatus,Discarded) that callers branch on. It carries the request method, the origin (scheme, host, non-default port), a status, and that category, and nothing else — no path, query string, userinfo, header, credential, or body. A vendor exception is never chained and its message never copied, since a lower-level HTTP or DNS client routinely names the full URI it failed on and an exception message is what a log pipeline records by default.#[\SensitiveParameter]on every forwarding parameter keeps caller input out ofgetMessage(),(string) $e, andgetTraceAsString();getTrace()andserialize()still reach theSensitiveParameterValuewrappers PHP puts in their place, so an argument-carrying trace is not a safe thing to forward.Kinetis\RevoltHttpClient\PreflightandKinetis\RevoltHttpClient\ResponseBudgetare@internal.ResponseBudgetowns one operation’s monotonic deadline and byte ceiling, produces the transport’son_progressguard and per-attempttimeout/max_duration, and builds every failure naming the method and origin.Kinetis\RevoltHttpClient\AmpHttpClientFactory::create(array $defaultOptions = [], ?callable $clientConfigurator = null, int $maxHostConnections = 6, int $maxPendingPushes = 50): Symfony\Contracts\HttpClient\HttpClientInterface— the package’s only construction method, takingSymfony\Component\HttpClient\AmpHttpClient’s own constructor parameters.$clientConfiguratordefaults to one handing the pooled delegate back untouched, so one request is one wire attempt and no interceptor repeats it below the caller: whatHttpis built on, so its own retry loop is the only one in the stack, and what every Kinetis integration handed this transport runs on. A supplied configurator builds the delegate instead, and owns whatever it installs.Depends on
symfony/http-client(^8.0— the first version whoseAmpHttpClienttargets the current, Revolt-basedamphp/http-clientgeneration rather than the old pre-Fiber one),symfony/http-client-contracts,amphp/http-client(^5.3, an optional peer dependency ofsymfony/http-clientthat isn’t auto-installed, so declared directly), andrevolt/event-loop(used directly to await a read and to wait out a retry backoff, so declared rather than taken transitively). Owncomposer.json/phpunit.xml/phpstan.neon.
packages/aws-sigv4 (kinetis/aws-sigv4)¶
Separate Composer package, not part of kinetis/framework core — and,
like kinetis/revolt-http-client, not dependent on it either:
kinetis/framework appears only in require-dev.
Kinetis\AwsSigV4\SigV4SigningClient implements Psr\Http\Client\ClientInterface— the package’s main class.__construct(string $origin, string $region, string $service, ?CredentialProvider $credentialProvider = null, ?DateTimeImmutable $now = null, ?Kinetis\AwsSigV4\SignedTransport $transport = null). Owns the target, the credentials, the body and the failure boundary: normalizes and origin-checks the target, reads the request body once from its current position through EOF, resolves credentials, builds the outgoing request itself from the trusted origin and a Nyholm stream over those bytes, hands it toSignature, and sends it.$nowexists solely for testability (sendRequest()’s signature is fixed by the PSR-18 interface, so there’s nowhere else to thread a fixed clock through) — real usage leaves itnull. See AWS request signing (SigV4) for the normative statement of origin, target normalization, canonical form, credential, redirect, failure, and buffering behavior.Kinetis\AwsSigV4\Signature—@internal. AWS Signature Version 4 over a request already in wire form: canonical request, string to sign, derived signing key, and theAuthorizationheader. Owns and overwritesHost(from the URI’s own authority),X-Amz-Date,AuthorizationandX-Amz-Security-Token, and computes the payload hash from the buffered body bytes rather than from any caller header.Kinetis\AwsSigV4\CredentialChain implements AsyncAws\Core\Credentials\CredentialProvider—@internal. AsyncAws’s five providers in AsyncAws’s order, each network-capable one holding the injectedSignedTransport. Holds the first unexpired credentials a lookup resolves and reuses them until they expire; passes over a null or expired answer and continues the same lookup down the chain; holds nothing when a lookup resolves nothing, so a transient ECS/IMDS/token-file failure costs one lookup rather than the worker’s remaining lifetime.Kinetis\AwsSigV4\SignedTransport implements Symfony\Contracts\HttpClient\HttpClientInterface— the only transport a signed request and the default credential chain travel on.create(array $defaultOptions = []): selfbuilds a SymfonyAmpHttpClientover a bareAmp\Http\Client\PooledHttpClient, pinning a client configurator that installs no AMPHP interceptor;request()forwards one delegate call per request withmax_redirects => 0written onto the request itself. The constructor is private andcreate()takes default options only, so no client and no configurator of a caller’s own reaches underneath a signature.answeredInProcess(callable $responder): selfis the testing seam, answering from a function on the calling thread with no connection opened. See AWS request signing (SigV4) for the normative statement of what one send guarantees and why the transport is owned rather than configured.Kinetis\AwsSigV4\TrustedOrigin—@internal. The parsed$origin, and the one place its grammar, its canonical form for comparison, its path joining, and its base-path containment check live. Package-owned rather thanparse_url()-based:parse_url()accepts hosts and ports no HTTP origin may take and reports nothing about the components it discards.Kinetis\AwsSigV4\WireTarget—@internal. The one rule turning a request target into the exact representation that leaves the process: unreserved percent escapes decoded, every other escape kept in uppercase hex, characters outside the safe set encoded, repeated/collapsed,./..segments removed after decoding, an empty path written as/. Applied before the origin and base-path checks and before signing, so an HTTP client resolving/a/../bor decoding/%7Efooafterward has nothing left to change and the signature covers the bytes that go out.Kinetis\AwsSigV4\Exception\—SigningExceptionfor a rejected$origin/$region/$serviceat construction;ClientFailureExceptionfor what every per-request failure shares, split under it by PSR-18 category intoUntrustedOriginException,UnsignableRequestExceptionandTransportFailureException(viaRequestFailureException, PSR-18’sRequestExceptionInterface) andNetworkFailureException(NetworkExceptionInterface). None of them is serializable: a stack trace holds#[SensitiveParameter]arguments and PHP refuses to serialize aSensitiveParameterValue.The signature itself is held to AWS’s published SigV4 test vectors — a fixed date and the static
AKIDEXAMPLEcredentials — run end to end through the client, each publishedAuthorizationheader asserted exactly. That is what makes the canonical form above a checked claim rather than a described one.Depends on
kinetis/revolt-http-client,async-aws/core,nyholm/psr7(the PSR-17 factories the internalPsr18Clientis built with, and the PSR-7 request and stream the outgoing request is built from),psr/http-client,psr/http-message,symfony/http-client,symfony/http-client-contracts, andamphp/http-client(named directly:SignedTransportpins the AMPHP client its own requests run through).async-aws/coresupplies the credential provider interface, types and providers only; the signing algorithm is this package’s own. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/storage-s3 (kinetis/storage-s3)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\StorageS3\S3FilesystemFactory::fromConfig(Config $config, string $connection = 'default'): League\Flysystem\Filesystem— buildsKinetis\StorageS3\S3ClientwithKinetis\RevoltHttpClient\AmpHttpClientFactory::create()injected as its transport,Kinetis\StorageS3\DeclaredContentLengthon that transport’s connection pool so awriteStream()body keeps theContent-LengthAsyncAws declared, wraps it inKinetis\StorageS3\S3Adapterwith private visibility,ContentTypeas the only forwarded option andretain_visibilityoff.FILESYSTEM_S3_BUCKET/FILESYSTEM_S3_REGIONrequired;FILESYSTEM_S3_PREFIX/FILESYSTEM_S3_ENDPOINT/FILESYSTEM_S3_PLAINTEXT/FILESYSTEM_S3_TIMEOUToptional, all viaConfig::scopedKey(). An explicit endpoint is validated down to one origin and addressed path-style; without one, an ambientAWS_ENDPOINT_URLis refused. Credentials are never read fromKinetis\Config: the factory composes AsyncAws’s standard providers in its standard order behindKinetis\StorageS3\CredentialChain, handing that same transport to every provider in it, including the one that assumes anAWS_ROLE_ARNrole through STS. See Storage (S3) for what stays blocking.Kinetis\StorageS3\CredentialChain implements AsyncAws\Core\Credentials\CredentialProvider— the providers above, consulted in order until one answers with credentials whoseisExpired()is false; an expired answer is skipped for the next provider in the same call, like a null one, and a round with no usable answer returns null. Holds only what it returns, so no clock is read here and nothing records a provider or a round as having failed — a role or token file appearing later is picked up on the next call.Kinetis\StorageS3\S3Client extends AsyncAws\S3\S3Client— drops the adapter’s defaultprivateACL fromputObject/copyObjectand refuses any other, resolves those two anddeleteObjectsinstead of leaving the result to a destructor, and rejects a copy with no ETag or a batch delete with per-key errors, which S3 reports under HTTP 200.Kinetis\StorageS3\S3Adapter extends League\Flysystem\AsyncAwsS3\AsyncAwsS3Adapter— replacesdeleteDirectory()and nothing else: one listing page of at most 1,000 keys is deleted before the next is requested by continuation token, and anUnableToDeleteDirectoryreason says whether a batch was confirmed complete, without ruling out a partial delete either way. See Storage (S3).kinetis/storage’s ownKinetis\Storage\FilesystemFactorydispatches to this package forFILESYSTEM_DRIVER=s3,class_exists()-gated; throwsKinetis\Storage\Exception\StorageUnavailableExceptionnaming this package when it isn’t installed.Depends on
kinetis/frameworkandkinetis/revolt-http-client(both viapathrepositories),async-aws/core(the credential chain),async-aws/s3,league/flysystem-async-aws-s3,amphp/http-client(DeclaredContentLength). Owncomposer.json/phpunit.xml/phpstan.neon.
packages/mailer (kinetis/mailer)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\Mailer\PackageBootstrap— declared viaextra.kinetis; withMAILER_DSNset, builds the mailer and binds the instance underSymfony\Component\Mailer\MailerInterface, so a malformed DSN, an unusableMAILER_TIMEOUT, or a missing bridge package fails at registration rather than on the first send. Inert whenMAILER_DSNis unset. The application’s ownbootstrap.phpruns after this and still replaces the binding.Kinetis\Mailer\MailerFactory::fromConfig(Config $config, string $connection = 'default'): Symfony\Component\Mailer\MailerInterface— readsMAILER_DSNandMAILER_TIMEOUT(Config::scopedKey()for named connections) and always passesKinetis\RevoltHttpClient\AmpHttpClientFactory::create()intoSymfony\Component\Mailer\Transport::fromDsn()as itsHttpClientInterface.MAILER_TIMEOUTdefaults to30.0seconds and becomes the client’stimeoutandmax_duration, withmax_redirectsat0; a zero or negative value throwsInvalidArgumentExceptionnaming the scoped key. Non-blocking for any API-based transport (Sendgrid, Mailgun, Postmark, SES, …) it resolves to;EsmtpTransport(SMTP) ignores the injected client and its options and opens a blocking socket. Its socket inherits PHP’sdefault_socket_timeoutfor the connection and each blocking stream operation, which is an inactivity bound rather than a total-send deadline — a disclosed limit, not a bug.No Kinetis-owned
MailerInterface—Symfony\Component\Mailer\MailerInterfaceis used directly, the same “don’t wrap an already-right abstraction” reasoningkinetis/storagealready applies toLeague\Flysystem\FilesystemOperator.Transport::fromDsn()discovers whichever bridge package (symfony/sendgrid-mailer,symfony/mailgun-mailer, …) is installed via its ownclass_exists()-gated factory list —MailerFactoryhas no dispatch logic of its own.Mail is queueable with zero code in this package: a
kinetis/queueJob’s ownhandle()method constructor-injectsMailerInterfaceexactly like any other service, resolved through the same containerQueueWorker/SyncQueuealready autowire against.Depends on
kinetis/frameworkandkinetis/revolt-http-client(both viapathrepositories),symfony/mailer;symfony/sendgrid-maileris a dev dependency, used by the tests as a real API bridge. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/broadcasting (kinetis/broadcasting)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\Broadcasting\BroadcasterInterface(broadcast(string $channel, string $event, array $payload): void) is the one driver contract;Kinetis\Broadcasting\NullBroadcasteris the always-present default (a silent no-op);Kinetis\Broadcasting\Driver\PusherBroadcasteris the one real driver, speaking the Pusher Channels wire protocol — Soketi/Reverb/Pusher itself all implement it identically, so this one driver covers all three. Built onKinetis\RevoltHttpClient\Http, sobroadcast()suspends the calling Fiber rather than blocking;BROADCAST_TIMEOUT(defaultHttp::DEFAULT_TIMEOUT_SECONDS, 30.0) is the whole budget for one trigger, applied byPusherBroadcaster::fromConfig()throughHttp::withTimeout()and therefore owned by the factory even for an injected client. A trigger returning means the broker answered 2xx, not that a subscriber received the event. A channel name outside the Pusher grammar raisesKinetis\Broadcasting\Exception\InvalidPusherProtocolValueException—PusherBroadcasterholds a trigger to the sameKinetis\Broadcasting\PusherProtocolgrammar its signing methods enforce — and an unencodable payload raisesJsonException, both before a request; an attempted request that does not produce 2xx raisesKinetis\RevoltHttpClient\Exception\HttpRequestException— Broadcasting’s “What a trigger outcome means” states which categories leave the outcome unknown.Kinetis\Broadcasting\Broadcasteris the service application code constructor-injects — autowires with nothing to register.Kinetis\Broadcasting\ShouldBroadcast(broadcastOn(): list<string>,broadcastAs(): string,broadcastWith(): array) describes how an event class broadcasts, read byBroadcaster::event(). Not wired intoKinetis\Events\EventDispatcherautomatically — unlikeKinetis\Events\ShouldQueue(checked per listener, inside a dispatch loop that already exists), broadcasting is a per-event concern with no natural hook in that loop; callBroadcaster::event()explicitly.Kinetis\Broadcasting\Attributes\BroadcastChannel(TARGET_METHOD, one string$pattern) marks a private/presence channel authorization callback, discovered byKinetis\Broadcasting\BroadcastChannelRegistry/BroadcastChannelDiscovery— the same “reflect a class for an attribute” shapeKinetis\Events\Listener/EventListenerRegistryalready are, mirroringMcpDiscovery’s exact three-source scan (project PSR-4 roots, the framework’s ownBroadcastingsegment, every installed package’s declaredextra.kinetisscan roots) with the identical cross-pass$seendedup every other Discovery class in this project carries. A pattern is dot-separated segments, each either one literal or exactly one whole{name}placeholder, with distinct placeholder names; a method’s signature — an optional leadingCurrentUserInterfaceparameter, then onestringparameter per placeholder, named and ordered to match — is validated atregister()time, throwingKinetis\Broadcasting\Exception\InvalidChannelAuthorizerExceptionimmediately rather than at the first real request, the same disciplineEventListenerRegistry::register()already applies to a malformed#[Listener]. Registration is append-only and every overlap is a conflict: two patterns of the same segment count whose every position is an equal literal pair or holds a placeholder are rejected against each other, in either arrival order, so at most one authorizer claims a channel name andmatch()carries no precedence or ordering.BroadcastChannelRegistryitself implementsKinetis\Cache\CacheableDiscoveryInterface— declared as this package’s ownextra.kinetisdiscoveryclass, so it’s part of the shared AOT cache (see Caching & AOT Compilation), not a separate mechanism. An artifact entry carriespattern/class/method/usesCurrentUserand nothing else;fromArray()checks those exact fields viaKinetis\Cache\Exception\ArtifactValidation, rebuilds each definition frompatternalone, and runs it through the same conflict checkregister()uses, throwingKinetis\Cache\Exception\InvalidCacheArtifactException— the classified exceptionCacheableDiscoveryInterface::fromArray()’s own contract requires, and the oneBootSequencerecompiles on — for anything missing, extra, wrong-typed, malformed, or overlapping.Kinetis\Broadcasting\Http\BroadcastAuthController(#[Post('/broadcasting/auth')]) is the endpoint a Pusher-protocol client library calls automatically before subscribing to aprivate-*/presence-*channel — discovered as an ordinary route via this package’s ownextra.kinetis.scan, never hand-registered. Constructor-injects the boundBroadcasterInterface— the one idPackageBootstrapbinds — so the route resolves under everyBROADCAST_DRIVER. Signing an authorization response is Pusher-protocol-specific, so the endpoint requires the bound broadcaster to bePusherBroadcasterand throwsKinetis\Broadcasting\Exception\BroadcastingException::authNotSupported(), naming the bound driver class, for any other. Carries#[Middleware('@broadcasting')], and depends on neither auth package; Broadcasting’s “Securing the endpoint” states what that group admits and what a channel authorizer requires.Http\BroadcastOriginMiddleware— the group’s one permanent member, at priority 100, which is also what guarantees the group the controller references always exists. Passes a request carrying noOriginheader, one carrying the request URI’s ownscheme://authority, or one listed exactly inBROADCAST_ALLOWED_ORIGINS(comma-separated, empty by default, not connection-scoped); anything else is403before the rest of the group or the controller runs. Not a CORS policy — a cross-origin browser request must also be admitted by the application’s globalCorsMiddleware. Same-origin without configuration is the difference fromkinetis/mcp’sMcpOriginMiddleware, whose spec-mandated default rejects everyOrigin— a browser posting this endpoint from the page it was served by is the ordinary case here.Kinetis\Broadcasting\PackageBootstrap— declared viaextra.kinetis;BROADCAST_DRIVER(default"null") selects and eagerly builds the boundBroadcasterInterfaceat worker boot (not lazily, unlikekinetis/session’s own driver bindings — nothing here depends on a sibling package’s bootstrap having run first, so a misconfiguredBROADCAST_DRIVER=pusherfails before the first request).BroadcastChannelRegistryis not bound here at all — the framework itself binds it, before this method ever runs, viaextra.kinetis’sdiscoverykey.PusherBroadcaster’s signing algorithm — trigger-request signing (auth_key/auth_timestamp/auth_version/body_md5, lexically sorted, HMAC-SHA256 over"{method}\n{path}\n{params}") and channel authorization ("{socketId}:{channelName}", or"{socketId}:{channelName}:{channelDataJson}"for presence) — is held topusher/pusher-php-server’s own source, and exercised against a real Soketi broker and a real WebSocket client: a public broadcast delivered, a private-channel subscription signed by this driver accepted with its triggered event delivered, and a presence-channel subscription signed by the realBroadcastAuthControlleraccepted with the correctchannel_data.Depends on
kinetis/frameworkandkinetis/revolt-http-client(both viapathrepositories),psr/http-message. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/search (kinetis/search)¶
Separate Composer package, not part of kinetis/framework core. Both
engine packages depend on it; neither engine’s library is a dependency
of it.
Kinetis\Search\SearchTransport::fromConfig(Config $config, string $prefix, string $connection = 'default', ?Closure $decorator = null): self— the one origin an engine client talks to and the PSR-18 client it talks over, as a readonly{origin, client}pair.$prefixis the engine’s configuration prefix without its trailing underscore (SEARCH_OPENSEARCH,SEARCH_ELASTICSEARCH), so both engines read the same policy under their own keys.originexists becauseElastic\Elasticsearch\ClientBuilderneedssetHosts()whileOpenSearch\TransportFactorydoes not.SEARCH_..._HOSTis exactly onehttp(s)://host[:port]origin — userinfo, a path (the key names one root origin), a query and a fragment are refused, and the accepted parts are rebuilt as a lowercase origin.SEARCH_..._PLAINTEXT(defaultfalse) gates anhttporigin;SEARCH_..._TIMEOUT(default30.0, positive) is bothtimeoutandmax_duration;SEARCH_..._MAX_RESPONSE_BYTES(default8388608, positive) is enforced by anon_progressguard. Transport options also carrymax_redirects => 0.SEARCH_..._USERNAME/SEARCH_..._PASSWORD(Basic auth) andSEARCH_..._VERIFY_PEER(defaulttrue) are optional, all viaConfig::scopedKey()for named connections.Kinetis\Search\BufferedHttpClient— the PSR-18 client over a SymfonyHttpClientInterface. Reads the status,getHeaders(false)andgetContent(false)before returning a bufferedNyholm\Psr7\Response, so a body-phase transport failure and the response bound land insidesendRequest()and inside any decorator around it. Status, body and headers pass through unchanged, leaving every 4xx/5xx mapping to the engine client and carrying Elasticsearch’sX-Elastic-Productcheck and theContent-Typeits response objects deserialize by; only a SymfonyTransportExceptionInterfaceis caught. It also replaces the request’sAccept-Encodingwithidentity, case-insensitively: the response bound counts wire bytes, andClientBuilderasks for gzip on an Elastic Cloud host unless it recognizes the client class as Symfony’s own.Kinetis\Search\SearchClient— the engine-neutral contract:index(),get(),delete(),search(),bulk(). Raw envelopes in and out, absence asnull/falserather than an exception, and a search body passed through untouched. Each engine package binds an implementation beside its own unwrapped client.Kinetis\Search\AbstractSearchClient— the engine-independent half of that contract, and what both engine adapters extend: the parameter array every call is made with (index,id,body,refreshonly when it is asked for, and aWriteCondition’s own parameters onindex()anddelete()), and the rule that a404answeringget()ordelete()is an absence while every other status stays aSearchRequestException. Its one abstract member,send(SearchCall $call, array $params): array, is the engine’s own half — the call dispatched through that engine’s client, and that engine’s failures mapped.Kinetis\Search\SearchCallnames which of the five calls is being made. The class is readonly, so an adapter is too.Kinetis\Search\BulkOperation—index(),create(),update()anddelete()static constructors, andlines(), the one or two body lines both engines’ bulk endpoints read. Building those here is what makes a bulk batch engine-neutral.index()anddelete()take a trailing?WriteCondition $condition = nullwhose parameters join that operation’s action metadata;create()andupdate()take none.Kinetis\Search\WriteCondition— the precondition parameters both engines evaluate for the named document before applying a write, as a final immutable value with aparametersmap and three named constructors:external(int $version)andexternalOrEqual(int $version)serializeversionwithversion_typeexternalorexternal_gte, andifUnchanged(int $sequenceNumber, int $primaryTerm)serializesif_seq_nowithif_primary_term. An external version creates a document that is not there yet;ifUnchanged()requires an existing one. Versions and sequence numbers are zero or greater, primary terms one or greater, and anything else is anInvalidArgumentException. A conditionalindex(), direct or bulk, requires an explicit id. Refusals keep the existing vocabulary —SearchRequestExceptioncarrying409directly, a per-item409in bulk — and neither client retries one. Conditional writes has the correctness properties of each form.Kinetis\Search\Exception\SearchConfigurationException(unusable configuration, refused while the client is built),SearchNetworkException(PSR-18NetworkExceptionInterface, retaining the request),SearchRequestException(an error status aSearchClientcall met, carryingstatusand the engine’s own exception) andSearchResponseTooLargeException(the response bound’s own abort, which reaches a caller under aSearchNetworkExceptionrather than on its own) are the whole failure surface. Configuration reasons name the scoped key, never its value.Depends on
kinetis/frameworkandkinetis/revolt-http-client(both viapathrepositories),psr/http-client,psr/http-message,symfony/http-client-contracts,nyholm/psr7. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/search-opensearch (kinetis/search-opensearch)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\SearchOpenSearch\PackageBootstrap— declared viaextra.kinetis; withSEARCH_OPENSEARCH_HOSTset, builds the client and binds the one instance asOpenSearch\Client— shared for the worker, whichHttpTransportkeeping nothing between calls andEndpointFactorybuilding a fresh endpoint per call is what makes safe — plusKinetis\Search\SearchClientasOpenSearchClientover it, so unusable configuration fails while registering rather than on the first search; construction opens no connection and the application’s ownbootstrap.phpcan replace either binding. Inert when the key is unset; the concrete client is the binding id because opensearch-php exposes no interface for it. Installing both engine packages leaves them competing for theSearchClientid — the application binds it itself to decide.Kinetis\SearchOpenSearch\OpenSearchClientFactory::fromConfig(Config $config, string $connection = 'default', ?Closure $transportDecorator = null): OpenSearch\Client— builds the client throughOpenSearch\TransportFactory::setHttpClient()(a real PSR-18 injection point, part of the library’s own non-deprecated construction path — the olderClientBuilder/Transport/ConnectionPoolstack is deprecated since 2.4.0 and has no such injection point) withSearchTransport’s client.$transportDecorator(Closure(ClientInterface): ClientInterface) wraps that fully-configured adapter right beforeTransportFactoryreceives it — the seamkinetis/telemetry’sTracingSearchTransportcomposes through, without duplicating the transport’s own config-reading logic.CONFIG_PREFIXisSEARCH_OPENSEARCH.Kinetis\SearchOpenSearch\OpenSearchClient—AbstractSearchClientover the official client: eachSearchCallgoes to the client method of the same name, andOpenSearch\Exception\HttpExceptionInterfacebecomes aSearchRequestExceptioncarrying its status, which the shared half reads asnullfromget()andfalsefromdelete()for a404. Response bodies are the cluster’s own, untouched.The transport’s JSON
Content-Typedefault exists for this engine: OpenSearch’s own request building never sets one, relying on the HTTP client to default a string body to JSON, while Symfony’s clients default an unmarked string body toapplication/x-www-form-urlencoded, which a node answers with406. No OpenSearch request replaces that default, so a_bulkbody travels as NDJSON lines underapplication/json, which the engine’s bulk handler accepts and this package’s real-cluster checks exercise.Depends on
kinetis/frameworkandkinetis/search(both viapathrepositories),opensearch-project/opensearch-php,psr/http-client.kinetis/revolt-http-clientis a dev dependency only: the transport iskinetis/search’s to own and reaches an install transitively, and nothing in this package’s source names it. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/search-elasticsearch (kinetis/search-elasticsearch)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\SearchElasticsearch\PackageBootstrap— declared viaextra.kinetis; withSEARCH_ELASTICSEARCH_HOSTset, builds theSearchTransportonce and bindsElastic\Elasticsearch\ClientandKinetis\Search\SearchClientas non-shared bindings over it, so each resolution gets its own client.Elastic\Transport\Transportretains$lastRequest/$lastResponseandClient::setAsync()is a mutable mode, so a worker-lifetime client would hold one request’s documents and results until the next search displaced them; the transport underneath owns the pool, keeps nothing per call, and is the part that is shared. No client is built at registration;ElasticsearchClientFactory::assertUsableCredentials()is what makes a conflicting credential pair fail there. The concrete client is the binding id becauseElastic\Elasticsearch\ClientInterfacecarries only the transport and mode accessors — none ofsearch(),index()orget(), which the finalClientpicks up from its endpoint traits. Inert when the key is unset; same registration-time failure and same application override as the OpenSearch package.Kinetis\SearchElasticsearch\ElasticsearchClientFactory::fromConfig(Config $config, string $connection = 'default', ?Closure $transportDecorator = null): Elastic\Elasticsearch\Client— builds the client throughClientBuilder::setHosts()/setNodePool()/setHttpClient()overSearchTransport’s origin and client.::over(SearchTransport $transport, Config $config, string $connection = 'default')is the same assembly over a transport that already exists, which is how the bootstrap gives many clients one pool.CONFIG_PREFIXisSEARCH_ELASTICSEARCH. Acceptselasticsearch/elasticsearch^8.19 || ^9.0; the client major must match the cluster’s, since a 9.x client sendscompatible-with=9.Retries are pinned to
0on the built transport, not throughClientBuilder::setRetries(), which cannot express zero:build()replaces the value with the host count wheneverempty()holds for it. Zero is required becauseElastic\Transport\Transportcatches PSR-18’sNetworkExceptionInterfaceand re-sends the request, which would replay anindexorbulkwhose dispatch outcome is unknown. A request that never completed therefore surfaces asElastic\Transport\Exception\NoNodeAvailableExceptionwrappingSearchNetworkException.Kinetis\SearchElasticsearch\SingleNode— aNodePoolInterfaceanswering the one configured origin with no liveness state.SimpleNodePool’s defaultNoResurrectstrategy would mark this client’s only node dead on the first network failure and never revive it, ending every later request through that client, which for one built outside the container and kept is the rest of the worker’s life; keeping the node in service also lets the transport report the exception that actually happened rather than “no alive nodes”.ClientBuilder::setBasicAuthentication()is never called: it reachesTransport::setUserInfo(), which puts credentials into the request URI’s userinfo where a transport error can quote them. Basic credentials stay in the HTTP client’sauth_basicoption.SEARCH_ELASTICSEARCH_API_KEY(with optionalSEARCH_ELASTICSEARCH_API_KEY_ID) travels as anAuthorization: ApiKeyheader viasetApiKey(); configuring it alongsideSEARCH_ELASTICSEARCH_USERNAMEraises aSearchConfigurationExceptionrather than leaving header precedence to pick a credential. NosetSSL*()/setCABundle()call is made —ClientBuilder::setOptions()throws for an HTTP client class it does not recognize, and TLS isSEARCH_ELASTICSEARCH_VERIFY_PEER’s.Kinetis\SearchElasticsearch\ElasticsearchClient—AbstractSearchClientover the official client: eachSearchCallgoes to the client method of the same name, itsResponse\Elasticsearchobjects are read withasArray(), andClientResponseException/ServerResponseExceptionbecome aSearchRequestExceptioncarrying the status both keep as their code, which the shared half reads asnullfromget()andfalsefromdelete()for a404.Depends on
kinetis/frameworkandkinetis/search(both viapathrepositories),elastic/transport(named directly:SingleNodeimplements itsNodePoolInterfaceandElasticsearchClientmaps itsNoNodeAvailableException),elasticsearch/elasticsearch,psr/container,psr/http-client.kinetis/revolt-http-clientis a dev dependency only, for the reason the OpenSearch package’s entry gives. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/telemetry (kinetis/telemetry)¶
Separate Composer package, not part of kinetis/framework core.
Participates in extra.kinetis through a PackageBootstrap.
Every decorator and hook below routes an operation’s own inputs through
one @internal policy class, Kinetis\Telemetry\Redaction, with no
configuration switch to bypass it: a fingerprint (scoped to a
FingerprintDomain, so identical bytes in two contexts never share a
digest), a count, a fixed-vocabulary descriptor, or the scheme, host
and port naming which service was addressed crosses into a span, and
the statement, key, path, URL userinfo, query string or fragment,
session id, exception message or stack trace it stands for does not.
Telemetry’s “What never reaches a span” states the rule and
carries the table; the entries below name only what each decorator puts
on its spans.
Kinetis\Telemetry\PackageBootstrap— bindsOpenTelemetry\API\Trace\TracerProviderInterfaceonAppScope: the OTLP-exporting provider whenOTEL_EXPORTER_OTLP_ENDPOINTis set, aNoopTracerProviderotherwise. Leaves OTel’s default Fiber-bound context storage in place, so a scope belongs to the Fiber that attached it. Registers the provider’sshutdown()viaregister_shutdown_function— request end under boot-and-die, worker exit under a persistent runtime, so both shapes flush.Kinetis\Telemetry\TracerFactory::fromConfig(Config): ?TracerProvider— aBatchSpanProcessorover the OTLP/HTTP exporter, whose transport isSymfony\Component\HttpClient\Psr18ClientwrappingAmpHttpClientFactory::create()withmax_redirects0 andtimeoutandmax_durationboth 10.0, and the transport is created withmaxRetries: 0— so each export request suspends rather than blocks and is one bounded wire attempt that is never replayed; see Appendix: Observability Reference.nullwhen no endpoint is configured.Kinetis\Telemetry\HttpClient\TracingHttpClient/TracingResponse— a client span per outgoing request, carryinghttp.request.methodfrom the method vocabulary,url.scheme/server.address/server.portandkinetis.http.url_fingerprint— never the URL’s userinfo, path, query string or fragment, each of which routinely holds a credential or an identifier, while$inneris handed the URL and the method exactly as the caller wrote them. A staletraceparent/tracestateon theheadersoption is replaced, so the injected carrier reaches$innerexactly once; every unrelated entry of that iterable arrives with its own value and its own position, neither regrouped nor repaired here. The span ends when the response is consumed —getContent()/toArray(), an error,cancel(), or destruct as the safety net — never whenrequest()returns, since requests through this transport complete later by design.stream()unwraps to the inner client’s own responses (Symfony clients only stream responses they created), so stream consumers get destruct-time span timing.Kinetis\Telemetry\Logging\TraceAwareLogger— PSR-3 decorator addingtrace_id/span_idto entry context when a span is recording; caller-supplied keys win.Kinetis\Telemetry\SimpleCache\TracingSimpleCache— wraps any PSR-16CacheInterface. A client span per method (get/set/delete/has/clear/getMultiple/setMultiple/deleteMultiple), named by the operation,db.system.name: redis, akinetis.cache.key_fingerprintover the operation’s ordered key list anddb.operation.batch.sizeon the three multi-key methods. Neither the keys nor the values are recorded: a key is built from whatever identifies the cached thing, so it is as sensitive as the value.Kinetis\Telemetry\Session\TracingSessionStore— wraps anySessionStoreInterface. A span perread/create/update/destroy; the session id never travels verbatim (it’s a bearer credential), only its fingerprint askinetis.session.id_fingerprint. The payload is never recorded.Kinetis\Telemetry\Search\TracingSearchTransport— wraps any PSR-18ClientInterface, meant for either engine factory’s$transportDecoratorparameter. A client span per call, named from the request’s method and the action its path performs (POST _search,GET _doc) rather than parsing the query DSL, both drawn from fixed vocabularies; a path naming no listed action givesrequest. Carriesdb.system.namefrom theSearchSystemcase it was constructed with (opensearchorelasticsearch),db.operation.name(the action) andkinetis.search.path_fingerprint— index names, aliases and document ids identify the records a call touched, so no segment of the path names a span or reaches an attribute. PSR-18’ssendRequest()always returns a complete response, so unlikeTracingHttpClientthere’s no deferred span lifecycle to manage.Kinetis\Telemetry\Instrumentation\OtelTelemetry— implements core’sKinetis\Instrumentation\TelemetryInterface, turning the framework’s hooks into spans;PackageBootstrapswaps it intoTelemetry::global()whenever the OTLP endpoint is configured.requestStarted()/requestEnded()produce the server span per request, aroundKernel::handle()’s complete global pipeline: the method as the span name,http.response.status_code,php.memory.usage, error status on 5xx or an escaping exception,traceparentextraction for distributed traces. No form of the request target travels on it — a path’s segments are the identifiers a request is addressed by. The span is active until the response leaves the pipeline — the parent for everything below — and a streamed body is emitted after it ends. It is the whole of Kinetis-owned SQL, transaction and queue tracing: a client span per query named by the statement’s opening keyword and carryingdb.system.name,db.operation.nameandkinetis.db.query_fingerprint, with aserver.startedevent marking the end of the wait for a pooled connection; atransactionspan carryingdb.transaction.outcome; a{queue} publishproducer span and a{queue} processconsumer span carryingmessaging.destination.name,kinetis.job.class,kinetis.job.attemptandkinetis.job.outcome. A hook pair ends by recording the failure’s type alone. Itsroute.matchspan carries the method and, once the router answers, the matched template ashttp.route— never the request target the hook is handed. The MCP tool name and resource URI it does export are registry-resolved definitions rather than caller-supplied text. Which hooks activate their span (parenting whatever starts next) is the load-bearing choice. The nested, same-Fiber pairs activate on the context their Fiber already carries — middleware, controller, event/listener, theconcurrently()batch, MCP tool calls.requestStarted(),taskStarted()andjobStarted()activate on a parent context they name themselves — the propagated or root context for a request or a job, the batch span reached through the batch tokentaskStarted()is handed — because each begins on a Fiber that may carry no context of its own. Query spans never activate: they overlap within one Fiber, and activating them would interleave that Fiber’s own stack.jobPushMetadata()injects atraceparentcarrier the backend stores with the job;jobStarted()extracts it, parenting the consumer span into the producer’s trace — one trace across processes. A failure in any of its own methods — an unreachable collector, a bad export — never reaches application code: core’sKinetis\Instrumentation\Telemetryis the no-throw boundary every hook call goes through before this backend is ever invoked, soOtelTelemetryis free to let a real export error propagate rather than swallowing it itself. See Appendix: System Layout’sKinetis\Instrumentationentry.Depends on
kinetis/framework,kinetis/revolt-http-client,open-telemetry/sdk,open-telemetry/exporter-otlp,symfony/http-client,nyholm/psr7,psr/log;kinetis/persistence/kinetis/cache-redis/kinetis/sessiononly inrequire-dev— every decorator class loads lazily, so none of them is forced on an install that only wants request spans, and the search decorator needs no engine package at all, only PSR-18. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/auth (kinetis/auth)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\Auth\BearerAuthMiddleware— PSR-15 route middleware (never global) validating anAuthorization: Bearer <token>header (parsed by core’sKinetis\Http\Auth\AuthorizationToken68Parserwith theBearerscheme — see Appendix: System Layout) against an app-suppliedUserProviderInterface, registering the resolved user on the currentRequestScopeasCurrentUserInterfaceon success, or returning401with aWWW-Authenticate: Bearerheader on failure — the same generic401whether the header itself failed to parse or a well-formed token was rejected by the provider. Resolved fresh per request from the route’s ownRequestScope, so it constructor-injectsRequestScopedirectly.Kinetis\Auth\UserProviderInterface— one method,findByToken(string $token): ?CurrentUserInterface. Storage-agnostic; the app implements it.Kinetis\Auth\TokenGenerator—generate(int $bytes = 32): string, arandom_bytes()wrapper, hex-encoded.Depends on
kinetis/framework(via apathrepository to this monorepo’s root),nyholm/psr7(BearerAuthMiddleware’s401response),psr/http-message,psr/http-server-middleware. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/auth-jwt (kinetis/auth-jwt)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\AuthJwt\JwtAuthenticator— the request-neutral authentication decision,authenticate(string $token): ?JwtUser; constructed with aJwtVerificationKeysplus optional$revocationStore,$expectedIssuer,$acceptedAudiences, all validated there. Immutable and safe forAppScopelifetime — token, claims and the returnedJwtUserstay method-local. Every unusable credential answers the samenull; a revocation lookup that throws propagates instead. Validation, security, limits, and failure rules: Appendix: Authentication.Kinetis\AuthJwt\JwtAuthMiddleware— PSR-15 route middleware (never global) parsing anAuthorization: Bearer <token>header, handing the credential to theJwtAuthenticatorit is constructed with, and registering the returnedJwtUserunder bothCurrentUserInterface::classandJwtUser::class, or answering401with aWWW-Authenticate: Bearerheader. Its other constructor parameter is theRequestScope, so an application registers oneJwtAuthenticatoronAppScopeand references#[Middleware(JwtAuthMiddleware::class)]directly. Notfinal, so an otherwise empty subclass can carry a class-level middleware-group attribute.Kinetis\AuthJwt\JwtUser— wraps the decoded claims (stdClass).id(): stringreadssub, narrowingCurrentUserInterface’sstring|intto the canonical subject string and throwing if the claim is anything else;claim(string)/claims()expose the rest.Kinetis\AuthJwt\JwtIssuer— signs the tokensJwtAuthenticatorverifies, throughissue(string|int $subject, array $claims = [], ?int $ttlSeconds = 3600): string, which converts$subjectto the canonical non-empty subject string once and rejects an empty one, from a constructor taking aJwtSigningKeyplus optional$issuerand$audience. Which claims outrank a same-named$claimsentry, and everyException\JwtIssuerException: Appendix: Authentication.Kinetis\AuthJwt\JwtSigningKey— the immutable signing configuration, built byhmacSecret(string $secret, string $algorithm = 'HS256', ?string $kid = null)orrsaPrivateKey(string $privateKeyPem, string $algorithm = 'RS256', ?string $kid = null), each validating its algorithm, key material and kid on construction and throwingException\JwtConfigurationExceptionotherwise. The key material never leaves the value:sign(array $payload): stringis the only reader.Kinetis\AuthJwt\JwtVerificationKeys— the immutable verification configuration, built byhmacSecret(string $secret, string $algorithm = 'HS256'),rsaPublicKey(string $publicKeyPem, string $algorithm = 'RS256'), orjwks(string $jwksJson)for the multi-key form, validated the same way.requiresKid(): boolstates whether a token must carry akidthis value knows;decode(string $token, ?string $kid): ?stdClassresolves that kid to one pinned key and returns the verified claims, ornullfor every unusable token.Kinetis\AuthJwt\JwtKeyValidator— the shared validator every other class here defers to, so the supported algorithms, the key-material rules, and the rule for what may name a key exist once. Public constantsSUPPORTED_ALGORITHMS,RSA_MINIMUM_BITSandMAXIMUM_KID_LENGTH; predicatesisHmacAlgorithm(),isRsaAlgorithm(),isUsableKid(string)and itsisUsableKidValue(mixed)form, for a kid read out of a decoded document or an array key and so not yet known to be a string; assertionsassertUsableKid(),assertHmacSecret(),assertRsaPublicKey()andassertRsaPrivateKey(), each throwingException\JwtConfigurationException. The rules they enforce: Appendix: Authentication.Kinetis\AuthJwt\RevocationStore— aPsr\SimpleCache\CacheInterface-backed denylist. Per-token:revoke(string $jti, ?int $ttlSeconds)is the primitive —nullrevokes with no expiry at all (a permanent denylist entry, routed straight through to the cache’s own null-TTL semantics), a positive value is the entry’s own remaining lifetime, and zero/negative throwsException\RevocationUnavailableException::nonPositiveRevokeTtl().revokeToken(JwtUser $user)derives this from the token’s ownexpclaim: absent means indefinite (revoke($jti, null)), already in the past skips the write entirely, present-but-non-integer throwsinvalidExp(), and a missing/emptyjtithrowsmissingJti()rather than silently doing nothing.isRevoked(string $jti)is the lookupJwtAuthenticatorruns. Revocation is per token only — “log out everywhere” is application-owned credential-generation policy (see JWT Authentication). Requires a real cache — construction overNullSimpleCachethrowsException\RevocationUnavailableException, since a denylist that never stores anything would let every revoked token stay valid until natural expiry. Fail-loud at the individual-write level too: a conforming PSR-16 cache may report a failedset()by returningfalserather than throwing, andrevoke()checks for it and throwsException\RevocationUnavailableException::revokeFailed()rather than silently treating the failed write as a successful revocation — the message names neither thejtinor the cache key involved.Kinetis\AuthJwt\RefreshTokenStore— aPsr\SimpleCache\CacheInterface-backed, single-use opaque refresh token, independent ofRevocationStore.issue(string|int $subject, array $claims = [], int $ttlSeconds = 1_209_600): stringconverts$subjectto the same canonical non-empty subject stringJwtIssuer::issue()writes and storessha256(token) => {subject, claims}, rejecting an empty subject or a non-positive$ttlSeconds;redeem(string $token): ?arrayatomically reads and deletes the entry the moment it’s looked up (valid or not, viaKinetis\SimpleCache\AtomicConsumeInterface::consume()) and returns{subject: string, claims}ornull— a record whose stored subject is not that canonical form is unredeemable rather than reinterpreted.revoke(string $token): voidinvalidates one token directly. Construction throwsException\RefreshTokenUnavailableExceptionoverNullSimpleCache(same asRevocationStore) or over any cache not implementingAtomicConsumeInterface— aget()then a separatedelete()would let two concurrent redeems of the same token both succeed. The same fail-loud discipline applies to every mutation:issue()throwsissueFailed()(ornonPositiveIssueTtl()) rather than returning a token that was never actually stored, andrevoke()throwsrevokeFailed()on a failed delete — neither failure message names the token, subject, or cache key.Kinetis\AuthJwt\PublishedRsaKey— one RSA public key and the kid it is published under (public private(set) string $kid/$publicKey), the inputJwkSet::fromRsaPublicKeys()takes. What each field must be, and why a kid travels as a value rather than an array key: Appendix: Authentication.Kinetis\AuthJwt\JwkSet::fromRsaPublicKeys(array $keys, string $algorithm = 'RS256'): array— builds an RFC 7517 JWK Set ({"keys": [...]}) from alist<PublishedRsaKey>, to publish at a.well-known/jwks.json-style route. RSA only; the accepted input and every failure are in Appendix: Authentication.Kinetis\AuthJwt\JwkSetParser::parse(string $jwksJson): array— the consuming half ofJwkSet, used byJwtVerificationKeys::jwks(): raw JWKS JSON parsed intoarray<string, Firebase\JWT\Key>keyed byLOOKUP_PREFIX . kid, so a kid PHP would read as a number stays the exact string the document published;Exception\JwtConfigurationExceptionotherwise. Public boundsMAXIMUM_JSON_BYTESandMAXIMUM_KEYS. Accepted shapes, limits, and failure rules: Appendix: Authentication.Kinetis\AuthJwt\JoseHeader::parse(string $token, bool $kidRequired): ?self— reads a compact JWT’s own unsigned header into thealg/kidpair (public private(set)) beforeJWT::decode()sees the token, answeringnullfor every rejection, whichJwtAuthenticatorturns into its ownnullandJwtAuthMiddlewareinto a generic401. TheMAXIMUM_*bounds, what a header must be, and whycrit/b64are refused: Appendix: Authentication.Kinetis\AuthJwt\StrictJson::decodeObject(string $json, int $maximumBytes, int $maximumDepth): ?array—@internal. Decodes a JSON object under caller-supplied byte and depth limits, answeringnullfor a document that names one member twice at any depth.Kinetis\AuthJwt\Base64Url—@internal.encode(string): stringis RFC 4648 §5 base64url without padding;decode(string): ?stringaccepts the one spelling of any byte string and answersnullfor every other.Depends on
kinetis/framework(via apathrepository to this monorepo’s root),firebase/php-jwt(^7.1—6.10/6.11are excluded by an open security advisory),psr/simple-cache(RevocationStore/RefreshTokenStore),ext-openssl(JwkSet),nyholm/psr7,psr/http-message,psr/http-server-middleware. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/session (kinetis/session)¶
Separate Composer package, not part of kinetis/framework core.
Participates in extra.kinetis with a PackageBootstrap plus a scan
root covering Kinetis\Session\Console (the session:gc command);
both middlewares are explicit per-route opt-ins.
Kinetis\Session\SessionStoreInterface—read(id): ?array/create(id, data, lifetimeSeconds)/update(id, data, lifetimeSeconds): bool/destroy(id). Payloads are JSON-serializablearray<string, mixed>application data, projected to JSON by every store; expiry is the store’s own job.update()writes only over a record that is still stored and still live, returningfalsewhen it is not — Appendix: Sessions Reference states that terminal rule. Concurrent updates of one live id are last-write-wins — no locking, deliberately, since serializing a browser’s parallel requests would fight the concurrent-worker model.Kinetis\Session\GarbageCollectableStoreInterface—gc(): int, deleting every expired session and returning the count. Implemented by the file and sql stores; the Redis store leaves it out because the key’s own TTL expires it.Kinetis\Session\Console\GcCommand— thesession:gccommand onvendor/bin/kinetis. Callsgc()on the bound store and prints the count; for a store withoutGarbageCollectableStoreInterfaceit reports that the backend expires entries on its own and exits0; with no store bound at all it exits1namingSESSION_DRIVER. Nothing schedules it — cron or an equivalent does.Kinetis\Session\Session— what a controller constructor-injects (registered on the RequestScope by the middleware).get/set/has/remove/all,flash()/flashed()(survives exactly one following request, aged at commit),csrfToken()(generates one on first use, stored in the session — a real write, so call it only where the response actually carries the token),verifyCsrfToken()(constant-timehash_equals()against whatever token the session already has, returningfalsewith no such token rather than generating one to compare against — the methodCsrfMiddlewareuses instead ofcsrfToken(), specifically so checking a wrong token can never itself be what creates one, or allocates a session for a cookie the store has never heard of),regenerate()(fresh id and fresh CSRF token, same application data, old payload destroyed — the fixation defense for a known, previously-issued id, so a token issued before a privilege change no longer verifies after it),destroy(). Lazy: the store isn’t read until first access, andcommit()writes only when something changed — an untouched session costs no round trip and no cookie.id()participates in the same lazy load, not a bare property read: on first access, a presented id is verified against the store, and one the store has never heard of (wellformed but nonexistent — the idSessionMiddleware’s own shape check cannot catch) is rotated to a fresh id before any state is exposed or written; that rotation is itself lazy (a read-only access persists nothing), and onlyid()itself, a mutation, orcsrfToken()marks the fresh id for persistence atcommit()— the session-fixation closure this package’s own README and Appendix: Sessions Reference describe.Kinetis\Session\Store\FileSessionStore— one JSON file per session (sess_{id}), expiry embedded as a timestamp; an expired file is deleted when next read, andgc()sweeps the rest forsession:gc. Validates ids against^[a-f0-9]{32}$before building any path — defense in depth against traversal even though the middleware validates first.Kinetis\Session\Store\RedisSessionStore— over theRedisSimpleCachethe container already holds (the concrete class, not PSR-16:update()needs its conditionalreplace()), the key’s own TTL as expiry, single node and cluster alike. Payloads are stored as the package’s own JSON text rather than handed to the cache’s native serializer.Kinetis\Session\Store\SqlSessionStore—kinetis_sessions(id/payload/expires_at) over the genericSqlLinkcontract; dialect-agnostic SQL.create()is a plain INSERT;update()is one UPDATE constrained to a live row, falling back to a single existence check when the server reports zero changed rows, which MySQL does for a byte-identical write. Expired rows stay untilgc()(thesession:gccommand) deletes them. Migration stubs inresources/migrations/, never auto-created.Kinetis\Session\Middleware\SessionMiddleware— route middleware only (theBearerAuthMiddlewarestructural rule): rejects any cookie value that is not a wellformed 32-hex id (a malformed value is treated as no cookie), registers a lazySessionon the scope, and afterwards commits + sets the cookie only when needed. Shape-only: a wellformed id the store has never issued is not this class’s job to catch, since verifying existence would mean reading the store eagerly on every request and breaking the lazy-until-accessed contract above —Session::load()is what actually checks a presented id against the store and rotates a miss, on the one request that ever touches the session. Cookie:HttpOnlyalways,Path=/, noDomain,Secure/SameSite/name/lifetime fromSESSION_*config. Validates the configured name at construction: it must be a legal cookie token, and a__Host-/__Secure-prefix (matched case-sensitively) requiresSESSION_SECURE— a browser drops such a cookie silently, which presents as sessions that never persist.SESSION_SAMESITEis validated there too:Strict,Lax, orNonematched case-insensitively and normalised to that casing in the header, withNonerequiringSESSION_SECUREfor the same reason. Reads the cookie fromgetCookieParams()and nowhere else — the PSR-7 form every runtime adapter fills from the incomingCookieheader, so parsing that header belongs to whatever builds the request, not here.Kinetis\Session\Middleware\CsrfMiddleware— synchronizer-token check on non-GET/HEAD/OPTIONS, viaX-CSRF-Tokenheader or a form body’s_token, checked throughSession::verifyCsrfToken()(constant-time, never generates a token),403on mismatch; a missing upstreamSessionMiddlewareis a distinct500naming the declaration-order mistake. JSON bodies use the header — the dispatcher decodes JSON itself, sogetParsedBody()never carries_tokenfor them.Kinetis\Session\PackageBootstrap— withSESSION_DRIVERset, bindsSessionStoreInterfaceas a lazy factory (resolved on first use, afterboot()and every sibling bootstrap have run — which is what lets theredisdriver consumeboot()’s ownCacheInterfacebinding and thesqldriver the linkkinetis/database-bridgeor the application’sbootstrap.phpbinds, regardless of bootstrap order).redistakes that bound cache and requires it to be aRedisSimpleCache, so an application keeps one Redis client and its own binding wins. Unknown driver throws naming the valid set;rediswithout kinetis/cache-redis installed or with no Redis cache bound (REDIS_URL,REDIS_HOST, orREDIS_CLUSTERwithREDIS_CLUSTER_SEEDS), andsqlwithout kinetis/persistence installed or with no link bound, throw naming the fix.Depends on
kinetis/framework,psr/http-message,psr/http-server-middleware;kinetis/persistence/kinetis/cache-redisonly inrequire-dev— store classes load lazily. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/views and view adapters¶
Four separate Composer packages make view rendering optional and keep template vendor dependencies out of core.
kinetis/viewsownsViews,ViewEngineInterface, logicalViewNamevalidation,ViewDirectorycontainment and deterministic recursive template discovery, the deterministicAssetUrl, and the commonViewNotFoundException/ViewRenderExceptionvocabulary.ViewRuntimemaps the application project root andAppEnvironmentto isolated.kinetis-cache/views/<engine>storage;ViewCacheDirectorycreates and empties only that derived directory without following links.Console\WarmCommand/ClearCommandexposeviews:warm/views:clearthrough this package’s scan root and delegate to the bound engine.Views::response()returns core’s UTF-8HtmlResponse;render()returns the same complete body as a string. Data is supplied anew on every call and theassetkey is reserved.kinetis/views-phpownsPhpViewEngine. It extracts one call’s data into a private include scope, exposes$asset, captures output, and restores every buffer opened above the caller’s level when a template throws. Cache warm and clear are successful zero-work operations.kinetis/views-latteownsLatteViewEngine, appends.latte, registers theasset()function, and exposes the underlying Latte engine for bootstrap-time extensions. In production it clears and warms every canonical.lattetemplate through Latte’swarmupCache()into.kinetis-cache/views/latte; development writes no cache.kinetis/views-twigownsTwigViewEngine, aFilesystemLoaderwhose path and cache-key root are both the configured directory, theasset()function, Twig environment options, and bootstrap-time access to that environment. In production it clears and loads every logical.twigtemplate into.kinetis-cache/views/twig; development writes no cache.The three adapters conflict pairwise in Composer, so an application selects exactly one. Each depends on
kinetis/views; only the Latte/Twig adapters add their corresponding vendor engine. See Views for wiring, escaping, persistent-worker state, and local-filesystem constraints.
See also¶
Appendix: System Layout — the same reference map for core (
kinetis/framework).Appendix: Continuous Integration — what actually runs in CI, including the real-backend integration checks for several packages listed above.
Appendix: Contributing to Kinetis — the monorepo layout, dev environment setup, and the manifest-driven tooling for changing a package’s dependencies.
Database, Redis, Migrations, Query Builder, ORM, Queue, Queue (Redis), Queue (SQL), Queue (SQS), Queue (RabbitMQ), Storage, Storage (S3), HTTP Client, AWS request signing (SigV4), Mailer, Search engines, Search (OpenSearch), Search (Elasticsearch), Broadcasting, Telemetry, Authentication, JWT Authentication, Sessions & CSRF, Authorization, Model Context Protocol (MCP), MCP Documentation Server, Orbitron, Runtime Adapters — the task-oriented page for each package above.