PHP HTTP Client Comparison: Guzzle, Symfony HttpClient, PSR-18
AI generated
<?php
8.4
PHP · HTTP Clients · Guzzle · PSR-18
The PHP HTTP Client Compared
cURL, Guzzle, Symfony HttpClient and PSR-18 in detail

Every API integration, every webhook and every payment gateway call depends on choosing the right PHP HTTP client. This article compares raw cURL, Guzzle and Symfony HttpClient across middleware, async support, PSR-18 compatibility and testability, with real code examples for each solution.

14 min read Guzzle · Symfony HttpClient · PSR-18 PHP 8.4

1. Why the HTTP client is the basis of every API integration

Hardly any modern PHP application gets by without outgoing HTTP requests. Whether it is REST calls to an inventory management system, incoming webhooks from a payment gateway, communication between microservices, or fetching exchange rates from an external provider, in every one of these cases a PHP HTTP client sits between the application and the outside world. How robustly this client is configured directly decides whether a single slow third party blocks the entire application or whether errors are caught and handled cleanly.

The choice of HTTP client affects far more than the plain function call. Timeout behavior, retry logic, connection pooling, testability in unit tests and interoperability with third-party libraries all depend on this one decision. A payment gateway that does not respond after two seconds must not freeze the checkout process for thirty seconds. A webhook consumer must be able to react to faulty responses without crashing the entire request handler. This is exactly where a well-designed PHP HTTP client stops being an implementation detail and becomes a strategic architecture decision.

Historically, PHP developers reached directly for curl_init() because the extension has been part of every standard installation for decades. With the rise of Composer and the PSR standardization process, the field has expanded considerably: Guzzle established itself as the de facto standard library, Symfony HttpClient brought a performance-oriented approach with streaming and HTTP/2, and PSR-18 created for the first time a common interface that library authors can code against without committing to a concrete implementation.

2. cURL directly in PHP: possibilities and limits of the low-level approach

The cURL extension forms the foundation that practically every PHP HTTP client ultimately builds on, Guzzle itself uses it as its default handler. Used directly, this means: curl_init() creates a handle, curl_setopt_array() configures method, headers, body, timeouts and SSL behavior, and curl_exec() executes the request synchronously. For individual, simple requests this works reliably, but the approach quickly shows its limits: every error handling routine, every timeout handling and every retry logic must be implemented manually, there is no built-in middleware architecture and no PSR compatibility.

For parallel requests, cURL offers the curl_multi_* family of functions. curl_multi_init() creates a multi handle to which several individual cURL handles are added, then a loop with curl_multi_exec() and curl_multi_select() drives all requests forward together. This works, but it is considerably more error-prone than the promise-based abstractions of modern libraries, because the developer alone is responsible for correct event handling, error propagation and collecting the results.

The biggest advantage of raw cURL lies in control: no extra Composer dependency, full visibility into every single option, and no abstraction layer standing in the way of debugging. In very small scripts, CLI tools without a Composer setup, or environments with strict dependency constraints, this remains a legitimate choice. For larger applications with many external integrations, though, the lack of standardization quickly becomes a maintenance burden, because every codebase invents its own variant of error handling and retry logic.


<?php

declare(strict_types=1);

/**
 * Minimal raw cURL request with explicit error handling.
 * No abstraction layer, full control over every option.
 */
function fetchOrderStatus(string $orderId, string $apiToken): array
{
    $ch = curl_init();

    curl_setopt_array($ch, [
        CURLOPT_URL => "https://api.example.com/orders/{$orderId}/status",
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST => 'GET',
        CURLOPT_HTTPHEADER => [
            "Authorization: Bearer {$apiToken}",
            'Accept: application/json',
        ],
        CURLOPT_CONNECTTIMEOUT => 3,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);

    $body = curl_exec($ch);
    $errno = curl_errno($ch);

    if ($errno !== 0) {
        $error = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException("cURL error ({$errno}): {$error}");
    }

    $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($statusCode >= 400) {
        throw new RuntimeException("API returned HTTP {$statusCode}");
    }

    /** @var array $decoded */
    $decoded = json_decode((string) $body, true, flags: JSON_THROW_ON_ERROR);

    return $decoded;
}

3. Guzzle in detail: architecture, middleware stack and promises

Guzzle is the best known PHP HTTP client in the Composer ecosystem and is required as a dependency by countless SDKs, frameworks and internal tools. The central idea behind Guzzle is the HandlerStack: a chain of middleware functions that every outgoing request and every incoming response passes through before it reaches the actual handler backend or is returned to the caller. This middleware concept allows logging, retry logic, authentication or caching to be implemented as independent, reusable building blocks without touching the actual request code.

Custom middleware is nothing more than a higher-order function that takes a handler and returns a new handler. Guzzle already ships built-in middleware for retry behavior (Middleware::retry()) and history tracking, and custom middleware for logging or enriching headers can be added in a few lines and hooked into the stack via $handlerStack->push(). This extensibility is one of the main reasons Guzzle has become so widely adopted as a PHP HTTP client.

Besides synchronous requests, Guzzle supports asynchronous requests through promises from the guzzlehttp/promises library. Instead of $client->get(), you call $client->getAsync() and immediately receive a promise object back while the request runs in the background. Multiple promises can be resolved together with Utils::unwrap() or Promise\Utils::settle(), enabling parallel requests without manual multi-handle management. This combination of middleware stack and promise-based async API makes Guzzle one of the most mature HTTP clients in the PHP ecosystem.


<?php

declare(strict_types=1);

use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Promise\Utils;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;

/**
 * Custom logging middleware for the Guzzle handler stack.
 * Logs method, URI and response status without touching call sites.
 */
function createLoggingMiddleware(LoggerInterface $logger): callable
{
    return function (callable $handler) use ($logger): callable {
        return function (RequestInterface $request, array $options) use ($handler, $logger) {
            $logger->info('HTTP request sent', [
                'method' => $request->getMethod(),
                'uri' => (string) $request->getUri(),
            ]);

            return $handler($request, $options)->then(
                function (ResponseInterface $response) use ($logger, $request) {
                    $logger->info('HTTP response received', [
                        'uri' => (string) $request->getUri(),
                        'status' => $response->getStatusCode(),
                    ]);
                    return $response;
                }
            );
        };
    };
}

$stack = HandlerStack::create();
$stack->push(createLoggingMiddleware($logger));
$stack->push(Middleware::retry(
    decider: fn (int $retries, $request, $response = null, $exception = null) =>
        $retries < 3 && ($exception !== null || $response?->getStatusCode() >= 500),
    delay: fn (int $retries) => 1000 * (2 ** $retries),
));

$client = new Client(['handler' => $stack, 'timeout' => 10.0]);

// Async requests resolved together via promises
$promises = [
    'orders' => $client->getAsync('https://api.example.com/orders'),
    'customers' => $client->getAsync('https://api.example.com/customers'),
];

$responses = Utils::unwrap($promises);
echo $responses['orders']->getStatusCode();

4. Symfony HttpClient: design, HttpClientInterface and streaming

Symfony HttpClient follows a different architectural approach than Guzzle. Instead of a middleware stack, the component is built on lazy responses: a call via HttpClient::create()->request() returns a response object immediately, without the request already having been fully executed. Only when data is actually accessed, for example with getStatusCode() or getContent(), does the call block and wait for the response. This design allows multiple requests to be started one after another without needing explicit async/await syntax or promises, while still achieving concurrency.

Another core feature is native streaming. With $httpClient->stream($response), a response can be consumed in chunks as they arrive, instead of waiting for the complete body. This is especially valuable when processing large JSON exports, forwarding file downloads to the client, or consuming server-sent-events-like endpoints where data is delivered continuously. The HttpClientInterface implementation also automatically supports HTTP/2, provided the underlying cURL version and the server allow it.

Because Symfony HttpClient was designed from the start to be PSR-18 compatible (via the Psr18Client adapter), the component can also be used in libraries that expect a PSR-compliant dependency. For projects that already use other Symfony components or depend on maximum performance with many parallel requests, this is often the more compelling PHP HTTP client compared to the classic Guzzle approach.


<?php

declare(strict_types=1);

use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

$httpClient = HttpClient::create([
    'timeout' => 10,
    'max_redirects' => 3,
]);

// Lazy response: the request only starts consuming data on access
$response = $httpClient->request('GET', 'https://api.example.com/export/large-dataset', [
    'headers' => ['Accept' => 'application/x-ndjson'],
]);

$buffer = '';

/**
 * Stream the response body chunk by chunk instead of
 * waiting for the full payload to be buffered in memory.
 */
foreach ($httpClient->stream($response) as $chunk) {
    /** @var ChunkInterface $chunk */
    $buffer .= $chunk->getContent();

    while (($newlinePos = strpos($buffer, "\n")) !== false) {
        $line = substr($buffer, 0, $newlinePos);
        $buffer = substr($buffer, $newlinePos + 1);

        if ($line !== '') {
            $record = json_decode($line, true, flags: JSON_THROW_ON_ERROR);
            processExportRecord($record);
        }
    }
}

if ($response->getStatusCode() >= 400) {
    throw new RuntimeException('Export request failed: ' . $response->getStatusCode());
}

5. PSR-18, PSR-17 and PSR-7: standardization and interoperability

PSR-18 defines a single, minimal interface for a PHP HTTP client: Psr\Http\Client\ClientInterface with exactly one method, sendRequest(), which takes a PSR-7 request and returns a PSR-7 response. This deliberate minimalism is the decisive advantage: a library that codes against PSR-18 is completely independent of whether Guzzle, Symfony HttpClient or another implementation ultimately executes the request. The consumer of the library decides, via dependency injection, which concrete client is used.

For this interplay to work, PSR-17 is also needed, the factory interfaces for PSR-7 objects, chiefly RequestFactoryInterface, StreamFactoryInterface and UriFactoryInterface. A library that uses PSR-18 and PSR-17 together does not need to hard-require a specific PSR-7 implementation such as nyholm/psr7 or guzzlehttp/psr7, but can have these factories injected as well. This is exactly what makes packages such as payment provider SDKs or API wrappers independent of the end user's concrete HTTP stack choice.

For library authors this is not an academic detail but a concrete advantage in the Composer ecosystem: an SDK that requires PSR-18 instead of a hard Guzzle dependency causes no version conflicts if an application already uses Symfony HttpClient. Discovery packages such as php-http/discovery determine at runtime which PSR-18 implementation is available in the project, so end users often do not need to configure anything explicitly.


<?php

declare(strict_types=1);

use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;

/**
 * Framework-agnostic API client depending only on PSR-18 and PSR-17.
 * Works unchanged with Guzzle, Symfony HttpClient or any other
 * PSR-18 compatible implementation injected by the consumer.
 */
final readonly class WeatherApiClient
{
    public function __construct(
        private ClientInterface $httpClient,
        private RequestFactoryInterface $requestFactory,
        private StreamFactoryInterface $streamFactory,
        private string $apiBaseUrl,
        private string $apiKey,
    ) {
    }

    /**
     * Fetches current weather data for a given city.
     *
     * @param string $city City name used as the query parameter.
     * @return array<string, mixed> Decoded JSON payload.
     * @throws ClientExceptionInterface When the underlying transport fails.
     * @throws RuntimeException When the API returns a non-success status.
     */
    public function getCurrentWeather(string $city): array
    {
        $uri = "{$this->apiBaseUrl}/current?city=" . urlencode($city);

        $request = $this->requestFactory
            ->createRequest('GET', $uri)
            ->withHeader('Authorization', "Bearer {$this->apiKey}")
            ->withHeader('Accept', 'application/json');

        $response = $this->httpClient->sendRequest($request);

        if ($response->getStatusCode() >= 400) {
            throw new RuntimeException("Weather API returned HTTP {$response->getStatusCode()}");
        }

        /** @var array<string, mixed> $decoded */
        $decoded = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);

        return $decoded;
    }
}

6. Configuring retry strategies, timeouts and connection pooling correctly

A timeout is not a single number, but at least two separate values: connect_timeout limits how long to wait for the TCP connection to be established, while timeout limits the total duration of the entire request including data transfer. Setting both to the same value risks either overly aggressive aborts on slow but functioning connections, or far too long waits when a server is reachable but never responds. A sensible starting point for most API integrations is two to three seconds for the connect timeout and five to fifteen seconds for the overall timeout, depending on the expected response time of the target system.

Retry logic should never blindly repeat every error. A 4xx status code such as 400 or 404 signals a problem on the client side that a retry will not fix, while 5xx status codes and connection errors can indeed represent transient problems worth retrying. Exponential backoff, where the wait time between attempts increases exponentially (for example 100ms, 200ms, 400ms, 800ms), prevents an already overloaded server from being put under additional strain by aggressive retry attempts. Both Guzzle via Middleware::retry() and Symfony HttpClient via the RetryableHttpClient decorator offer this strategy built in.

Connection pooling and keep-alive connections significantly reduce the overhead of repeated TLS handshakes when an application sends many requests to the same host. Both Guzzle and Symfony HttpClient use cURL handles under the hood that, by default, reuse connections for subsequent requests to the same host, as long as the same client (or the same cURL multi instance) persists over the runtime. Instantiating a new client object for every single request destroys this benefit and forces a complete connection setup on every call.

7. Testing HTTP clients: mocking with Guzzle MockHandler and Symfony MockHttpClient

Unit tests must never trigger real network calls, they would be slow, unreliable and dependent on the availability of external systems. Guzzle offers the MockHandler for this: instead of a real HTTP handler, a queue of predefined response objects or exceptions is hooked into the HandlerStack, so that every call in the code under test receives exactly the configured response without a network connection being established. Because the rest of the application continues to code against the normal ClientInterface, the tested code does not differ from production code.

Symfony HttpClient offers a comparable mechanism with MockHttpClient: callbacks or prepared MockResponse objects simulate arbitrary status codes, headers and body contents, including simulated streaming behavior for testing chunk-based code. Both approaches also allow network errors such as connection drops or timeouts to be simulated deliberately, which in practice is often more important than the success case, because this exact path is the one least often tested manually in real integrations.

Anyone using PSR-18 as a dependency additionally benefits during testing from the fact that any PSR-18-compatible test implementation can be injected, without the tested class knowing anything about Guzzle or Symfony HttpClient. This reduces coupling in the test setup and makes it possible to verify the same service code against different mock implementations, should a project switch between multiple HTTP client libraries.


<?php

declare(strict_types=1);

namespace App\Tests;

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;

/**
 * Verifies WeatherApiService behavior without any real network call,
 * using Guzzle's MockHandler to queue predefined responses.
 */
final class WeatherApiServiceTest extends TestCase
{
    public function testReturnsDecodedTemperatureOnSuccess(): void
    {
        $mock = new MockHandler([
            new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([
                'city' => 'Berlin',
                'temperature' => 21.5,
            ])),
        ]);

        $handlerStack = HandlerStack::create($mock);
        $client = new Client(['handler' => $handlerStack]);

        $service = new WeatherApiService($client);
        $result = $service->getTemperature('Berlin');

        self::assertSame(21.5, $result);
    }

    public function testThrowsOnServerError(): void
    {
        $mock = new MockHandler([
            new Response(503, [], 'Service Unavailable'),
        ]);

        $client = new Client(['handler' => HandlerStack::create($mock)]);
        $service = new WeatherApiService($client);

        $this->expectException(\RuntimeException::class);
        $service->getTemperature('Berlin');
    }
}

8. Performance: HTTP/2, multiplexing and parallel requests

HTTP/2 allows multiplexing, meaning multiple requests and responses run simultaneously over a single TCP connection instead of requiring a new handshake for every request. For applications that send many small requests to the same host, for example fetching multiple resources from an API with many individual endpoints, this noticeably reduces latency. Symfony HttpClient enables HTTP/2 automatically, provided the cURL version in use supports it and the server also offers HTTP/2, without the developer having to change any configuration.

For parallel requests, Guzzle offers the Pool mechanism: a generator supplies a configurable number of requests, while Pool::batch() or a manually configured concurrency ensures that never more than a defined number of concurrent connections is open. This prevents a thousand parallel requests from overloading a target server or destabilizing the local system through too many open sockets. Symfony HttpClient achieves the same effect through the lazy responses already mentioned: multiple request() calls are started one after another but run concurrently in the background until their results are actually queried.

In benchmarks with many small, parallel requests to the same host, Symfony HttpClient is often slightly ahead due to native HTTP/2 support and the efficient lazy-response architecture. For use cases with few but complex requests, where middleware logic such as logging, auth refresh or retry takes center stage, Guzzle convinces with its mature middleware stack. The following table summarizes the most important decision criteria.

Criterion cURL (native) Guzzle Symfony HttpClient
PSR-18 compatibility No, own API Yes, natively Yes, via Psr18Client
Async support curl_multi_*, manual Promises, mature Lazy responses, mature
HTTP/2 multiplexing Manually configurable Depends on handler Automatically active
Dependencies None, core extension guzzlehttp/guzzle + psr7 symfony/http-client
Learning curve Flat, but error-prone Medium, middleware concept Medium, lazy-response mindset
Community/ecosystem Universal, but raw Very large, de facto standard Growing, Symfony-adjacent

9. Choosing the right HTTP client: decision criteria for projects

Choosing the right PHP HTTP client depends heavily on project size. A small CLI tool or script without existing Composer dependencies can get by with raw cURL, especially if only a single, simple request is needed. As soon as multiple endpoints, retry logic or middleware requirements come into play, the effort of building custom error handling quickly outweighs the overhead of an additional library.

Existing dependencies are often the decisive factor: a project that already uses Symfony components such as the Messenger or the Mailer benefits from seamless interplay with Symfony HttpClient, including shared configuration and event dispatching. A project with many existing Guzzle-based SDKs, for example for AWS or various payment providers, avoids conflicts and duplicate HTTP stacks by consistently sticking with Guzzle. Anyone developing their own library or SDK for third parties should practically always choose PSR-18 and PSR-17 as a dependency and leave the concrete implementation to the consumer.

Testability and long-term maintenance almost always favor one of the established libraries over raw cURL, because mocking, middleware and community support considerably reduce maintenance effort over the project lifetime. For new projects without an existing commitment, a pragmatic rule of thumb is: Guzzle when middleware flexibility and the huge ecosystem of ready-made SDKs matter most, Symfony HttpClient when performance under many parallel requests and native streaming matter most, and in every case PSR-18 as the interface in your own application code, to keep the concrete choice changeable later without a major rewrite.

10. Summary

The right PHP HTTP client is rarely a pure matter of taste. Raw cURL offers maximum control without dependencies, but demands manual error handling and retry logic for every single use case. Guzzle establishes itself as a safe default choice for most projects through its middleware stack and huge adoption, while Symfony HttpClient scores with lazy responses, native HTTP/2 and efficient streaming on performance-critical, parallel workloads.

PSR-18 and PSR-17 solve the actual interoperability problem: they allow libraries and application code to be written independently of the concrete HTTP client implementation. Anyone starting a new integration today should code against these interfaces, plan for cleanly configured timeouts and retry strategies with exponential backoff, and test from the start with MockHandler or MockHttpClient instead of building real network calls into the test suite.

PHP HTTP Client Comparison - The Essentials at a Glance

PSR-18 as the foundation

Libraries should code against ClientInterface and RequestFactoryInterface, not against a concrete implementation such as Guzzle.

Guzzle for middleware

HandlerStack, built-in retry middleware and promises make Guzzle a mature standard for complex API integrations.

Symfony HttpClient for performance

Lazy responses, native HTTP/2 multiplexing and streaming often have the edge under many parallel requests.

Do not forget retry and timeouts

Configure connect_timeout and timeout separately, use exponential backoff, only retry transient errors.

11. FAQ: PHP HTTP Client Comparison

1What is a PHP HTTP client?
A library or extension that sends outgoing HTTP requests from PHP, for example for REST calls, webhooks or microservice communication. Examples: cURL, Guzzle, Symfony HttpClient.
2Guzzle or Symfony HttpClient?
Guzzle for existing Guzzle SDKs and middleware needs. Symfony HttpClient for many parallel requests thanks to lazy responses and native HTTP/2. Both support PSR-18.
3Is raw cURL still worthwhile?
Yes, for small scripts and single simple requests. With retry logic, middleware or multiple endpoints, custom error handling quickly becomes too much effort.
4What does PSR-18 mean?
The ClientInterface with sendRequest(). Allows libraries to use an HTTP client without committing to Guzzle or Symfony HttpClient.
5Does Guzzle support HTTP/2?
Yes, via the cURL handler, provided version and server allow it. Less deeply integrated than in Symfony HttpClient, which activates HTTP/2 automatically.
6How to test without real network calls?
With Guzzle's MockHandler or Symfony's MockHttpClient. Simulate predefined responses and errors, the tested code stays coded against ClientInterface unchanged.
7connect_timeout vs. timeout?
connect_timeout limits connection setup, timeout limits the total duration including transfer. Configure separately, typically two to three seconds connect, five to fifteen seconds overall.
8Parallel requests with Guzzle?
Via promises with getAsync() or the Pool mechanism with limited concurrency, to avoid overloading the target server or the local system.
9Does every project need an HTTP client?
Only for actual outgoing requests. Single requests: raw cURL is enough. Multiple integrations with retry needs: Guzzle or Symfony HttpClient are worthwhile.
10Migrating between Guzzle and Symfony HttpClient?
Easiest with PSR-18 and PSR-17 in the application code. Then it is enough to swap the implementation in the DI container without touching the rest of the code.

Mironsoft

PHP development, API integrations and Magento agency

Need an API integration that stays reliable under load?

We analyze existing HTTP client integrations, harden timeout and retry configuration, and implement new connections with Guzzle, Symfony HttpClient or PSR-18 compliant libraries, testable and maintainable.

API integrations

Robust connections to payment gateways, inventory systems and third-party services

Resilience audit

Checking timeouts, retry strategies and connection pooling for production readiness

Test coverage

Building mocking strategies for HTTP clients in PHPUnit test suites