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.
packages/bref-adapter (kinetis/bref-adapter)¶
Separate Composer package, not part of kinetis/framework core.
Kinetis\BrefAdapter\BrefLambdaAdapter implements Kinetis\Runtime\RuntimeAdapterInterface—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(), answering anException\MalformedRequestBodyException(invalid base64, a multipart body with no usable boundary) with the same400every adapter gives instead of an invocation error, and the entry point the runtime conformance suite drives (tests/Conformance/LambdaDriver).requestFromEvent()decodes a base64 body strictly (invalid base64 isMalformedRequestBodyException, never an empty body);responseToPayload()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. Parsesmultipart/form-dataviariverline/multipart-parser’sStreamedPart,application/x-www-form-urlencodedviaparse_str()— a Lambda event body is one in-memory string with no livephp://input, sorequest_parse_body()(what core’s own adapters use) can’t apply here. See Runtime Adapters for the full supported/unsupported feature list.Depends on
kinetis/framework(via apathrepository to this monorepo’s root),nyholm/psr7,psr/http-message,riverline/multipart-parser. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/persistence (kinetis/persistence)¶
Separate Composer package, not part of kinetis/framework core — extracted
from it so core itself has no direct MySQL/Postgres dependency.
Kinetis\Persistence\TransactionGuard— request-scoped SQL transaction safety net.transaction()(commit on success, rollback on throw) andbeginTransaction()/rollbackDangling()for the manual case.Kinetis\Http\Kernelandbin/kinetisboth registerrollbackDangling()as a dispose hook whenever this class is available (class_exists()-gated), not unconditionally — an application with no database can skip this package entirely.Kinetis\Persistence\SqlConnectionFactory::fromConfig(Config $config, string $connection = 'default'): Contract\MysqlLink|Contract\PostgresLink— builds a runtime-matched driver client fromDB_*(orDB_{NAME}_*for a named connection); shared bykinetis/migrations’migrate*commands,kinetis/queue-sql’sSqlQueueFactory, and this package’s ownPackageBootstrap.$poolOptions['warmConnections']/DB_WARM_CONNECTIONSopens connections at construction via each driver’swarmUp(?int $connections = null)— load-bearing for the mysqli driver under worker mode (see Performance tuning).Kinetis\Persistence\Testing\DatabaseTransactions/DatabaseTruncation— per-test database isolation for a consumer’s PHPUnit suite (see Testing): a rolled-back transaction per test, or explicit-table deletion before each test. Both ask the test for the connection via an abstractdatabaseLink().DatabaseTransactionsrequires a single-connection (PDO) driver and skips otherwise — a transaction on one pooled connection isolates nothing the others do;DatabaseTruncationworks with any driver and with code that opens its own transactions.Testing\DatabaseIsolation(@internal) holds their shared checks.Kinetis\Persistence\Contract\PrefersPreparedStatements— a marker, no methods: this link is faster binding a value than reading it as a literal. Carried byPdoMysqlClient/PdoPgsqlClientand their transactions, which memoize prepared statements per connection (and per transaction — a transaction owns its own handle, so it keeps its own cache) and therefore keep the binary protocol; the native drivers do not carry it, since an unparameterized query saves them a round trip.Kinetis\QueryBuilder\Queryis the caller that branches on it.Kinetis\Persistence\PackageBootstrap— declared viaextra.kinetis; withDB_CONNECTIONset, bindsSqlConnectionFactory::fromConfig()’s result under its dialect contract (Contract\MysqlLinkorContract\PostgresLink) before the application’s ownbootstrap.phpruns (which wins on the same binding). Inert whenDB_CONNECTIONis unset; named (non-default) connections stay explicit app-side wiring.Depends on
kinetis/framework(via apathrepository to this monorepo’s root) andrevolt/event-loop; the drivers useext-mysqli/ext-pgsql/PDO, suggested rather than required. 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 amphp/redis dependency moved.
Kinetis\SimpleCache\RedisSimpleCache— single-node, backed byAmp\Redis\RedisClient.fromConfig(Config $config, string $connection = 'default')/buildRedisConfig(Config $config, string $connection = 'default')readREDIS_URLor discreteREDIS_HOST/REDIS_PORT/REDIS_PASSWORD/REDIS_DATABASE/REDIS_TIMEOUT(or theirREDIS_{NAME}_*named-connection equivalents), returningnullwhen neither is set.clear()flushes the entire selected database. 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.Kinetis\SimpleCache\ClusteredRedisSimpleCache— the Redis Cluster counterpart, activated byREDIS_CLUSTER=true/REDIS_CLUSTER_SEEDS.Cluster\Crc16::slotFor()computes the owning slot (CRC16-XMODEM mod 16384, honoring a{...}hash tag when present);Cluster\ClusterTopologydiscovers the slot→node layout viaCLUSTER SHARDSand resolves a slot to theRedisClientthat owns it, refreshing on aMOVEDreply.getMultiple()/deleteMultiple()dispatch one command per key rather than a batchedMGET/DEL(Redis Cluster rejects any multi-key command whose keys don’t share a slot), run concurrently viaKinetis\Async\concurrently();clear()fansFLUSHDBout to every master. Only database 0 is supported, matching a real cluster’s own restriction. ImplementsAtomicCounterInterface/AtomicConsumeInterfacetoo — each script carries one key, so it runs on whichever node owns that key’s slot.Kinetis\SimpleCache\Connection\TlsRedisConnector— aRedisConnectorusingAmp\Socket\connectTls(), sinceAmp\Redis’s own default connector never upgrades to TLS. Shared by both classes above; returnsnullwhenREDIS_TLSisn’t set.Kinetis\Container\AppScope::boot()triesClusteredRedisSimpleCache::fromConfig()thenRedisSimpleCache::fromConfig(), bothclass_exists()-gated against this package; 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/framework(via apathrepository to this monorepo’s root),amphp/redis,amphp/socket,amphp/serialization. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/mcp (kinetis/mcp)¶
The Model Context Protocol server. Installing the package is the whole
setup: its extra.kinetis declares a scan root covering the namespace
and a bootstrap, so the /mcp route, mcp:serve, and the docs
resources all appear with nothing to wire. Core has no MCP surface
without it.
PackageBootstrap— lazy-bindsMcpServer: discovery (McpDiscovery::discover()) runs when something first resolves the server — once per worker under a persistent runtime, once per/mcprequest under PHP-FPM, never on a request that doesn’t touch it. The project root comes from Composer’s own runtime API (InstalledVersions::getRootPackage()), so the same code serves a consumer install and this package’s own development.Http\McpController—/mcpas an ordinary route (#[Post('/mcp')],#[Middleware('@mcp')]): parse-error handling, protocol-era detection, the mirrored-header checks (MCP-Protocol-Version/Mcp-Method/Mcp-Name, modern-era requests only, with the=?base64?{...}?=sentinel decoded and failing closed), the spec’s error-code-to-HTTP-status mapping,202for notification-only bodies, and the SSE progress stream (aStreamedResponsewhose emitter runs after the request scope is disposed, so the streamed call gets its own scope — with the request’sCurrentUserInterfacecarried across — disposed after the final event). GET/DELETE declare no routes: the router’s own405withAllow: POSTis exactly what the 2026-07-28 spec asks for.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.Console\McpServeCommand—#[Command('mcp:serve')], resolving the bootstrap’s ownMcpServerbinding and handing the transport the realAppScopefor per-message scopes.McpServer— handles one decoded JSON-RPC message;handle()takes an optional per-message scope threaded through toMcpDispatcher. Supports the legacy (2025-03-26)initializehandshake and the modern (2026-07-28) statelessserver/discovermodel side by side.loggerparam defaults toNullLogger(constructed directly, not through the container). A throwing tool reportsisError: truewith the fixed content stringTool execution failed., the real exception going to the logger — a failed validation keeps its realerrorsmap, since that’s the argument feedback an agent retries on.wrapModernResult()addsttlMs/cacheScopeperCACHEABLE_METHOD_SCOPES(server/discover/tools/list/resources/list→public,resources/read→private,tools/call→ neither, since it’s an action not a cacheable read). The optional constructor$instructionsis included onserver/discoveronly when given, omitted entirely otherwise.McpRegistry—#[McpTool]/#[McpResource]discovery,toArray()/fromArray()for the AOT cache.McpDispatcher— the MCP analogue ofHttp\Dispatcher.callTool()/readResource()take an optional per-call scope the transports create per message; 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).ProgressReporter— injected by type into a tool method;report()streams anotifications/progressevent when_meta.progressTokenis present, a no-op otherwise.Transport\StdioTransport— one JSON-RPC message per line on stdin/stdout. Given anAppScope(asmcp:servepasses), each line is a unit of work: fresh scope, theTransactionGuardrollback hook behind the sameclass_exists()gateKerneluses, disposal once the response is written, thengc_collect_cycles()— a stdio server is a persistent process. Without one, messages share the dispatcher’s own container, the pre-scope behavior.KinetisDocsResource— registers everydocs/*.mdpage as an MCP resource (kinetis://docs/{slug}) — the monorepo’s own files when developing Kinetis, the published documentation otherwise. Lives under this package’s scan root, so discovery always finds it on both transports; registering it manually ($registry->register(KinetisDocsResource::class)) is only needed for a hand-wiredMcpRegistrythat never goes through discovery.McpDiscovery::discover(string $projectRoot, ?array $paths = null): McpRegistry— builds a registry from every class found anywhere under a project’s own PSR-4 root(s), plusKinetis\Mcpitself (NamespaceScanner, seeKinetis\Cachebelow), rather than an explicit registration file.$paths, orMCP_DISCOVERY_PATHSwhen omitted, restricts the project-side scan.
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<timestamp>_<description>.phpfiles under amigrations/project-root directory, sorted by filename;load()is a barerequireof the file.Kinetis\Migrations\MigrationRepositoryInterface/SqlMigrationRepository— tracks applied migrations in akinetis_migrationstable (migrationprimary key,applied_at), 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;rollback()targets the single most recently applied migration only, throwingException\MigrationFileMissingExceptionif that migration’s file no longer exists.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).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 connection/paths holder: connects viaKinetis\Persistence\SqlConnectionFactory, readingDB_CONNECTION(mysql|pgsql, required) plusDB_HOST/DB_NAME/DB_USER/DB_PASSWORD/DB_PORT;--connection=<name>wins overMIGRATE_CONNECTION_NAME(default'default') for a named connection.Depends on
kinetis/frameworkandkinetis/persistence(both via apathrepository to this monorepo’s root);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 thin, parameterized SQL query builder, not an ORM (no relationships/migrations/change-tracking). One class works with either MySQL or Postgres via the sharedKinetis\Persistence\Contract\SqlLinkfamily (auto-detected instanceofContract\MysqlLink/Contract\PostgresLink).select()/selectRaw()/where()/orWhere()/whereIn()/whereRaw()/join()/leftJoin()/orderBy()/orderByRaw()/limit()/offset(), terminalget()/first()/count()(optionalHydrator-based DTO mapping) andinsert()/insertGetId()/update()/delete(). Accepts a plain pool or an in-flightSqlTransaction, so it composes insideTransactionGuard::transaction(). Every terminal method routes through one privaterun(), which chooses between$link->query()and$link->execute()per driver: a query with nothing bound always takesquery(), and a query whose every value is anintorboolwrites them as literals and takes it too — unless the link carriesContract\PrefersPreparedStatements, which the PDO drivers do, where binding is the cheaper of the two. Strings,nulland floats always bind, and any use ofwhereRaw()/selectRaw()/orderByRaw()disables inlining for the whole query.Kinetis\QueryBuilder\Dialect(+Dialect\MySqlDialect/Dialect\PostgresDialect) — isolates identifier quoting, retrieving a generated primary key after an insert (MySQL:getLastInsertId(); Postgres:INSERT ... RETURNING), andliteralFor(), the per-dialect logic behind the literal-inlining above.Kinetis\QueryBuilder\CompiledQuery— the{sql, params}output of everyto*Sql()compile method, built together in one pass so bound parameters always land in the same position as their?in the generated SQL, even oncewhereRaw()/whereIn()mix with structuredwhere()calls.Query::paginate(int $perPage, int $page = 1, ?string $dtoClass = null): Kinetis\Http\Pagination\Paginator— acount()fortotal/lastPageplus alimit()/offset()-basedget()for the page, against the samewhere()/join()filters already on the query. A page past the last one returns emptydatawith the realtotalstill reported, not an error.Query::cursorPaginate(int $perPage, ?string $cursor, string $cursorColumn = 'id', ?string $dtoClass = null, ?string $cursorAlias = null): Kinetis\Http\Pagination\CursorPaginator— orders by$cursorColumnand filtersWHERE $cursorColumn > $cursoronce one is given (nullfetches from the start); noCOUNT(*), no page number.nextCursoralways comes out of the same result as the delivered rows — one query, never two, so a write landing between reads can’t leave the cursor naming a row the caller was never handed. Always fetches raw rows first regardless of$dtoClass, so the cursor is read off the real column name rather than a hydrated DTO’s own property name. Aselect()projection that omits an unqualified$cursorColumnstill works: it’s added to the query automatically and stripped back out of every returned row (and never reaches$dtoClasshydration) before returning. A qualified$cursorColumn(orders.id, for ajoin()ed query) requires$cursorAlias: both MySQL and Postgres report it under its bare name, which a join collides with, and no alias this class could pick is guaranteed absent from an arbitrary projection — so the caller names one, the column is additionally selected under it, and it is stripped from every returned row. Omitting it for a qualified column throwsInvalidArgumentException, as does an alias matching a column the caller listed inselect()(checked before any SQL runs). An alias colliding with a column only a wildcard brings in is a documented caller precondition rather than a check: it replaces that column, since detecting it would need column metadataSqlResultdoesn’t carry and the one available proxy also fires on the ordinary duplicateidof aSELECT *across a join. Everything else about the query is untouched, so an alias anorderBy()depends on and a caller’s ownoffset()both survive.One
Queryinstance is one query — nothing resets between fluent calls; construct a fresh instance per query.Depends on
kinetis/frameworkandkinetis/persistence(viapathrepositories to this monorepo’s root). 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): void,fail(QueuedJob $job): void,size(string $queue = 'default'): int(jobs waiting to be popped — delayed included, reserved excluded),clear(string $queue = 'default'): int(discards waiting jobs, returning how many; reserved jobs are untouched).$queuesis checked in the given order — priority by list position, not a numeric score.$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.Kinetis\Queue\QueuedJob—{class, args, handle, queue, attempts, maxAttempts, metadata}.$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\JobSerializer— converts aJobinstance to plain{class, args}data (reading each constructor parameter’s value off a same-named property via reflection) and back vianew $class(...$args). ThrowsException\UnserializableJobExceptionfor a constructor parameter with no matching property.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. For local development; not selectable viaQUEUE_CONNECTION. A freshRequestScopeperpush(), same asQueueWorker; unlikeQueueWorker, a failing job’s exception propagates rather than being caught and logged.Kinetis\Queue\QueueWorker—__construct(AppScope $app, QueueInterface $queue, int $defaultMaxAttempts = 0),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(),handle()’s parameters autowired through it viaJobInvoker. 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.Kinetis\Queue\QueuedListenerInvoker— implements core’sKinetis\Events\ListenerInvokerInterface. Serializes the event (viaJobSerializer, generalized to accept anyobject, notJobspecifically) and pushes anInvokeListenerJobcarrying the listener’s class/method as plain strings.Kinetis\Queue\InvokeListenerJob— the jobQueuedListenerInvokerpushes.handle(RequestScope $scope)resolves the listener through the given scope and reconstructs the event viaJobSerializer::deserialize(), invoking the original method by name.Kinetis\Queue\QueueFactory::fromConfig(Config): QueueInterface— builds the backendQUEUE_CONNECTIONselects (redis|sql|sqs|rabbitmq, required);QUEUE_CONNECTION_NAME(default'default') selects a named connection of that backend. 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 missing package when the selected one isn’t installed.Kinetis\Queue\PackageBootstrap— declared viaextra.kinetis; withQUEUE_CONNECTIONset, bindsQueueInterfacetoQueueFactory::fromConfig()’s result before the application’s ownbootstrap.phpruns (which wins on the same binding). Inert whenQUEUE_CONNECTIONis unset.Kinetis\Queue\Console\WorkCommand— thequeue:work [--queue=high,default]command onvendor/bin/kinetis, registered through this package’sextra.kinetisscan root. Constructor-injectsQueueInterface(thePackageBootstrapbinding, or the application’s override) plusConfigforQUEUE_POLL_TIMEOUTandQUEUE_MAX_ATTEMPTS(passed through asQueueWorker’s$defaultMaxAttempts, both defaulting to5/0respectively). 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 backendQUEUE_CONNECTIONselects.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\QueueInterface— backed byAmp\Redis\RedisClient. Uses the “reliable queue” pattern (popTailPushHeadBlocking(), i.e.BRPOPLPUSH) rather than a plain destructive pop, moving a job to a separate processing list untilack()/release(); delayed jobs live in a sorted set scored by ready-at time, promoted in bounded batches (DELAYED_PROMOTION_BATCH_SIZE) once perpop()call, so a large ready backlog can’t stall other Redis clients for one script’s whole duration.release()(processing → pending) and delayed-job promotion (delayed → pending) both run as a single Lua script (RedisClient::eval()) rather than a remove-then-push pair of commands, so a process crash can never land between the two halves of either move;release()’s script is also conditional on actually having found and removed the source entry, so a duplicate call or a retry after an ambiguous connection failure throwsException\StaleJobHandleException(whichQueueWorkertreats as benign) instead of enqueueing a second replacement. 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. A job written by the envelope format that predatesid/pushedAtis still release()-able after an upgrade: both fields are read optionally, falling through to a freshly generated value the first time such a job is released, rather than depending on a key that older format never wrote.Kinetis\QueueRedis\RedisQueueFactory::fromConfig(Config $config, string $connectionName = 'default'): QueueInterface— builds aRedisQueuefrom the sameREDIS_*conventionRedisSimpleCache::buildRedisConfig()reads; throws when neitherREDIS_URLnorREDIS_HOSTis set.kinetis/queue’sQueueFactorydispatches to this package forQUEUE_CONNECTION=redis.Depends on
kinetis/framework,kinetis/queue, andkinetis/cache-redis(all viapathrepositories), plusamphp/redis. 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\QueueInterface— 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. Requires thekinetis_queue_jobstable (withqueue,attempts,max_attemptscolumns 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(defaultnull, meaning never), reclaims a crashed worker’s reserved row after that many seconds, incrementingattemptsat that point.Kinetis\QueueSql\SqlQueueFactory::fromConfig(Config $config, string $connectionName = 'default'): QueueInterface— builds aSqlQueuefromSqlConnectionFactory::fromConfig()’s result, reading the optionalQUEUE_VISIBILITY_TIMEOUT_SECONDS(viaConfig::scopedKey()) for the second constructor argument — absent meansnull.kinetis/queue’sQueueFactorydispatches to this package forQUEUE_CONNECTION=sql.Depends on
kinetis/framework,kinetis/queue, andkinetis/persistence(SqlQueue’sTransactionGuarduse,SqlConnectionFactory) — 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\QueueInterface— backed byAsyncAws\Sqs\SqsClient.push()/pop()map ontoSendMessage/ReceiveMessage;ack()/fail()ontoDeleteMessage;release()ontoChangeMessageVisibilitywithVisibilityTimeout: 0(immediately available again, rather than waiting out the normal timeout). 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()’s multi-queue priority cycling uses a short, fixed per-queueWaitTimeSeconds(SQS’s own long-polling primitive, capped at 20 seconds) — 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.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_QUEUE_PREFIXoptional, all viaConfig::scopedKey(). Credentials are never read fromKinetis\Config— left to AsyncAws’s own default credential provider chain.Kinetis\QueueSqs\SqsQueueFactory::fromConfig(Config $config, string $connectionName = 'default'): QueueInterface— theclass_exists()-gated entry pointkinetis/queue’s ownQueueFactorycalls: buildsSqsQueuefromSqsClientFactory::fromConfig()plus the optionalQUEUE_SQS_QUEUE_PREFIX.kinetis/queue’sQueueFactorydispatches to this package forQUEUE_CONNECTION=sqs.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\QueueInterface— 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()instead publishes to a dedicated{queue}.delayqueue configured withx-dead-letter-exchange/x-dead-letter-routing-keypointing back at the real queue and a per-messageexpirationequal to the delay, so RabbitMQ itself moves the message once it expires — no polling-based promotion.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();release()republishes with an incrementedattemptsheader before discarding the original delivery vianack(requeue: false), sincenack’s ownrequeueflag redelivers the message unchanged.QueuedJob::$handleis theThesis\Amqp\DeliveryMessageitself.pop()’s multi-queue priority cycling usesbasic.get(a single, immediate, non-blocking request per queue — AMQP has no native blocking-wait-with-timeout primitive), sleeping viaAmp\delay()between full sweeps when nothing is found. 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\RabbitMqClientFactory::fromConfig(Config $config, string $connection = 'default'): Client— buildsThesis\Amqp\ClientfromThesis\Amqp\Config::fromURI().QUEUE_RABBITMQ_URLrequired, viaConfig::scopedKey().Kinetis\QueueRabbitMq\RabbitMqQueueFactory::fromConfig(Config $config, string $connectionName = 'default'): QueueInterface— theclass_exists()-gated entry pointkinetis/queue’s ownQueueFactorycalls: buildsRabbitMqQueuefromRabbitMqClientFactory::fromConfig()plus the optionalQUEUE_RABBITMQ_QUEUE_PREFIX.kinetis/queue’sQueueFactorydispatches to this package forQUEUE_CONNECTION=rabbitmq.Depends on
kinetis/frameworkandkinetis/queue(both viapathrepositories) plusthesis/amqp. 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\Filesysteminstead of Flysystem’s own local adapter, so every operation suspends the calling Fiber via Revolt rather than blocking the worker.readStream()is the one exception: it reads the whole file via the same non-blocking primitive, then buffers it into an in-memoryphp://tempresource, since a PHPresourcecan’t lazily pull from a userland object without a registered stream wrapper.write()/writeStream()/copy()stream genuinely, viaAmp\ByteStream\pipe()between realAmp\File\Filehandles. Rejects a symlink observed at check time: every path is checked component by component (Amp\File\Filesystem::isSymlink(), lstat semantics) before the real operation runs, and a discovered symlink throwsLeague\Flysystem\SymbolicLinkEncounteredrather than being resolved — a check-then-use guard, not a race-free one, soFILESYSTEM_ROOTis a real boundary only when this adapter is the sole writer to it; see Storage’s “Symlink checks” section for the full threat model and the operational mitigation for a shared root.deleteDirectory()plans the whole subtree before deleting anything, so a symlink found partway through leaves nothing deleted rather than the entries visited earlier already gone.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 for the local driver), both viaConfig::scopedKey()for named connections.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’s streamingwrite()/writeStream()/copy(), viaAmp\ByteStream\pipe()). Owncomposer.json/phpunit.xml/phpstan.neon.
packages/revolt-http-client (kinetis/revolt-http-client)¶
Separate Composer package, not part of kinetis/framework core — and,
unlike every other satellite package, not dependent on it either:
kinetis/framework appears only in require-dev (for tests and the
NoStaticPropertiesRule dogfooding), never in require. Genuinely
installable and usable with no Kinetis framework present at all.
Kinetis\RevoltHttpClient\Http— the client application code uses. Immutable:withBaseUrl()/withToken()/withBasicAuth()/withHeaders()/withQuery()/withTimeout()/withRetries()/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 taking Symfony HttpClient options. Constructed with no argument it defaults toAmpHttpClientFactory::create(), so it autowires; pass anyHttpClientInterface(Symfony’sMockHttpClient, for one) to substitute the transport.withRetries()wraps the transport in Symfony’s ownRetryableHttpClientrather than hand-rolled retry logic.Kinetis\RevoltHttpClient\HttpResponse—status()/successful()/failed()/clientError()/serverError()/body()/json()/jsonPath()/header(). An error status is returned rather than thrown;throw()opts into raisingException\HttpRequestExceptionand returns the response otherwise, so it chains.getMessage()deliberately excludes the response body, the request URL’s userinfo/query string, and any lower-level transport exception’s own message — any of those could carry a secret (a signed URL’s signature, an API key, PII in an upstream error body, a credential embedded in a transport client’s own error text) that routine exception logging would otherwise leak; the full, unredacted detail is still reachable viadiagnosticUrl()/diagnosticBody()/diagnosticMessage()/getPrevious(), but only by an explicit call — the first two are accessor methods over private fields, not public properties, so a generic serializer likejson_encode($e)never exposes them either. A transport failure — no response at all — throws the same exception type with status0, so one catch covers everything the client throws. The body is read once and cached, and reading is deferred until asked for, which is what lets requests started insideconcurrently()overlap.Kinetis\RevoltHttpClient\AmpHttpClientFactory::create(array $defaultOptions = [], ?callable $clientConfigurator = null, int $maxHostConnections = 6, int $maxPendingPushes = 50): Symfony\Contracts\HttpClient\HttpClientInterface— mirrorsSymfony\Component\HttpClient\AmpHttpClient’s own constructor exactly, no Kinetis-specific defaults layered on top.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, andamphp/http-client(^5.3, an optional peer dependency ofsymfony/http-clientthat isn’t auto-installed, so declared directly). 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. Wraps another PSR-18 client and signs every request with AWS Signature Version 4 before delegating to it, reusingAsyncAws\Core\Signer\SignerV4directly (the same signer every AsyncAws service client already uses internally) rather than reimplementing the algorithm. Converts a PSR-7 request toAsyncAws\Core\Request, resolves credentials viaAsyncAws\Core\Credentials\ChainProvider::createDefaultChain()(wrapped inCacheProvider) unless aCredentialProvideris passed directly, signs, and copies the resulting headers back onto a PSR-7 request. The constructor’s own?\DateTimeImmutable $nowparameter exists 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 always leaves itnull. Before signing, the request’s body is always replaced with aSpooledStream(below) — sourced by rewinding-then-reading a seekable original body, or reading a non-seekable one from wherever it already is (seeking one backward is impossible by definition) — so neither the signature computation nor the wrapped client’s own read ever has to seek the caller’s original stream. A seekable original body’s own cursor position is saved before reading and restored afterward (success or failure), since it’s the same stream object the caller’s own request was built with, not a private copy.Kinetis\AwsSigV4\SpooledStream implements Psr\Http\Message\StreamInterface—@internal, constructed only bySigV4SigningClientitself. A minimal, always-seekable PSR-7 stream over an already-in-memory string, backed byphp://temp(in memory up to 2MB, then a real temp file) so this stream’s own storage doesn’t hold a second long-lived full copy of the body — it does not bound the peak memory a signed request costs, since the body is still read into a plain PHP string more than once along the way (once to build this stream, again to compute the signature); avoids this package taking on a full PSR-7 implementation as a runtime dependency either way.Its own test suite includes AWS’s published “get-vanilla” SigV4 test vector (a fixed date and static test credentials,
AKIDEXAMPLE) and matches the published expectedAuthorizationheader exactly, plus a non-seekable request body and a body pastphp://temp’s in-memory threshold, both signed and sent correctly, and a seekable body’s own cursor position confirmed restored to exactly where it started after signing.Depends on
kinetis/revolt-http-client,async-aws/core,psr/http-client,psr/http-message. 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— buildsAsyncAws\S3\S3ClientwithKinetis\RevoltHttpClient\AmpHttpClientFactory::create()injected as its transport, wraps it inLeague\Flysystem\AsyncAwsS3\AsyncAwsS3Adapter.FILESYSTEM_S3_BUCKET/FILESYSTEM_S3_REGIONrequired;FILESYSTEM_S3_PREFIX/FILESYSTEM_S3_ENDPOINT/FILESYSTEM_S3_PATH_STYLEoptional, all viaConfig::scopedKey(). Credentials are never read fromKinetis\Config— left toAsyncAws\Core\Configuration’s own default credential provider chain.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/s3,league/flysystem-async-aws-s3. 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, lazily bindsSymfony\Component\Mailer\MailerInterfacetoMailerFactory::fromConfig()’s result. Inert whenMAILER_DSNis unset. The binding resolves in whichever process injects it, so a queued job’shandle()gets it in the worker.Kinetis\Mailer\MailerFactory::fromConfig(Config $config, string $connection = 'default'): Symfony\Component\Mailer\MailerInterface— the only class in the package. Reads a singleMAILER_DSN(Config::scopedKey()for named connections) and always passesKinetis\RevoltHttpClient\AmpHttpClientFactory::create()intoSymfony\Component\Mailer\Transport::fromDsn()as itsHttpClientInterface. Genuinely non-blocking for any API-based transport (Sendgrid, Mailgun, Postmark, SES, …) it resolves to;EsmtpTransport(SMTP) ignores the injected client and opens a raw, genuinely blocking socket regardless — a disclosed exception, 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 actually 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. 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, lazily bindsOpenSearch\ClienttoOpenSearchClientFactory::fromConfig()’s result. Inert when the key is unset; the concrete client is the binding id because opensearch-php exposes no interface for it.Kinetis\SearchOpenSearch\OpenSearchClientFactory::fromConfig(Config $config, string $connection = 'default'): OpenSearch\Client— the only class in the package. 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) with aSymfony\Component\HttpClient\Psr18ClientwrappingKinetis\RevoltHttpClient\AmpHttpClientFactory::create()as the client.No Kinetis-owned client interface — the real
OpenSearch\Clientis returned directly.SEARCH_OPENSEARCH_HOSTis a single base URI;SEARCH_OPENSEARCH_USERNAME/SEARCH_OPENSEARCH_PASSWORD(Basic auth) andSEARCH_OPENSEARCH_VERIFY_PEER(defaulttrue) are optional, all viaConfig::scopedKey()for named connections.Depends on
kinetis/frameworkandkinetis/revolt-http-client(both viapathrepositories),opensearch-project/opensearch-php,symfony/http-client. Owncomposer.json/phpunit.xml/phpstan.neon.
packages/telemetry (kinetis/telemetry)¶
Separate Composer package, not part of kinetis/framework core.
Participates in extra.kinetis: a scan root covering
Kinetis\Telemetry\Middleware\ (so RequestSpanMiddleware is
discovered as global middleware on install) and a PackageBootstrap.
Kinetis\Telemetry\PackageBootstrap— bindsOpenTelemetry\API\Trace\TracerProviderInterfaceonAppScope: the OTLP-exporting provider whenOTEL_EXPORTER_OTLP_ENDPOINTis set, aNoopTracerProviderotherwise. Also replaces OTel’s fiber-bound default context storage with the sharedContextStorage— Kinetis Fibers are scheduling units within one request, not independent execution contexts, and the swap is what lets a span begun by the middleware parent spans created insideconcurrently()tasks. 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(), so span export suspends rather than blocks.nullwhen no endpoint is configured.Kinetis\Telemetry\Middleware\RequestSpanMiddleware—#[AsGlobalMiddleware(priority: 90)], a server span per request: method as the span name (route templates aren’t visible to global middleware and raw paths would explode name cardinality),url.path,http.response.status_code,php.memory.usage, error status on 5xx or an exception,traceparentextraction for distributed traces. The span is active while the handler runs — the parent for everything below.Kinetis\Telemetry\Persistence\TracingMysqlLink/TracingPostgresLink(and theTracingMysqlTransaction/TracingPostgresTransactiontheirbeginTransaction()hands back, plus theTracingSqlLinkBase/TracingSqlTransactionBaseabstract bases) — a client span perquery()/execute()named by the SQL’s first keyword,db.system.nameanddb.query.textattributes, bound parameter values deliberately never recorded.COMMIT/ROLLBACKspanned too. Each decorator implements its dialect marker, so query-builder dialect detection is unaffected. Query spans are never activated — they read the current context as parent and end immediately, so concurrent queries can’t interleave anyone’s scope stack.Kinetis\Telemetry\Queue\TracingQueue— wraps anyQueueInterface.push()gets a producer span; a consumer span opens atpop()and closes atack()/release()/fail()(tracked via aWeakMap<QueuedJob, ...>), carryingkinetis.job.class/attempt/outcome, error status onfail(). Active while the job runs, so the job’s own spans nest under it. Producer and consumer spans are separate traces — linking them needs context in the payload, which a decorator can’t reach; a disclosed gap.Kinetis\Telemetry\HttpClient\TracingHttpClient/TracingResponse— a client span per outgoing request withtraceparentinjection (appended in Symfony’s"Name: value"string form, coexisting with any existing header shape). 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\Instrumentation\OtelTelemetry— implements core’sKinetis\Instrumentation\TelemetryInterface, turning the framework’s hooks into spans;PackageBootstrapswaps it intoTelemetry::global()whenever the OTLP endpoint is configured. Which hooks activate their span (parenting whatever starts next) is the load-bearing choice: only strictly-nested single-fiber pairs do — middleware, controller, event/listener, theconcurrently()batch, MCP tool calls, worker jobs. Query and per-task spans never activate: they can overlap across fibers on the shared context, and activating them would interleave the scope 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.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/queueonly inrequire-dev— the decorators’ classes load lazily, so neither is forced on an install that only wants request spans. 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 against an app-suppliedUserProviderInterface, registering the resolved user on the currentRequestScopeasCurrentUserInterfaceon success, or returning401with aWWW-Authenticate: Bearerheader on failure. 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\JwtAuthMiddleware— PSR-15 route middleware (never global) verifying anAuthorization: Bearer <token>header’s signature viafirebase/php-jwt, registering the decoded claims as aJwtUser(CurrentUserInterface) on success, or returning401withWWW-Authenticate: Beareron failure — a decode exception, a structurally valid token with nosubclaim, and a revoked token (checked against the optionalRevocationStore) are all treated identically.$keyis the shared secret forHS256/HS384/HS512, the public half of a key pair (PEM string) forRS256/RS384/RS512—JwtIssuertakes the matching private half — or anarray<string, Firebase\JWT\Key>map keyed bykid, for verifying against more than one key at once during a rotation; a plain string keeps working unchanged. Deliberately notfinal:#[Middleware(...)]carries only a class-string, with nowhere to pass a key, so a subclass supplying it via a constructor of only class-typed parameters is the documented pattern.Kinetis\AuthJwt\JwtUser— wraps the decoded claims (stdClass).id()readssub, throwing if it’s missing or non-scalar;claim(string)/claims()expose the rest.Kinetis\AuthJwt\JwtIssuer—issue(string|int $subject, array $claims = [], ?int $ttlSeconds = 3600): string, signing with the same key/algorithmJwtAuthMiddlewareverifies against.sub/iat/exp/jti(a random unique token ID) always win over a same-named entry in$claims. An optional constructor$kidis written into the token’s own header, forJwtAuthMiddleware’s multi-key$keymap to select against.Kinetis\AuthJwt\RevocationStore— aPsr\SimpleCache\CacheInterface-backed denylist. Per-token:revoke(string $jti, int $ttlSeconds)is the primitive;revokeToken(JwtUser $user)derives the TTL from the token’s ownexpclaim automatically. Per-user (“log out everywhere”):revokeAllForUser(string|int $userId, int $ttlSeconds)stores an inclusiveiatcutoff;isRevokedForUser()compares a token’s owniatagainst it. Requires a real cache — construction overNullSimpleCachethrowsException\RevocationUnavailableException, since a denylist that never stores anything would let every revoked token stay valid until natural expiry.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): stringstoressha256(token) => {subject, claims, issuedAt};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, claims}ornull.revoke(string $token): voidinvalidates one token directly.revokeAllForUser(string|int $userId, int $ttlSeconds): voidstores a per-subject cutoff timestamp (the same mechanismRevocationStore::revokeAllForUser()uses) rather than an enumerated list —redeem()checks a token’s ownissuedAtagainst its subject’s latest cutoff, inclusive. 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.Kinetis\AuthJwt\JwkSet::fromRsaPublicKeys(array $publicKeysByKid, string $algorithm = 'RS256'): array— builds an RFC 7517 JWK Set ({"keys": [...]}) from one or more PEM-format RSA public keys viaopenssl_pkey_get_public()/openssl_pkey_get_details(), base64url-encoding the modulus/exponent. RSA only; throwsException\JwkSetExceptionfor an invalid PEM or a non-RSA key.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/write(id, data, lifetimeSeconds)/destroy(id). Payloads are JSON-serializedarray<string, mixed>, never PHPserialize(); expiry is the store’s own job; concurrency is declared 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 cache store leaves it out because its backend expires entries itself.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()(generated on first use, stored in the session),regenerate()(fresh id, same data, old payload destroyed — the fixation defense),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.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\CacheSessionStore— over PSR-16, the backend’s TTL as expiry; rejectsNullSimpleCacheat construction (a store that never stores means logins that silently don’t stick). Redis sessions are this store plus theCacheInterfacebindingkinetis/cache-redisalready provides.Kinetis\Session\Store\SqlSessionStore—kinetis_sessions(id/payload/expires_at) over the genericSqlLinkcontract; dialect-agnostic SQL. The upsert is UPDATE-then-INSERT with a catch-and-re-UPDATE on a primary-key collision — surviving both MySQL’s 0-affected-rows-on-identical-values report and racing first-writes. Expired rows stay untilgc()(thesession:gccommand) deletes them. Migration stubs inresources/migrations/, never auto-created.Kinetis\Session\Middleware\SessionMiddleware— route middleware only (theBearerAuthMiddlewarestructural rule): reads the cookie (id validated, tampered values get a fresh session), registers a lazySessionon the scope, and afterwards commits + sets the cookie only when needed. 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. ReadscookieParamsfirst with the rawCookieheader as fallback, soTestClient-built requests work by setting the header.Kinetis\Session\Middleware\CsrfMiddleware— synchronizer-token check on non-GET/HEAD/OPTIONS, viaX-CSRF-Tokenheader or a form body’s_token,hash_equals()comparison,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 thecachedriver consume the boot-timeCacheInterfacebinding and thesqldriver consume persistence’s link binding regardless of bootstrap order). Unknown driver throws naming the valid set;sqlwithout kinetis/persistence installed, or with no link bound, throws naming the fix.Depends on
kinetis/framework,psr/simple-cache,psr/http-message,psr/http-server-middleware;kinetis/persistence/kinetis/cache-redisonly inrequire-dev— store classes load lazily. Owncomposer.json/phpunit.xml/phpstan.neon.
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.
Migrations, Query Builder, Queue, Queue (Redis), Queue (SQL), Queue (SQS), Queue (RabbitMQ), Storage, Storage (S3), HTTP Client, AWS request signing (SigV4), Mailer, Search (OpenSearch), Authentication, JWT Authentication — the task-oriented page for each package above.