What a PHP Framework Looks Like When It’s Designed for Persistent Workers and Non-Blocking I/O#

If you run PHP under FrankenPHP or RoadRunner, your framework’s state model matters more than its router. This is how Kinetis is built for persistent PHP workers from the first line, and what each design decision costs and buys.

PHP’s execution model is changing under the ecosystem’s feet. FrankenPHP, Swoole, and RoadRunner keep a process alive across requests: bootstrap once, stay warm, serve thousands. Most frameworks meet this world through an adapter: the framework was designed for boot-and-die, and a compatibility layer teaches it to survive persistence. Kinetis starts from the opposite end, on two premises that reinforce each other: persistent workers are the primary deployment, and I/O is non-blocking by design, not as an optional async add-on but as the framework’s native way of waiting. One public/index.php runs under FrankenPHP worker mode, RoadRunner, PHP-FPM and AWS Lambda; FrankenPHP worker mode is the primary target. Concrete answers below, each with the design decision it forced.

Request state in persistent PHP workers#

Boot-and-die PHP had one glorious property: it was impossible to leak state between requests, because the process died. Persistent workers repeal that guarantee. A static property, a memoized “current user,” a connection holding an open transaction: all of it now survives into the next request, carrying one user’s state into another’s.

Kinetis treats this as an architecture concern, not a code-review concern:

  • Two container scopes, enforced. Application scope holds what legitimately outlives requests (config, pools, compiled caches); request scope is created per request and disposed unconditionally.

  • Two PHPStan rules for your application code. NoStaticPropertiesRule flags every static property declaration, because a static is exactly how request state slips past request scope. The default answer to “can I stash this in a static?” is no. NoBlockingIoRule flags blocking sleeps, sockets, curl waits and hand-built database connections. Both are guardrails, not proofs, but they catch the common mistakes before review does.

  • TransactionGuard is the safety net for the sharpest leak: a request that opens a database transaction and fails before closing it. The disposal hook rolls back anything dangling, every request, because connection pools have no concept of “your request ended.” See persistence.

Non-blocking I/O with Fibers and the Revolt event loop#

Every waiting primitive in Kinetis is built on one pattern: register a watcher on the Revolt event loop, suspend the current Fiber, resume when the condition fires. Sockets, timers, the database drivers, the Redis client, the HTTP client: different APIs, one loop, one way of waiting.

This is a stronger claim than “it has an async layer.” A framework where async is an add-on keeps blocking calls in its core paths, and a single blocking call inside a Fiber doesn’t become non-blocking. It blocks the entire worker, invisibly, defeating every concurrent task sharing that thread. Designing non-blocking-first means the framework refuses that failure mode structurally: the clients Kinetis ships or adopts are chosen because they suspend Fibers on the same loop. Where an extension gives no way to do that, the docs say so. A mysqli connect blocks, because mysqli has no asynchronous connect, and so does a libpq host lookup. The full list is part of the database reference.

The payoff is composability. concurrently() fans out independent work on Fibers, and because every client waits the same way, a single batch can mix a MySQL query, a Postgres query, a Redis read, and an outbound HTTP call, and complete in roughly the slowest one’s time, not the sum. Given a connection pool as wide as the fan-out, twenty queries cost one round trip of wall time. A dashboard aggregating four backends costs its worst backend. No worker-pool gymnastics, no promises API: closures in, results out, in order.

One boundary stated plainly, because eliding it is how async PHP gets oversold: a worker thread (or a RoadRunner process) still handles one HTTP request at a time. Cross-request concurrency comes from the worker count, same as FPM’s; non-blocking I/O is what makes the inside of each request parallel. The two pillars divide the work. Persistence amortizes what can be kept, and non-blocking I/O overlaps what must be waited for.

The subtle part is what a decade of thread-free PHP never had to teach: Fibers on ZTS threads share a process address space, and allocating a Fiber stack per task serializes every thread on the kernel’s memory-map lock. Kinetis runs tasks on resident Fibers that park between tasks instead of terminating, in a pool per thread reused across tasks and requests. Persistence isn’t just “skip the bootstrap”; it’s a license to keep every expensive-to-create thing alive, including the machinery of concurrency itself.

Match the database driver to the runtime, automatically#

The same application code needs opposite database strategies in the two runtimes:

  • Under a persistent worker: connections amortize across requests, so pay for a real async client. MySQL queries run on native mysqli connections from a per-thread pool and overlap with each other; mysqli can’t hand its socket to the event loop, so the driver polls it in short windows. Postgres goes further: pg_socket() exposes a real file descriptor, so that driver is event-driven end to end, connect included.

  • Under PHP-FPM: every request pays a fresh connection and dies; an async client buys nothing and costs CPU. The same contracts run on plain lazy PDO, with one lesson learned the hard way. Native prepares cost a server round trip each, so prepared statements are memoized per connection. A twenty-query loop pays 21 round trips, not 40. That single cache tripled the FPM deployment’s fan-out throughput.

Selection is DB_DRIVER=auto: the runtime is detected, the driver matches it, application code doesn’t change. The split is measured, not aesthetic. Forcing either driver into the other’s runtime measures worse.

Sizing connection pools for worker threads#

Persistent workers multiply resources that FPM counted singly. Each worker thread builds its own connection pool, so the real ceiling is threads × maxConnections against the database’s max_connections. That is a budget, not a knob. On 8 vCPUs, 20 worker threads × 12 connections = 240 against MySQL’s default of about 1,000 is the measured joint optimum across read-heavy and write-heavy routes. Overshoot it and the surplus connection is rejected mid-request, which makes it a correctness problem, not a performance one. The sizing method differs by workload: threads at 2 to 3× vCPUs and a wide pool for read fan-out, restraint for writes, and the reasons why. The framework’s performance documentation teaches the interaction, because that’s the part no database manual covers.

Compiling discovery ahead of time, without secrets#

Persistent workers amortize application boot; compiled discovery removes reflection from its hot path. kinetis build compiles routes and validation plans, MCP tools and resources, commands and event listeners into one plain PHP artifact loaded at boot. It is useful under workers and transformative under FPM, where it replaces per-request reflection. Publishing is all-or-nothing: a failed compile never replaces a cache a previous build produced.

The build command never runs the application’s bootstrap, so pre-warming the artifact in a pipeline requires no database credentials, no API keys, nothing the application’s services demand. #[Command(bootstrap: false)] opts any static-shape command of your own out of application boot the same way. Cache compilation with production secrets absent is table stakes for real pipelines, and it’s easy to get wrong when the framework assumes “boot the app” is free.

The same application under PHP-FPM#

Everything above degrades deliberately. Under PHP-FPM the same application runs unmodified, with request scope per request, PDO drivers, compiled caches, and concurrently() returning correct results with sequential execution. The benchmark suite runs Kinetis both ways precisely to keep that promise measurable. On FrankenPHP worker mode, Kinetis leads five of six TechEmpower-style tests against eight other PHP frameworks, and loses /updates to Slim. On PHP-FPM, the identical application still leads every full framework on /json, /plaintext and /db and is level on /fortunes; it gives ground only on /queries and /updates, where a blocking driver has nothing to overlap. The rig, every implementation and the fairness rules are public in the benchmark repository.

The position#

PHP’s persistent-worker era doesn’t need faster adapters bolted onto boot-and-die designs. It needs the assumptions inverted: state lifetimes enforced by scopes, waiting done by suspending Fibers instead of blocking threads, drivers chosen by runtime, resources budgeted per thread, compilation ahead of time. Kinetis is one answer: a non-blocking core in a persistent runtime, built in the open, with an auditable benchmark, a documented tuning method, and its worst test result printed in the same table as its best.

To try it, start with the tutorial or the documentation.