Reuse the TLS Handshake: PHP 8.5 Persistent cURL Share Handles in Laravel

curl_share_init_persistent lets PHP 8.5 share DNS, connections and TLS sessions between requests. Wire it into Laravel's HTTP client without breaking failover.

Steven Richardson
Steven Richardson
· 11 min read

Every outbound HTTPS call from a PHP request starts from nothing. Resolve the hostname, open a TCP connection, complete a TLS handshake, and only then send a byte of payload. A page that talks to a payment API, a search service and a CRM pays that three times, on every request, forever — and no amount of application-level optimisation touches it, because the time is spent before your code gets a response to work with.

curl_share_init() has been able to share that setup between handles since forever, but only inside a single PHP request, which is exactly the scope where there is the least to reuse. PHP 8.5 changes the scope. Here is what actually gets shared, how Laravel 13 exposes it, and the one operational assumption it quietly breaks.

Measure where the time actually goes#

Before wiring anything, split a single HTTPS call into its four phases so you know what fraction of the request you are actually chasing. curl_getinfo() gives you the microsecond keys; a cold call and a warm call to the same host tell you everything.

These are the real numbers Eric Norris — who wrote the RFC — posted against example.com in the Guzzle tracker, running the same script twice against a persistent share handle:

Phase Cold request Warm request
DNS (namelookup_time_us) 4,816 µs 0 µs
TCP connect (to connect_time_us) 131,058 µs 0 µs
TLS handshake (to appconnect_time_us) 151,161 µs 0 µs
Pre-transfer total (pretransfer_time_us) 288,131 µs 372 µs
Whole transfer (total_time_us) 461,809 µs 160,821 µs

287ms of the first request's 462ms was setup. On the second request it was 0.4ms. That is not a micro-optimisation — on a chatty integration it is most of the latency, and it is the same 287ms whether the response body is 500 bytes or 500KB.

Grab your own version of that table before you change anything:

$ch = curl_init('https://api.example.com/v1/ping');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_exec($ch);

$info = curl_getinfo($ch);

// Each figure is cumulative from the start of the transfer, so subtract
// the previous phase to get the cost of the phase itself.
printf(
    "dns=%dus tcp=%dus tls=%dus pretransfer=%dus total=%dus\n",
    $info['namelookup_time_us'],
    $info['connect_time_us'] - $info['namelookup_time_us'],
    $info['appconnect_time_us'] - $info['connect_time_us'],
    $info['pretransfer_time_us'],
    $info['total_time_us'],
);

If appconnect_time_us is already near zero on the second call in a request, Guzzle's within-request connection reuse is doing its job and the only thing left to win is the first call of each request. That is the number persistent sharing moves.

Create a persistent cURL share handle#

curl_share_init_persistent() takes a non-empty array of CURL_LOCK_DATA_* constants and returns a CurlSharePersistentHandle. Unlike curl_share_init(), the handle is not destroyed at request end — and if a persistent handle with the same set of share options already exists in the worker, you get that one back instead of a new one.

<?php

// Reused across SAPI requests in the same worker process.
$sh = 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/ping');
curl_setopt($ch, CURLOPT_SHARE, $sh);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

// May reuse the connection opened by an earlier request on this worker.
curl_exec($ch);

Four constants are safe here and documented as such in the RFC: CURL_LOCK_DATA_DNS, CURL_LOCK_DATA_CONNECT, CURL_LOCK_DATA_SSL_SESSION and CURL_LOCK_DATA_PSL. Connection sharing is safe because libcurl refuses to reuse a connection between handles whose connection settings differ — a handle with a client certificate will not be handed a connection opened without one.

The keying is worth internalising because it is not what the original RFC proposed. There is no persistent ID parameter: the handle is keyed by the set of share options alone. Two calls with [DNS, CONNECT] get the same handle. You cannot create a separate persistent share per upstream host by calling the function twice with identical options — and you do not need to, because libcurl's connection cache is keyed by host and connection settings anyway.

Understand why cookies are refused#

CURL_LOCK_DATA_COOKIE throws a ValueError. This is a deliberate safety decision, not a gap waiting for a patch, and it is worth stating plainly because the workaround people reach for is the actual vulnerability.

// ValueError: curl_share_init_persistent(): Argument #1 ($share_options)
// must not contain CURL_LOCK_DATA_COOKIE
curl_share_init_persistent([CURL_LOCK_DATA_COOKIE]);

A persistent share crosses request boundaries, and in a web app request boundaries are user boundaries. A shared cookie jar means the session cookie your worker picked up while acting for user A gets attached to the upstream call it makes for user B. The error is thrown at handle creation, so a service provider that tries it fails the whole boot rather than silently mixing two people's credentials — which is the correct trade. An empty array throws too, as does any integer that is not a CURL_LOCK_DATA_* constant; a non-integer gets you a TypeError.

If you actually need cookies across calls in one request, that is what a per-request Guzzle CookieJar is for, and PHP 8.5's partitioned cookie flag covers the browser-side half of the same problem.

Confirm your process outlives the request#

Persistence here means the life of the PHP process, not the life of the machine, so check what your runtime actually does before you expect a win. Under PHP-FPM a worker serves many requests before it recycles, which is exactly the shape this feature was built for. A one-shot CLI script exits immediately and there is no second request to benefit — so an Artisan command that makes three calls gains nothing beyond what plain curl_share_init() already gave it.

# The number that decides how much sharing you get back per worker.
php -i | grep -E 'pm.max_requests|pm.max_children'

pm.max_requests is the ceiling on how many requests can reuse a warm connection before the worker recycles and throws the share handle away. If it is set low — 100, say, to paper over a memory leak — you are re-handshaking every 100 requests per worker. Fix the leak instead; the same reasoning applies to the OPcache and memory tuning in tuning OPcache and preloading for Laravel in production.

If you are on Octane with FrankenPHP or RoadRunner, the process already lives across requests and Guzzle's own handler already survives, so the marginal gain is smaller — mostly it comes from sharing between separate client instances rather than from surviving request teardown. Measure it there rather than assuming; max_jobs and max_requests play the same recycling role that pm.max_requests does under FPM.

Turn on persistent transport in Laravel 13#

You almost certainly should not call curl_share_init_persistent() yourself in a Laravel app. Guzzle 8 wires it into the cURL handler, and Laravel 13 exposes that through a single call in AppServiceProvider::boot() — added in laravel/framework#60321.

use Illuminate\Http\Client\PersistentTransport;
use Illuminate\Support\Facades\Http;

public function boot(): void
{
    // Share connections when the runtime supports it; fall back silently otherwise.
    Http::globalPersistentTransport(PersistentTransport::Preferred);
}

The enum has three cases. None is the default and keeps today's behaviour. Preferred attempts to share and degrades quietly to handler-lifetime sharing when it cannot — the right choice for an app that has to boot on PHP 8.4 or a host without ext-curl. Required fails loudly when sharing is unavailable, which is what you want in a container image you control, because a silent fallback is a performance regression nobody notices for a quarter.

This needs guzzlehttp/guzzle ^8.0; Laravel 13 still allows Guzzle 7, and a lockfile pinned to 7.10 gets you the method and none of the benefit. Check before you celebrate:

composer show guzzlehttp/guzzle | grep versions
composer require "guzzlehttp/guzzle:^8.0" --update-with-dependencies

Preferred is also the honest default for a library or a package you distribute, because you do not control the runtime it lands on.

Stop setting CURLOPT_SHARE by hand#

Skip the obvious-looking approach, because it is now a deprecation and shortly an error. Passing a share handle through Guzzle's curl request option — Http::withOptions(['curl' => [CURLOPT_SHARE => $sh]]) — worked historically, but Guzzle 7.11 deprecated conflicting raw cURL request options including CURLOPT_SHARE, and Guzzle 8 rejects them outright.

// Deprecated in Guzzle 7.11, rejected in Guzzle 8. Do not ship this.
Http::withOptions([
    'curl' => [CURLOPT_SHARE => curl_share_init_persistent([CURL_LOCK_DATA_DNS])],
])->get('https://api.example.com/v1/ping');

The supported route is the transport_sharing option that 7.11 introduced on the client and the cURL handler, with TransportSharing::PERSISTENT_PREFER and TransportSharing::PERSISTENT_REQUIRE as the persistent modes. Reach for it only when you are on Guzzle directly rather than through Laravel's facade:

use GuzzleHttp\TransportSharing;
use Illuminate\Support\Facades\Http;

// Equivalent to PersistentTransport::Preferred, one layer down.
Http::globalOptions([
    'transport_sharing' => TransportSharing::PERSISTENT_PREFER,
]);

Enum case names moved between 7.11 and 8.0, so confirm them against the version in your own vendor/ rather than against a blog post — including this one. If you are already centralising client configuration, note that globalOptions() replaces rather than merges, so every global option has to live in one call.

Verify the connection is actually being reused#

Assume nothing here, because the failure mode is silent: everything still works, it is just as slow as it was. Fire the same route twice against a warm worker and read appconnect_time_us on the second run — zero means the TLS session came from the share handle, non-zero means it did not.

use Illuminate\Support\Facades\Http;
use GuzzleHttp\TransferStats;

Route::get('/debug/upstream', function () {
    $stats = null;

    Http::withOptions([
        'on_stats' => function (TransferStats $s) use (&$stats) {
            $stats = $s->getHandlerStats();
        },
    ])->get('https://api.example.com/v1/ping');

    return [
        'dns_us' => $stats['namelookup_time_us'] ?? null,
        'connect_us' => $stats['connect_time_us'] ?? null,
        'tls_us' => $stats['appconnect_time_us'] ?? null,
    ];
});

Then hit it under a load generator so you are measuring warm workers rather than a cold one:

hey -n 200 -c 10 https://your-app.test/debug/upstream

Watch the p50 and the p95 separately. The p50 should drop by roughly the setup cost you measured in step one; the p95 will not move as much, because every worker recycle and every upstream Connection: close puts one request back on the cold path. If the p50 does not move at all, the share handle is not attached — check the Guzzle version before you check anything else. This is also the point to pull a flame graph if the numbers are confusing, using the approach in profiling a slow Laravel endpoint.

Decide what a stale DNS entry costs you#

Work out your failover story before this reaches production, because it is the one thing persistent sharing can genuinely make worse. libcurl caches name resolution for 60 seconds by default (CURLOPT_DNS_CACHE_TIMEOUT), and sharing that cache across requests means every worker honours the same 60-second window rather than re-resolving per request.

Sixty seconds is usually fine. The real hazard is CURL_LOCK_DATA_CONNECT: an established connection is reused until it is closed, regardless of what DNS now says. If your upstream fails over by flipping an A record with a 5-second TTL, a worker holding an open connection to the old address keeps using it until that connection dies or the worker recycles — the DNS change is simply not consulted.

// Short-TTL failover upstream: share the connection pool and TLS sessions,
// but let every request resolve the name itself.
$sh = curl_share_init_persistent([
    CURL_LOCK_DATA_CONNECT,
    CURL_LOCK_DATA_SSL_SESSION,
]);

Dropping CURL_LOCK_DATA_DNS narrows the window but does not close it, so treat worker lifetime as the real control: a pm.max_requests that recycles workers every few minutes bounds how long a stale connection can survive. For upstreams inside your own network, or anything behind a load balancer with a stable VIP, none of this applies and you should share all three.

Guard the rollout and ship it#

Roll this out as a config-driven flag rather than a hardcoded call, so switching it off does not need a deploy of new code. One environment variable, one conditional, and a function_exists() guard that lets the app still boot on PHP 8.4.

use Illuminate\Http\Client\PersistentTransport;
use Illuminate\Support\Facades\Http;

public function boot(): void
{
    if (! config('http.persistent_transport')) {
        return;
    }

    Http::globalPersistentTransport(
        function_exists('curl_share_init_persistent')
            ? PersistentTransport::Required
            : PersistentTransport::None
    );
}

Enable it in one environment, leave it for a day, and compare the p50 of your outbound spans before and after — not the p95, which is dominated by worker recycles. If your PHP 8.5 upgrade is still ahead of you, the PHP 8.5 deprecations cheat sheet is the right place to start, and if most of your outbound calls do not need to block the response at all, moving them off the hot path with Laravel's defer and HTTP batching beats optimising a handshake you never needed to make.

FAQ#

What is curl_share_init_persistent in PHP 8.5?

It is a PHP 8.5 function that creates a cURL share handle which is not destroyed at the end of the request. You pass it an array of CURL_LOCK_DATA_* constants — typically DNS, CONNECT and SSL_SESSION — and it returns a CurlSharePersistentHandle that later requests in the same worker process get back, so DNS results, open connections and TLS sessions carry over instead of being rebuilt. Handles are keyed by the set of share options, so calling it twice with the same options returns the same handle.

How do I reuse TLS connections in Laravel's HTTP client?

On Laravel 13 with Guzzle 8 and PHP 8.5, call Http::globalPersistentTransport(PersistentTransport::Preferred) in the boot() method of AppServiceProvider. That is the whole wiring — Guzzle's cURL handler creates and attaches the persistent share handle for you. You do not need to touch curl_share_init_persistent() directly, and you should not attach a share handle through the curl request option, which Guzzle 8 rejects.

What is the difference between curl_share_init and curl_share_init_persistent?

curl_share_init() creates a share handle scoped to the current PHP request: handles created within that request can share state with each other, and everything is thrown away when the request ends. curl_share_init_persistent() creates a handle stored in the worker's global memory that survives request teardown, so the next request served by the same process inherits the warm DNS cache, open connections and TLS sessions. The persistent version also takes its share options as a constructor array rather than through repeated curl_share_setopt() calls.

Why can't I share cookies with a persistent curl share handle?

Because a persistent share crosses request boundaries, and in a web application that means it crosses users. A shared cookie jar would let a session or authentication cookie picked up while serving one visitor be attached to an upstream call made on behalf of a different visitor. PHP therefore throws a ValueError if CURL_LOCK_DATA_COOKIE appears in the options array, and it throws it at handle creation so a misconfigured service provider fails the application boot rather than leaking credentials at runtime.

Does a persistent curl share handle help under PHP-FPM?

Yes — PHP-FPM is the main case it was designed for. An FPM worker serves many requests before recycling, so a connection opened during one request is still open for the next one that worker handles. How much you gain is bounded by pm.max_requests: every recycle discards the share handle and the next request on that worker pays full setup cost again. It does not help a one-shot CLI script, which exits before a second request exists.

How do I set CURLOPT_SHARE through Laravel's Http facade?

You should not. Guzzle 7.11 deprecated conflicting raw cURL request options including CURLOPT_SHARE, and Guzzle 8 rejects them, so passing one through Http::withOptions(['curl' => [...]]) will stop working. Use Http::globalPersistentTransport() on Laravel 13, or the underlying Guzzle transport_sharing option if you are constructing clients yourself. Both end up calling curl_share_init_persistent() with a safe set of share options.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.