<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <id>https://kinetis.dev/</id>
  <title>Kinetis blog</title>
  <updated>2026-10-24T16:18:27.127807+00:00</updated>
  <link href="https://kinetis.dev/"/>
  <link href="https://kinetis.dev/blog/atom.xml" rel="self"/>
  <generator uri="https://ablog.readthedocs.io/" version="0.11.13">ABlog</generator>
  <entry>
    <id>https://kinetis.dev/blog/posts/designed-for-persistent-workers.html</id>
    <title>What a PHP Framework Looks Like When It’s Designed for Persistent Workers and Non-Blocking I/O</title>
    <updated>2026-10-24T00:00:00+00:00</updated>
    <author>
      <name>Kinetis</name>
    </author>
    <content type="html">&lt;section id="what-a-php-framework-looks-like-when-it-s-designed-for-persistent-workers-and-non-blocking-i-o"&gt;

&lt;p&gt;If you run PHP under FrankenPHP or RoadRunner, your framework’s state
model matters more than its router. This is how
&lt;a class="reference external" href="https://kinetis.dev/"&gt;Kinetis&lt;/a&gt; is built for persistent PHP workers
from the first line, and what each design decision costs and buys.&lt;/p&gt;
&lt;p&gt;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: &lt;strong&gt;persistent workers are the primary
deployment&lt;/strong&gt;, and &lt;strong&gt;I/O is non-blocking by design&lt;/strong&gt;, not as an
optional async add-on but as the framework’s native way of waiting.
One &lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;public/index.php&lt;/span&gt;&lt;/code&gt; runs under
&lt;a class="reference external" href="https://kinetis.dev/docs/runtime-adapters.html"&gt;FrankenPHP worker mode, RoadRunner, PHP-FPM and AWS Lambda&lt;/a&gt;;
FrankenPHP worker mode is the primary target. Concrete answers below,
each with the design decision it forced.&lt;/p&gt;
&lt;section id="request-state-in-persistent-php-workers"&gt;
&lt;h2&gt;Request state in persistent PHP workers&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Kinetis treats this as an architecture concern, not a code-review
concern:&lt;/p&gt;
&lt;ul class="simple"&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a class="reference external" href="https://kinetis.dev/docs/container.html"&gt;Two container scopes&lt;/a&gt;, enforced.&lt;/strong&gt;
Application scope holds what legitimately outlives requests (config,
pools, compiled caches); request scope is created per request and
disposed unconditionally.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Two PHPStan rules for your application code.&lt;/strong&gt;
&lt;a class="reference external" href="https://kinetis.dev/docs/container.html#nostaticpropertiesrule-the-enforcement-php-itself-can-t-provide"&gt;&lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;NoStaticPropertiesRule&lt;/span&gt;&lt;/code&gt;&lt;/a&gt;
flags every &lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;static&lt;/span&gt;&lt;/code&gt; 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.
&lt;a class="reference external" href="https://kinetis.dev/docs/concurrency.html#flagging-blocking-calls-with-phpstan"&gt;&lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;NoBlockingIoRule&lt;/span&gt;&lt;/code&gt;&lt;/a&gt;
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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;TransactionGuard&lt;/span&gt;&lt;/code&gt;&lt;/strong&gt; 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 &lt;a class="reference external" href="https://kinetis.dev/docs/persistence.html"&gt;persistence&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;
&lt;section id="non-blocking-i-o-with-fibers-and-the-revolt-event-loop"&gt;
&lt;h2&gt;Non-blocking I/O with Fibers and the Revolt event loop&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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 &lt;em&gt;entire worker&lt;/em&gt;, 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.
&lt;a class="reference external" href="https://kinetis.dev/docs/appendix-database.html#database-reference-native-io"&gt;The full list&lt;/a&gt;
is part of the database reference.&lt;/p&gt;
&lt;p&gt;The payoff is composability.
&lt;a class="reference external" href="https://kinetis.dev/docs/concurrency.html"&gt;&lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;concurrently()&lt;/span&gt;&lt;/code&gt;&lt;/a&gt; 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
&lt;em&gt;slowest&lt;/em&gt; 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.&lt;/p&gt;
&lt;p&gt;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
&lt;em&gt;inside&lt;/em&gt; 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.&lt;/p&gt;
&lt;p&gt;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
&lt;a class="reference external" href="https://kinetis.dev/docs/appendix-runtime.html#resident-fibers"&gt;resident Fibers&lt;/a&gt;
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 &lt;em&gt;every&lt;/em&gt; expensive-to-create thing
alive, including the machinery of concurrency itself.&lt;/p&gt;
&lt;/section&gt;
&lt;section id="match-the-database-driver-to-the-runtime-automatically"&gt;
&lt;h2&gt;Match the database driver to the runtime, automatically&lt;/h2&gt;
&lt;p&gt;The same application code needs opposite database strategies in the
two runtimes:&lt;/p&gt;
&lt;ul class="simple"&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Under a persistent worker&lt;/strong&gt;: 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: &lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;pg_socket()&lt;/span&gt;&lt;/code&gt; exposes a
real file descriptor, so that driver is event-driven end to end,
connect included.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Under PHP-FPM&lt;/strong&gt;: 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
&lt;a class="reference external" href="https://kinetis.dev/docs/appendix-database.html#prepared-statements-on-pdo"&gt;prepared statements are memoized per connection&lt;/a&gt;.
A twenty-query loop pays 21 round trips, not 40. That single cache
tripled the FPM deployment’s fan-out throughput.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Selection is
&lt;a class="reference external" href="https://kinetis.dev/docs/persistence.html#driver-selection"&gt;&lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;DB_DRIVER=auto&lt;/span&gt;&lt;/code&gt;&lt;/a&gt;:
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.&lt;/p&gt;
&lt;/section&gt;
&lt;section id="sizing-connection-pools-for-worker-threads"&gt;
&lt;h2&gt;Sizing connection pools for worker threads&lt;/h2&gt;
&lt;p&gt;Persistent workers multiply resources that FPM counted singly. Each
worker thread builds its own connection pool, so the real ceiling is
&lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;threads&lt;/span&gt; &lt;span class="pre"&gt;×&lt;/span&gt; &lt;span class="pre"&gt;maxConnections&lt;/span&gt;&lt;/code&gt; against the database’s &lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;max_connections&lt;/span&gt;&lt;/code&gt;.
That is &lt;a class="reference external" href="https://kinetis.dev/docs/performance-tuning.html#the-connection-budget"&gt;a budget&lt;/a&gt;,
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
&lt;a class="reference external" href="https://kinetis.dev/docs/performance-tuning.html#tuning-by-workload-shape"&gt;differs by workload&lt;/a&gt;:
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 &lt;em&gt;interaction&lt;/em&gt;, because that’s the part no
database manual covers.&lt;/p&gt;
&lt;/section&gt;
&lt;section id="compiling-discovery-ahead-of-time-without-secrets"&gt;
&lt;h2&gt;Compiling discovery ahead of time, without secrets&lt;/h2&gt;
&lt;p&gt;Persistent workers amortize application boot; compiled discovery
removes reflection from its hot path.
&lt;a class="reference external" href="https://kinetis.dev/docs/cli.html#kinetis-build"&gt;&lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;kinetis&lt;/span&gt; &lt;span class="pre"&gt;build&lt;/span&gt;&lt;/code&gt;&lt;/a&gt;
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
&lt;a class="reference external" href="https://kinetis.dev/docs/caching.html"&gt;cache&lt;/a&gt; a previous build
produced.&lt;/p&gt;
&lt;p&gt;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.
&lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;#[Command(bootstrap:&lt;/span&gt; &lt;span class="pre"&gt;false)]&lt;/span&gt;&lt;/code&gt; 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.&lt;/p&gt;
&lt;/section&gt;
&lt;section id="the-same-application-under-php-fpm"&gt;
&lt;h2&gt;The same application under PHP-FPM&lt;/h2&gt;
&lt;p&gt;Everything above degrades deliberately. Under PHP-FPM the same
application runs unmodified, with request scope per request, PDO
drivers, compiled caches, and &lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;concurrently()&lt;/span&gt;&lt;/code&gt; returning correct
results with sequential execution. The
&lt;a class="reference external" href="https://kinetis.dev/docs/benchmarks.html"&gt;benchmark suite&lt;/a&gt; runs
Kinetis both ways precisely to keep that promise measurable. On
FrankenPHP worker mode, Kinetis
&lt;a class="reference external" href="https://kinetis.dev/docs/benchmarks.html#who-leads-each-test"&gt;leads five of six&lt;/a&gt;
TechEmpower-style tests against eight other PHP frameworks, and loses
&lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;/updates&lt;/span&gt;&lt;/code&gt; to Slim. On PHP-FPM, the identical application still leads
every full framework on &lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;/json&lt;/span&gt;&lt;/code&gt;, &lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;/plaintext&lt;/span&gt;&lt;/code&gt; and &lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;/db&lt;/span&gt;&lt;/code&gt; and is level
on &lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;/fortunes&lt;/span&gt;&lt;/code&gt;; it gives ground only on &lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;/queries&lt;/span&gt;&lt;/code&gt; and &lt;code class="docutils literal notranslate"&gt;&lt;span class="pre"&gt;/updates&lt;/span&gt;&lt;/code&gt;,
where a blocking driver has nothing to overlap. The rig, every
implementation and the fairness rules are public in the
&lt;a class="reference external" href="https://github.com/aln-1/kinetis-benchmarks"&gt;benchmark repository&lt;/a&gt;.&lt;/p&gt;
&lt;/section&gt;
&lt;section id="the-position"&gt;
&lt;h2&gt;The position&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;To try it, start with the &lt;a class="reference external" href="https://kinetis.dev/docs/tutorial.html"&gt;tutorial&lt;/a&gt;
or the &lt;a class="reference external" href="https://kinetis.dev/docs/"&gt;documentation&lt;/a&gt;.&lt;/p&gt;
&lt;/section&gt;
&lt;/section&gt;
</content>
    <link href="https://kinetis.dev/blog/posts/designed-for-persistent-workers.html"/>
    <summary>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.</summary>
    <category term="fibers" label="fibers"/>
    <category term="frankenphp" label="frankenphp"/>
    <category term="performance" label="performance"/>
    <category term="php" label="php"/>
    <category term="roadrunner" label="roadrunner"/>
    <published>2026-10-24T00:00:00+00:00</published>
  </entry>
</feed>
