- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Your PHP-FPM worker takes a request. Somewhere in the handler it calls a payment API. cURL does a DNS lookup, opens a TCP socket, runs a full TLS handshake, sends the request, reads the response. Then the request ends. The connection dies with it.
Next request lands on the same worker. Same host, same certificate, same everything. And cURL does the DNS lookup, the TCP handshake, and the TLS handshake all over again. Three round trips of setup you already paid for a few milliseconds ago, thrown away because the process model says a request owns nothing past its own lifetime.
PHP 8.5 gives you a way out of that with curl_share_init_persistent(). It lets a share handle survive across requests inside the same worker, so DNS results, TLS sessions, and live connections carry over.
What a share handle shared before 8.5
curl_share_init() has existed for years. You create a share handle, attach it to several cURL handles with CURLOPT_SHARE, and they pool the resources you asked for.
$sh = curl_share_init();
curl_share_setopt($sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS);
curl_share_setopt($sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT);
$a = curl_init('https://api.example.com/v1/ping');
$b = curl_init('https://api.example.com/v1/pong');
curl_setopt($a, CURLOPT_SHARE, $sh);
curl_setopt($b, CURLOPT_SHARE, $sh);
That works. The catch is the lifetime. The share handle is a normal PHP value, so it gets freed when the request ends. Any connection cached inside it goes with it. Two calls in the same request could share a socket; two calls in different requests never could.
For a lot of PHP work that limit is invisible, because most handlers make one outbound call and move on. It shows up the moment a worker hits the same host over and over: a payment gateway, an internal service, an LLM endpoint you poll in a loop.
What curl_share_init_persistent() changes
The 8.5 function takes the set of things to share and returns a handle that outlives the request.
$share = curl_share_init_persistent([
CURL_LOCK_DATA_DNS,
CURL_LOCK_DATA_CONNECT,
CURL_LOCK_DATA_SSL_SESSION,
]);
$ch = curl_init('https://api.example.com/v1/charge');
curl_setopt($ch, CURLOPT_SHARE, $share);
curl_exec($ch);
Two details matter here.
First, the share options go in as an array. No separate curl_share_setopt() calls. You declare up front what the pool holds.
Second, the persistent handle is keyed by that exact set of options. Call curl_share_init_persistent() again in a later request with the same array, and PHP looks in the worker's persistent memory, finds the existing handle, and hands it back instead of building a new one. Ask for a different set of options and you get a different pool. That's the whole matching rule.
The connection cache lives in CURL_LOCK_DATA_CONNECT. Because the pool now persists, an open keep-alive socket to api.example.com is still there when the next request runs on the same worker. That request skips the TCP and TLS handshakes and writes straight onto the warm connection.
Putting it behind an HTTP adapter
If you build with ports and adapters, the domain talks to an HttpClient interface and never sees cURL. The persistent share belongs inside the adapter, created once and held for the worker's life.
interface HttpClient
{
public function get(string $url): HttpResponse;
}
final class PersistentCurlHttpClient implements HttpClient
{
private CurlSharePersistentHandle $share;
public function __construct()
{
$this->share = curl_share_init_persistent([
CURL_LOCK_DATA_DNS,
CURL_LOCK_DATA_CONNECT,
CURL_LOCK_DATA_SSL_SESSION,
]);
}
public function get(string $url): HttpResponse
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_SHARE => $this->share,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$body = curl_exec($ch);
if ($body === false) {
throw new HttpTransportException(curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
return new HttpResponse($status, (string) $body);
}
}
Register that adapter as a singleton in your container. Under PHP-FPM the object rebuilds every request, but the constructor call to curl_share_init_persistent() returns the same underlying pool each time, so the connection cache carries over even though the PHP object does not. The domain keeps calling $client->get(...) and knows nothing about any of it.
One note on the handle object: you no longer need curl_close() in PHP 8. $ch is a CurlHandle, and it frees itself once it goes out of scope. Freeing it does not close the pooled connection, because the connection belongs to the share, not the request handle.
Proving the reuse is real
Don't take the feature on faith. curl_getinfo() reports the timing breakdown per request, and the reused-connection case has a distinct shape you can watch for.
$ch = curl_init('https://api.example.com/v1/ping');
curl_setopt($ch, CURLOPT_SHARE, $share);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_exec($ch);
$info = curl_getinfo($ch);
printf(
"dns=%.4f connect=%.4f tls=%.4f total=%.4f\n",
$info['namelookup_time'],
$info['connect_time'],
$info['appconnect_time'],
$info['total_time'],
);
These fields are cumulative seconds measured from the start of the transfer. On a cold call you see namelookup_time, then connect_time higher than it (the TCP handshake), then appconnect_time higher again (the TLS handshake).
On a call that reuses a pooled connection, connect_time collapses down to the namelookup_time value and appconnect_time collapses down to connect_time. No new socket, no new handshake, so those deltas go to roughly zero. That gap is the setup cost the persistent share is saving you. Log these four numbers across a few requests to the same host and the second-request drop is visible without a benchmark rig.
Where it pays off is exactly the hot path: a worker calling one upstream on most requests. The first request on a fresh worker still pays full setup. Every request after it on that worker rides the warm connection until the keep-alive idles out or the server closes it.
The cookie trap
One option is off the table, and PHP enforces it for you. CURL_LOCK_DATA_CONNECT, CURL_LOCK_DATA_SSL_SESSION, CURL_LOCK_DATA_DNS, and CURL_LOCK_DATA_PSL are safe to share across requests. Pass CURL_LOCK_DATA_COOKIE and curl_share_init_persistent() throws a ValueError instead of building the pool.
The reason it is blocked is worth understanding. A persistent cookie pool would hold cookies from one request and hand them to the next. If your worker makes calls on behalf of different users, request B could send request A's session cookie to the upstream. That is a cross-user data leak baked into your transport layer. The RFC calls this out, and the shipped engine turns it into a hard error. Manage cookies per request, per user, with an explicit cookie jar you control.
Gotchas worth knowing before you ship
- Scope is one worker. Each FPM worker holds its own persistent pool. Ten workers mean up to ten warm connections to a host, not one shared across the pool. That is usually fine; size your upstream's connection limits with the worker count in mind.
- CLI gets little. A one-shot CLI script is a single process with a single request, so there's no later request to reuse anything. This helps long-lived SAPIs like FPM, or workers that loop.
-
Keep-alive has to be on. If the upstream sends
Connection: close, there is no socket to pool. The DNS and TLS-session caches still help, but the TCP reuse won't happen. - The option set is the key. Two adapters that pass different arrays get different pools. Pick one option set per upstream concern and stick with it so the reuse actually lands.
Connection reuse is a transport concern. It lives in the adapter, at the edge of your system, and nothing about a charge, an order, or a user should ever mention a share handle. Keeping that concern where it belongs is what lets you swap cURL for something else later without the domain noticing. That boundary between framework-and-transport plumbing and the code that models your business is the whole subject of Decoupled PHP.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)