There is a pull request in php-src, number 15603, that would have let you name your own persistent curl share with an arbitrary ID string and then mutate it later through curl_share_setopt(). It was rejected. The replacement, PR 16937, derives the identifier from the options themselves, makes the returned object immutable, and throws a ValueError the moment you ask it to share cookies. That version won the vote and became curl_share_init_persistent() in PHP 8.5. Laurent Mn benchmarked the result on a self-compiled 8.5.6 build and got a lovely number out of it. I want to argue that the number is the smaller story and the rejected pull request is the bigger one.

persistent-share.php
$share = curl_share_init_persistent([
    CURL_LOCK_DATA_SSL_SESSION,
    CURL_LOCK_DATA_CONNECT,
]);

$ch = curl_init('https://api.example.com/orders');
curl_setopt($ch, CURLOPT_SHARE, $share);
curl_exec($ch);

Here is the mechanism, because you cannot judge the design without it. The curl extension holds a hash table called persistent_curlsh in its module globals. That table is built in PHP_GINIT_FUNCTION and torn down in PHP_GSHUTDOWN_FUNCTION, which means it is created when the worker boots and destroyed when the worker dies, and a normal request cycle never resets it. Your option array is folded into a four bit mask: DNS at bit 0, SSL session at bit 1, the connection cache at bit 2, PSL at bit 3. PHP looks that mask up, hands you a cheap wrapper object around whatever CURLSH it finds, and builds a new one only if nothing matches. Calling the function on every single request is not a leak, it is the documented usage.

Now look at what that identity scheme buys. You cannot collide with another part of your codebase, because two callers asking for the same flags get the same underlying share by definition, and two callers asking for different flags cannot accidentally end up in the same pool. You cannot leak a session cookie from one visitor into another visitor's response, because CURL_LOCK_DATA_COOKIE is simply not on the accepted list alongside DNS, SSL_SESSION, CONNECT and PSL. You cannot reconfigure a share that three other requests already hold a reference to, because the handle takes no setopt at all. Every one of those guarantees exists because someone on the internals list argued for the mutable version first and lost.

The honest counter-argument is that this costs people something. There are shops with a genuine need to share a cookie jar across outbound calls on a worker, and for them the answer is a flat no with no escape hatch, not even an opt-in ini flag. Immutability also means you cannot tune one persistent share differently from another once it exists. If you are the kind of team that reads the C source before shipping, and the author here did exactly that in ext/curl/share.c rather than trusting a fresh manual page, the mutable API would have been fine in your hands. Standard library APIs are not written for that team, though. They are written for the codebase where someone copies a snippet from a blog into a controller at 18:40 on a Friday, and in that codebase a shared cookie jar is a security incident with a CVE-shaped ending.

Which brings me to why I distrust leading with the benchmark. The measured result is a steady state at roughly a ninth of baseline, an 88 to 89 percent cut, with the TLS handshake step falling to literal zero after warmup and a standard deviation of 0.024 ms. But the whole thing ran over loopback against a Python TLS server on 127.0.0.1:8443, and the author says so plainly rather than dressing it up. He also had to set disable_nagle_algorithm to kill a delayed-ACK artifact that had inflated an earlier pass by 40 ms, which tells you how easily a microbenchmark measures the wrong thing. The percentage is a property of that setup. What generalises is the mechanism: DNS lookup, TCP connect and TLS negotiation stop happening on every request after the first one, and across a real network those are worth far more in absolute milliseconds than loopback can show.

The operational caveats deserve equal billing with the win. Scope is per worker, so fifty FPM children means fifty cold starts after every deploy, each paid by whichever unlucky request lands on a fresh process. The first persistent-mode request in the test cost about 1.9 ms, indistinguishable from baseline, exactly as you would expect. Then pm.max_requests recycles the process and you pay it again, which turns a memory hygiene setting into a performance setting overnight. And CURLOPT_MAXCONNECTS changes meaning: the connection cache now serves a worker's entire lifetime rather than one request, so a limit sized for a single request will evict sockets you thought were warm. If your worker talks to two or three internal services all day, this is close to free money. If it hits a different unpredictable host each time, there is nothing to reuse and nothing to gain.

Two smaller things from the same release, worth knowing before someone files a confusing bug report. Comparing two handles with === returns false even for identical flags, because each call mints a fresh wrapper object around the same shared resource, so use == or just call the function again and let the lookup do its job. And curl_close() plus curl_share_close() are both deprecated in 8.5 now that CurlHandle and CurlShareHandle free themselves through garbage collection. If you are on Symfony, the wiring is a decorator around http_client that pushes CURLOPT_SHARE into the extra.curl bag, supported since 6.3, and it only does anything under CurlHttpClient. NativeHttpClient will ignore you politely.

So here is what I actually want to hear from you. Internals traded away a legitimate use case to make a misuse impossible, and I think they were right, but I hold that position from the comfort of never having needed a shared cookie jar across outbound requests. Have you? And more practically: do you know how many distinct downstream hosts one of your workers touches over its lifetime, or is that a number nobody on your team has ever pulled from the logs?