PHP-FPM’s shared-nothing request model is the reason PHP scales the way it does, and it is also the reason every request starts by paying a tax it already paid five seconds ago, because the process that just served request ten thousand still opens a fresh TCP connection, negotiates TLS again, and resolves the same hostname again when request ten thousand and one arrives. For a database client that speaks HTTP to a daemon on the same host or the same rack, that handshake tax is pure waste; the query itself might take two milliseconds while the connection setup around it takes twenty, and until PHP 8.5 there was no clean way for userland code to carry that connection state across the boundary between one FPM request and the next. PHP 8.5, released in November 2025, added curl_share_init_persistent, and the MongrelDB PHP client uses it when it is there.
What a persistent share handle actually shares
A classic cURL share handle lets handles inside one request reuse DNS results, TLS session tickets, and the connection pool, which is what the client has always done internally with its per-request handle pool keyed by host. The persistent variant takes the same idea and lifts the lifetime, so the shared state survives the request and is waiting for the next FPM invocation that hits the same daemon; PHP deduplicates persistent share handles by their option set, which means creating one per client instance is cheap, because the second call hands you back the first handle rather than building a new one.
The three things worth sharing are DNS resolution, TLS sessions, and the connection pool itself, exposed as CURL_LOCK_DATA_DNS, CURL_LOCK_DATA_SSL_SESSION, and CURL_LOCK_DATA_CONNECT, and each one removes a different slice of the handshake tax. DNS sharing kills the resolver round trip, TLS session resumption kills most of the handshake, and connection-pool sharing kills the TCP setup entirely when an idle connection is still warm, which for a mongreldb-server sitting on loopback or one hop away is the difference between a query that costs two milliseconds and a query that costs two milliseconds wrapped in twenty milliseconds of ceremony.
Turning it on
The feature is off by default and opt-in, because a client library has no business silently carrying state across your requests, so you flip it with one constructor argument:
use Visorcraft\MongrelDB\Database;
// Sensible defaults: DNS + TLS sessions + connection pool
$db = new Database('http://127.0.0.1:8453', persistentSharing: true);
If you want to decide exactly what gets shared, pass an explicit list of CURL_LOCK_DATA_* constants at the transport layer instead:
use Visorcraft\MongrelDB\MongrelDB;
use Visorcraft\MongrelDB\Transport\CurlTransport;
// Share DNS only, keep everything else per-request
$transport = new CurlTransport(persistentSharing: [\CURL_LOCK_DATA_DNS]);
$client = new MongrelDB('http://127.0.0.1:8453', token: 'secret', transport: $transport);
$db = new Database(client: $client);
The defaults include CURL_LOCK_DATA_CONNECT, the connection-pool sharing option, only when the constant is defined, because it needs libcurl 7.67 or newer underneath; the client checks with defined() rather than trusting the PHP version number, since the version of PHP and the version of libcurl it was built against are two different facts.
The 8.4 fallback and the cookie guard
The client supports PHP 8.4 as its floor, and it cannot require a function that did not exist until 8.5, so the detection is a plain function_exists('curl_share_init_persistent') at the moment the share handle would be created. On 8.4 the option quietly degrades to the per-request pooling the client always had, no exception, no warning, and no behavior change, which is exactly how a version-gated feature should behave when the floor is one release behind.
One share option is rejected outright, and it is rejected at construction time rather than at first request: CURL_LOCK_DATA_COOKIE. PHP itself refuses the same option by throwing a ValueError, because cookies carried across requests would leak session state between unrelated callers, and a stateless database client has no use for cookies in the first place, so the client goes one step earlier and throws its own InvalidArgumentException the moment you build the transport. That guard deliberately does not depend on the 8.5 runtime API, which means it catches the mistake on 8.4 too, where the persistent handle would otherwise be skipped and the bad option would sit there looking harmless.
Where the win shows up, and where it does not
The honest tradeoff is that this is a latency feature, not a throughput feature, and it earns its keep wherever each request rebuilds the client from scratch: PHP-FPM serving many short requests against the same daemon is the canonical case, where the first query of each request used to pay full connection setup. Under FrankenPHP or a long-running worker the picture changes but does not disappear, because process lifetime and handle lifetime are different facts: if your client is bootstrapped once in worker scope and its cURL handles already survive the request boundary, the per-request pool was never the bottleneck and persistent sharing adds little, while a client instantiated per request inside a worker still pays the setup each time and still benefits, which is exactly the long-lived-SAPI shape PHP built the feature for; under a CLI script that runs one migration and exits, persistent sharing buys nothing at all, since there is no next request to inherit the state.
That is also why the default stays off, because the client cannot know which of those shapes it is running inside, and opting in is one boolean once you do know. The older me, the one who tuned KeepAlive on Apache 1.3 boxes and measured handshake overhead with tcpdump because nothing better existed, would have loved having this switch; the modern equivalent is a constructor argument and a runtime check, and the result is the same as it ever was, which is that the fastest connection is the one you do not have to set up again.