The FPM-versus-worker argument keeps coming back because your application code pays a genuine tax under the wrong execution model, either bootstrapping a full framework on every request under classic FPM or auditing for leaked state once the process goes resident under FrankenPHP, RoadRunner, or Swoole, and that tax is large enough that the choice deserves every blog post it gets. What does not deserve a seat in that argument is your database client, at least not when the database runs in its own process and the client speaks plain HTTP to it, because then everything the runtime model affects, connection reuse, TLS session resumption, DNS caching, protocol state, lives either in the daemon or in a small curl handle pool, and your PHP code is the same three lines under all three runtimes.

Why the runtime argument is real for app code

Under traditional FPM every request starts cold, so the framework boots, the container compiles, the service providers register, and only then does your controller run, which is why a stock Laravel hello-world can spend tens of milliseconds doing work that has nothing to do with the request. FrankenPHP’s worker mode and the RoadRunner school of thought fix this by keeping the application resident and looping over requests inside one process, which deletes the bootstrap cost entirely but hands you a new job in return, because now every static, every singleton, and every forgotten global is state that leaks from one request into the next, and the bug reports from that migration are their own genre.

That tradeoff is worth having meetings about. The mistake is dragging the database layer into the meeting as if it were part of the same decision, which it only is when the client library holds native per-request state, the way the old mysql_pconnect era tied connection behavior to the process model and gave you different answers on FPM than on a threaded MPM.

The client holds almost nothing

visorcraft/mongreldb-php requires PHP 8.4 or newer and ext-curl, and that is the entire native footprint; there is no extension holding a socket open across requests, no background thread, and no connection object your code keeps alive past the request that created it, with the single opt-in exception being PHP 8.5’s persistent curl share handle, which gets its own section below. The client is a thin layer over HTTP calls to mongreldb-server, which is the process that actually owns the WAL, the indexes, the page cache, and the hard state, so the only thing a PHP process accumulates is a small pool of reusable curl handles keyed by host, and even that is a performance detail rather than a correctness one.

Here is the whole client lifecycle under classic FPM, where everything dies with the request and nobody has to think about it:

use Visorcraft\MongrelDB\Database;

$db = new Database('http://127.0.0.1:8453', token: $token);

$rows = $db->query('orders')
    ->where('pk', ['value' => 1])
    ->execute();

And here is FrankenPHP’s worker mode, the canonical resident-process shape, with the same client sitting outside the request loop exactly where the framework sits:

use Visorcraft\MongrelDB\Database;

$db = new Database('http://127.0.0.1:8453', token: $token);

$handler = static function () use ($db) {
    $rows = $db->query('orders')
        ->where('pk', ['value' => 1])
        ->execute();

    // render $rows
};

$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0);
for ($n = 0; !$maxRequests || $n < $maxRequests; $n++) {
    $keepRunning = \frankenphp_handle_request($handler);
    if (!$keepRunning) {
        break;
    }
}

Nothing about the second version is database code; the $db instance is safe to capture because it carries no mutable per-request state beyond that handle pool, so a resident process does not turn it into a leak vector the way a resident ORM unit-of-work does. RoadRunner looks the same for the same reason, since the wire contract is an HTTP POST and HTTP does not care which event loop dispatched it. Swoole earns one footnote, because under coroutines a blocking ext-curl call will stall the event loop unless you enable SWOOLE_HOOK_CURL, after which the same three lines work there too.

The one knob that knows about process lifetime

There is exactly one place where the client acknowledges how long your process lives, and it is the cURL layer rather than your code. Within a single request the CurlTransport pools keep-alive handles per host, so sequential calls in one request reuse the connection, and on PHP 8.5, which has been stable for a while now, the client can additionally create a persistent share handle that carries DNS results, TLS sessions, and the connection pool itself across requests, including across separate FPM invocations hitting the same daemon. You opt in at construction, and the option is a graceful no-op on 8.4, where it quietly falls back to per-request pooling:

$db = new Database(
    'http://127.0.0.1:8453',
    token: $token,
    persistentSharing: true, // PHP 8.5+: reuse DNS, TLS, connections across requests
);

The shape of this is deliberate, because the thing being shared is curl’s connection cache and not your application’s state, so a long-lived worker gets the reuse for free, an FPM pool gets it through the persistent share handle, and a shared host on 8.4 gets correct behavior with slightly colder connections, all from the same constructor call.

The honest tradeoffs

The cost of this arrangement is the one you would predict, which is that every query is an HTTP round trip with JSON on the wire, so a chatty data layer that issues hundreds of tiny queries per request will feel the per-call overhead more than it would over a binary protocol, and the fix there is the same fix it has always been, batch your statements and let commit() send one atomic payload instead of twenty small ones. The gain is that the deployment matrix collapses, since FPM, FrankenPHP, RoadRunner, a queue consumer, a cron script, and a serverless function all speak to the daemon identically, and moving your app between them is a runtime decision rather than a data-layer rewrite.

We used to pick process models around the database, back when persistent MySQL connections behaved differently under prefork than under threads and you tuned max_children against max_connections like a pair of dials on the same machine. The modern equivalent is simpler: pick the runtime that your application code wants, give the daemon its own process and its own port, and let the client be the boring part, because boring is what a transport is supposed to be.