Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Http Client Laravel Package

symfony/http-client

Symfony HttpClient provides powerful methods to fetch HTTP resources synchronously or asynchronously, with a clean API for building requests, handling responses, streaming, and testing HTTP calls in PHP applications.

View on GitHub
Deep Wiki
Context7

Getting Started

This is Symfony's HTTP client, not Laravel's Illuminate\Support\Facades\Http (which wraps Guzzle). You are adopting it alongside or instead of Guzzle, so the first decision is scope: use it for one integration, or make it the transport for everything.

1. Install and confirm the transport.

composer require symfony/http-client
php -r "var_dump(extension_loaded('curl'));"

With ext-curl present you get CurlHttpClient (curl_multi, real concurrency, HTTP/2/3). Without it you silently fall back to NativeHttpClient (PHP streams, weaker concurrency). Never rely on the fallback in production — assert it in a boot check or config test.

2. Read the entry point, not the class list. The whole API surface you need day-to-day is Symfony\Component\HttpClient\HttpClientInterface: request(), stream(), withOptions(). Start there. HttpClient::create() is the factory; everything else (RetryableHttpClient, ScopingHttpClient, CachingHttpClient, NoPrivateNetworkHttpClient, MockHttpClient) is a decorator around the same interface, so learn one method signature and compose.

3. First use case — bind a singleton in a service provider.

// app/Providers/HttpClientServiceProvider.php
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;

public function register(): void
{
    $this->app->singleton(HttpClientInterface::class, function () {
        return new RetryableHttpClient(
            HttpClient::create([
                'headers' => ['user-agent' => config('app.name')],
                'timeout' => 10,        // idle timeout
                'max_duration' => 30,   // total wall clock, incl. retries
            ]),
            new GenericRetryStrategy([500, 502, 503, 504, 429], 200),
            maxRetries: 2,
        );
    });
}

Singleton matters: CurlHttpClient shares a CurlClientState and reuses connections. Resolving a new client per call (or per job) throws away connection pooling and multiplexing — the main reason to pick this package over Guzzle.

4. First call.

$response = $client->request('GET', 'https://api.example.com/v1/users', [
    'query' => ['page' => 1],
    'headers' => ['accept' => 'application/json'],
]);

$status  = $response->getStatusCode();   // executes here
$payload = $response->toArray();         // throws on 4xx/5xx

Note the laziness: request() only builds the request. Execution begins when you first touch the response (getStatusCode, getContent, toArray, stream, or destruction).

Implementation Patterns

Per-integration scoping instead of globals

withOptions() returns a cheap clone sharing the same transport state. This is the cleanest way to model third-party APIs and multi-tenant credentials without a second client and a second connection pool.

$stripe = $client->withOptions([
    'base_uri' => 'https://api.stripe.com/',
    'auth_bearer' => config('services.stripe.secret'),
    'headers' => ['content-type' => 'application/x-www-form-urlencoded'],
]);

$stripe->request('POST', 'v1/charges', ['body' => $form])->toArray();

For many hosts, ScopingHttpClient + a regex map keeps call sites free of URLs entirely:

new ScopingHttpClient($client, [
    '#^https://api\.github\.com/#' => ['auth_bearer' => config('services.github.token')],
    '#^https://api\.example\.com/#' => ['base_uri' => 'https://api.example.com/'],
]);

Concurrency: fan-out, then interleave

This is where the package earns its keep. Fire N requests, then consume them through stream() so they progress in parallel rather than serially.

$responses = [];
foreach ($ids as $id) {
    $responses[$id] = $client->request('GET', "https://api.example.com/items/$id");
}

foreach ($client->stream($responses) as $response => $chunk) {
    if ($chunk->isTimeout()) {
        $response->cancel();
        continue;
    }
    if ($chunk->isLast()) {
        $data[$idOf[$response]] = $response->toArray(false);
    }
}

Keep the map from response object to your domain key (spl_object_id($response) works) — you cannot recover your own identifier from the response.

Tune pool size on the factory: HttpClient::create([], maxHostConnections: 10). A too-high value is a self-inflicted rate-limit; a too-low value makes fan-out pointless.

Streaming and SSE

For long-lived endpoints, stream() with 'buffer' => false gives you chunks as they arrive:

$response = $client->request('GET', $url, ['buffer' => false]);

foreach ($client->stream($response) as $chunk) {
    if ($chunk->isFirst() || $chunk->isLast() || $chunk->isTimeout()) {
        continue;
    }
    echo $chunk->getContent(); // flush to the browser / write to log
}

If you need a raw PHP stream resource (fopen/fpassthru/Storage::put), use Symfony\Component\HttpClient\Response\StreamWrapper::createResource($response) rather than buffering the whole body into a string.

Retries that respect the server

RetryableHttpClient + GenericRetryStrategy already honours Retry-After. As of 7.4/8.1 QUERY joined the retriable-method list; POST still is not retried by default, and that is correct — do not override it to "make the flaky call work". If you must retry a POST, make it idempotent server-side with an idempotency key and write a custom RetryStrategyInterface.

Also: a streamed/resource request body cannot be replayed on retry. Pass a closure returning a fresh body instead:

'body' => fn () => fopen($path, 'r'),

SSRF protection for user-supplied URLs

Anywhere a URL comes from a request payload, a webhook config, or an imported feed, wrap the client:

$safe = new NoPrivateNetworkHttpClient($client->withOptions(['base_uri' => null]));

The 6.4.41 / 7.4.13 / 5.4.53 releases added CVE-2026-48736 coverage for IPv6 transition forms (NAT64, 6to4, Teredo). Pin at or above those patch levels — this is a security boundary, not a nice-to-have. Note this client is not a DNS-rebinding defence on its own; treat it as one layer.

PSR-18 / Guzzle interop

If a package type-hints Psr\Http\Client\ClientInterface, don't add Guzzle:

$psr18 = new Psr18Client($client);   // also PSR-17 factories

And GuzzleHttpHandler lets Guzzle middleware run on top of this transport when you're migrating incrementally.

Ordering decorators deliberately

Composition order is functional, not cosmetic. The 7.4.1 fix ("CachingHttpClient must run after UriTemplate and Scoping") is the canonical example: ScopingHttpClient must resolve the base URI/options before caching keys are computed.

new RetryableHttpClient(          // outermost: retries the whole operation
    new CachingHttpClient(        // cache after scoping
        new ScopingHttpClient($client, $map),
        $cache
    ),
    $strategy
);

CachingHttpClient is RFC 9111-compliant as of 7.4 and needs a symfony/cache-style implementation. Since Laravel ships PSR-6/PSR-16 via Illuminate\Cache, bridge with Symfony\Component\Cache\Adapter\Psr16Adapter — and remember Laravel's cache store and Symfony's cache adapter have different TTL and tag semantics, so don't expect Cache::tags() to work through it.

Testing with MockHttpClient

$mock = new MockHttpClient([
    new MockResponse(json_encode(['id' => 1]), ['http_code' => 200]),
    new MockResponse('', ['http_code' => 503]),
]);

$service = new MyApiService($mock);
$service->fetch(1);
$service->fetch(1); // exercises your retry path, no network

$this->assertSame('Bearer abc', $mock->getRequests()[0]->getHeaders()['authorization'][0]);

For dynamic behaviour, pass a callable that receives method/url/options. Prefer this over hitting a real sandbox in CI — it also lets you assert the outgoing payload, which is usually where the bugs are.

Gotchas and Tips

Laziness bites in try/catch. Wrapping only request() catches nothing. Transport errors (TransportExceptionInterface) and HTTP errors (ClientExceptionInterface / ServerExceptionInterface) are thrown when the response is consumed — often inside toArray(). Structure your code so the try/catch encloses the consumption, not the construction.

Unconsumed responses block on destruct. A response going out of scope without being read will wait for completion (or throw from a destructor). Always either consume it, $response->cancel(), or discard it explicitly. The 6.4.34/7.4.17/8.1.5 "destructor throwing" fixes cleaned up the worst of this, but the waiting behaviour is inherent. In Laravel queue workers and Octane, this manifests as mystery latency between jobs.

getContent() throws; getContent(false) doesn't. Error bodies from upstream APIs are frequently the most useful diagnostic. Use toArray(false) / getContent(false) and log the body with the status code.

timeout is not a total timeout. timeout is idle; max_duration is wall clock and is the only one that bounds a slow-drip response. Set both, and set max_duration above the sum of your retry backoff or retries get killed mid-flight.

No cookie jar. symfony/http-client does not persist cookies, and Psr18Client doesn't either. Multi-step scraping/auth flows need manual set-cookie handling. Don't assume the package behaves like a browser client.

Version pinning is not optional here. The changelogs are dense with transport-level correctness fixes: truncated streams after headers (6.4.33), re-yielded first chunk after reconnect (6.4.42), max_host_connections growth (6.4.31), cross-authority redirect header leakage (6.4.43), PHP 8.6 deprecations (7.4.17 / 8.1.5), and the SSRF CVE. Run the newest patch of your minor line: ^6.4.44, ^7.4.17, or ^8.1.8. Never float across majors blindly — 8.x is a different PHP floor.

extra.curl options are CurlHttpClient-only. They are silently ignored by NativeHttpClient and AmpHttpClient. If a config like CURLOPT_SSL_CIPHER_LIST matters, assert the transport class in a test rather than discovering it in production.

Debugging: getInfo() is the whole toolbox.

$response->getInfo('debug');            // verbose curl output + equivalent curl command
$response->getInfo('http_code');
$response->getInfo('total_time');
$response->getInfo('redirect_count');
$response->getInfo('response_headers');

Enable globally with withOptions(['debug' => true]) in a local env, or attach TraceableHttpClient and push getTracedRequests() into Laravel's log/Telescope. getInfo('debug') printing a runnable curl command is the single fastest way to hand a failure to a third-party API vendor.

withOptions() clones share transport state — that's the point, but it's also a footgun. max_host_connections and proxy/no_proxy are partly handle-level; changing them on a clone does not necessarily give you an isolated pool. If you genuinely need isolation (e.g., a scraping subsystem with a different proxy), construct a separate root client and accept the separate pool.

accept-encoding and compression are automatic. Do not hand-set gzip headers; 8.1.8 specifically fixed rejecting highly compressed responses, so custom header manipulation is more likely to reintroduce a bug than solve one.

Retries and side effects. RetryableHttpClient around a webhook sender or a payment call is a duplicate-charge generator unless the operation is idempotent. Keep retries on reads, and put explicit idempotency keys on writes.

Extension points worth knowing before you subclass anything: implement HttpClientInterface (or extend DecoratorTrait-style wrapping) to inject tracing, tenant routing, or metrics; implement RetryStrategyInterface::shouldRetry() + getDelay() for bespoke backoff; supply a custom LoggerInterface-backed decorator rather than patching vendor code. Given this package lives in the monolithic symfony/symfony repo, contributing a fix is slower than decorating it — build the decorator.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle