Symfony 4.3 shipped an HTTP client worth using

Two packages in the same project required incompatible Guzzle majors, which is a resolution failure with no good answer — one of them has to be forked, replaced or pinned to an old version. That is the recurring cost of an HTTP client being a library everybody has and nobody agrees on, and 4.3 in May is Symfony deciding to have its own.

The symptom

$ composer require aws/aws-sdk-php

Problem 1
  - guzzlehttp/guzzle[6.5.0] requires php ^5.5 || ^7.0
  - Conclusion: don't install guzzlehttp/guzzle 7.x
  - some/legacy-sdk 2.4.0 requires guzzlehttp/guzzle ^5.3
  - Installation request for guzzlehttp/guzzle ^6.5

$ composer why guzzlehttp/guzzle
some/legacy-sdk    2.4.0  requires  guzzlehttp/guzzle (^5.3)
aws/aws-sdk-php    3.98   requires  guzzlehttp/guzzle (^6.2.1)

Neither package is wrong. Both made a reasonable choice years apart, and the application cannot have both. PSR-18 exists to solve exactly this and adoption in 2019 is early enough that most SDKs still require a concrete client.

Why it happens

An HTTP client is infrastructure that every library needs and none of them should own. Before PSR-18 there was no interface to depend on, so a package either bundled a client or picked one — and picking one propagates that choice to every consumer.

The interesting question is why a framework should ship one at all, and the answer is not that Guzzle is bad. It is that a framework can define an interface, ship an implementation, and make the interface the thing packages depend on — which is a different value proposition from being a better client.

The fix

The interface, and responses that are lazy

use SymfonyContractsHttpClientHttpClientInterface;

final class PricingGateway
{
    private $http;

    public function __construct(HttpClientInterface $http)
    {
        $this->http = $http;
    }

    public function quote(string $sku): array
    {
        $response = $this->http->request('GET', '/quotes/' . $sku);

        // the request has NOT been sent yet. it is sent on first access.
        return $response->toArray();
    }
}

The response object is returned before the request completes, and the transfer happens when a method on it is called. That sounds like a curiosity and is the mechanism behind everything else: several requests can be started and then awaited together with no promise library, because starting one costs nothing.

toArray() decodes JSON and throws on a malformed body or a non-2xx status, which is the opposite default from fetch and from Guzzle without exceptions enabled. That is the right default for a client talking to services you control and worth knowing before it turns a 404 into an exception in a code path that expected null.

Concurrent requests, without promises

$responses = [];

foreach ($skus as $sku) {
    $responses[$sku] = $this->http->request('GET', '/quotes/' . $sku);
}

// all of them are in flight. this yields each as it completes.
foreach ($this->http->stream($responses) as $response => $chunk) {
    if ($chunk->isLast()) {
        $quotes[] = $response->toArray();
    }
}

Twelve sequential requests at 180 milliseconds each is 2.2 seconds; the same twelve concurrently is 240 milliseconds. That arithmetic is available to any client with a multi-handle and it is available here without adopting a promise abstraction, which is the practical difference — the code above is a loop, and a Guzzle equivalent is a pool with a callback.

The chunk-based iteration also means a large response can be processed as it arrives rather than buffered, which matters for anything streaming. For ordinary JSON the isLast() check is the whole of it and the streaming nature is invisible.

Configuration per client, not per call

framework:
  http_client:
    default_options:
      timeout: 3
      max_redirects: 3
      headers:
        User-Agent: 'shop/1.0'

    scoped_clients:
      pricing.client:
        base_uri: '%env(PRICING_URL)%'
        timeout: 2
        headers:
          Authorization: 'Bearer %env(PRICING_TOKEN)%'

# and the constructor argument is now named after the scope:
#   public function __construct(HttpClientInterface $pricingClient)

A scoped client is injected by argument name, so a class asking for $pricingClient gets one preconfigured with the base URI, the token and its own timeout. That removes the class of bug where a timeout is set at one call site and forgotten at another, and it keeps credentials out of the code entirely.

The three-second default is worth setting explicitly rather than inheriting: the library default is considerably higher, and a slow upstream with fifty PHP-FPM workers is an outage at any timeout above a few seconds.

Testing it without a mock server

use SymfonyComponentHttpClientMockHttpClient;
use SymfonyComponentHttpClientResponseMockResponse;

public function testAQuoteIsParsed(): void
{
    $http = new MockHttpClient([
        new MockResponse('{"sku":"FR-100","cents":4900}', [
            'http_code' => 200,
            'response_headers' => ['content-type' => 'application/json'],
        ]),
    ]);

    $quote = (new PricingGateway($http))->quote('FR-100');

    $this->assertSame(4900, $quote['cents']);
}

The mock implements the same interface, so nothing in the class under test knows it is a test. Passing a callable instead of an array lets the mock assert on the request — method, URL and body — which is where most of the interesting assertions are, and it is considerably less machinery than a mock HTTP server on a port.

Verifying it worked

$ composer remove guzzlehttp/guzzle
$ composer why guzzlehttp/guzzle
There is no installed package depending on "guzzlehttp/guzzle"

$ bin/console debug:container --parameters | grep -c http_client
4

# the timeout, which is the assertion that matters
$ bin/console app:quote FR-100 --host=blackhole.example
[3.02s] UpstreamUnavailable: pricing timed out

$ vendor/bin/phpunit --filter Pricing
OK (14 tests, 41 assertions)

Pointing a client at a host that accepts connections and never responds is the test worth doing by hand once, because a timeout that is configured and not applied is a common and invisible failure. Three seconds and an exception is the correct outcome; thirty seconds means the scoped configuration is not reaching the client.

What this costs

Another abstraction in a space that already had two well-established ones, and the honest position is that this is not obviously better than Guzzle at being an HTTP client. What it is better at is being the client a framework ships, which means packages in the Symfony ecosystem can depend on the contract rather than on an implementation — and that is a benefit that only materialises once adoption is wide.

The migration itself is not free either. Response objects are lazy, exceptions are thrown by default rather than on request, and the middleware concept has no direct equivalent — anything relying on a Guzzle handler stack has to be rethought rather than translated. For a project with a thin wrapper around the client it is an afternoon; for one that built retry, logging and circuit-breaking as Guzzle middleware it is considerably more, and staying put is a defensible answer.