The HttpClient

Send HTTP requests from your own code, read JSON out of the response, and understand why a response is lazy until you read it.

Syntax$response = $client->request('GET', 'https://api.example.com/articles', ['query' => ['page' => 1]]);

Most applications have to talk to other HTTP services: a payment provider, a search index, an internal API owned by another team. The HttpClient component makes those calls. In a Symfony application the client is already registered in the container, so a constructor argument typed Symfony\Contracts\HttpClient\HttpClientInterface receives it. You never construct it yourself.

Making a request

request() takes an HTTP method, a URL, and an array of options. query takes an array and encodes it into the query string for you. headers takes header names as array keys. json encodes an array as the body and adds Content-Type: application/json; the longhand is the body option plus that header written by hand, and the two produce the same request.

Responses are lazy

request() starts the request and returns immediately. What comes back is a handle, not a finished response. Reading from it is what waits. getStatusCode() and getHeaders() block until the response headers arrive; getContent() blocks until the whole body has. toArray() decodes a JSON body into a PHP array, and is the shorthand for json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR).

What trips people up

getHeaders(), getContent() and toArray() throw when the status code is in the 300-599 range. Pass false to take that over yourself: $response->getContent(false). If you never read the response at all, its destructor still waits for the headers and still throws. Calling getStatusCode() yourself switches that fallback off.

Example

Example · php
<?php
// src/Service/ArticleFeed.php

declare(strict_types=1);

namespace App\Service;

use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ArticleFeed
{
    // The container injects its "http_client" service for this interface.
    public function __construct(
        private HttpClientInterface $client,
    ) {
    }

    /**
     * @return list<string>
     */
    public function titles(int $perPage = 2): array
    {
        $response = $this->client->request('GET', 'https://api.example.com/articles', [
            // Encoded into the URL for you: ?page=1&per_page=2
            'query' => ['page' => 1, 'per_page' => $perPage],
            'headers' => ['Accept' => 'application/json'],
            'timeout' => 5,
        ]);

        // Nothing has been read yet. This line waits for the body and
        // decodes it. Longhand for the same result:
        // json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR)
        $payload = $response->toArray();

        return array_column($payload['data'], 'title');
    }
}

// Given the response body {"data":[{"title":"First"},{"title":"Second"}]}
// Output: ['First', 'Second']

When to use it

  • Fetch today's exchange rates from a provider's JSON endpoint inside a service.
  • POST a payload to a partner's API and read the identifier it returns.
  • Check the health endpoints of several internal services in parallel to render a status page.
  • Read a paginated API by passing the page number through the query option.

More examples

Sending a JSON body, shorthand and longhand

The two forms send the same bytes, with one difference worth knowing: the "json" option encodes with the JSON_HEX_TAG, JSON_HEX_APOS, JSON_HEX_AMP, JSON_HEX_QUOT and JSON_PRESERVE_ZERO_FRACTION flags, so characters such as angle brackets come out escaped. Setting both "json" and a non-empty "body" throws an InvalidArgumentException. Reach for the longhand when the body is not an array you want encoded, for example a raw string or a file stream.

Example · php
<?php
// src/Service/ArticlePublisher.php

declare(strict_types=1);

namespace App\Service;

use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ArticlePublisher
{
    public function __construct(
        private HttpClientInterface $client,
    ) {
    }

    public function publish(string $title): int
    {
        // Shorthand: "json" encodes the array and sets
        // Content-Type: application/json when you have not set it.
        $response = $this->client->request('POST', 'https://api.example.com/articles', [
            'json' => ['title' => $title],
        ]);

        // Longhand for the same request:
        // $response = $this->client->request('POST', 'https://api.example.com/articles', [
        //     'headers' => ['Content-Type' => 'application/json'],
        //     'body' => json_encode(['title' => $title], JSON_THROW_ON_ERROR),
        // ]);

        return $response->toArray()['id'];
    }
}

// Given a 201 response with the body {"id":7}
// Output: 7

Several requests in flight at once

Laziness is what makes this work: because request() does not wait, the second loop reads responses that have been downloading in parallel, so the total time is close to the slowest call rather than the sum of all of them. Reach for this shape whenever you need data from several endpoints to render one page. The trap is doing the work in a single loop, which turns it back into one call after another.

Example · php
<?php
// src/Service/StatusBoard.php

declare(strict_types=1);

namespace App\Service;

use Symfony\Contracts\HttpClient\HttpClientInterface;

final class StatusBoard
{
    public function __construct(
        private HttpClientInterface $client,
    ) {
    }

    /**
     * @param list<string> $urls
     *
     * @return array<string, int>
     */
    public function check(array $urls): array
    {
        $responses = [];

        // Nothing blocks in this loop: every request is started and the
        // handle is kept. Keeping the handle is what stops the destructor
        // from waiting here.
        foreach ($urls as $url) {
            $responses[$url] = $this->client->request('GET', $url, ['timeout' => 3]);
        }

        $codes = [];

        // Now read them. Each getStatusCode() waits for that response's
        // headers while the others keep transferring.
        foreach ($responses as $url => $response) {
            $codes[$url] = $response->getStatusCode();
        }

        return $codes;
    }
}

// Output: ['https://a.example.com/health' => 200, 'https://b.example.com/health' => 503]

Treating 404 as a result, not an error

This is the pattern for an API where a missing resource is normal. Checking the status first turns the 404 into a null return, while a 500 still raises an exception that you have not quietly swallowed. The alternative, $response->toArray(false), turns the exception off for every error status, which also hands you the error body as if it were data.

Example · php
<?php
// src/Service/ArticleFinder.php

declare(strict_types=1);

namespace App\Service;

use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ArticleFinder
{
    public function __construct(
        private HttpClientInterface $client,
    ) {
    }

    /**
     * @return array<string, mixed>|null
     */
    public function find(int $id): ?array
    {
        $response = $this->client->request('GET', 'https://api.example.com/articles/'.$id);

        // getStatusCode() never throws; it only waits for the headers.
        if (404 === $response->getStatusCode()) {
            return null;
        }

        // Every other failure stays an exception: toArray() throws for
        // any status in the 300-599 range.
        return $response->toArray();
    }
}

// Given a 404 response
// Output: null
// Given a 200 response with the body {"id":7,"title":"First"}
// Output: ['id' => 7, 'title' => 'First']

Discussion

  • Be the first to comment on this lesson.