← Tous les cours / HttpClient Symfony Cours
00 — LA REQUÊTE LAZY

HttpClient et la requête lazy

La requête n'est envoyée qu'au premier accès au body.

Imagine que tu demandes à un serveur "envoie-moi le menu". Avec la plupart des clients HTTP, la requête part immédiatement. Avec le HttpClient de Symfony, c'est différent : il répond "d'accord, je note" — et n'envoie vraiment la requête qu'au moment où tu lis le résultat.

Réponse lazy
$client->request() retourne immédiatement un objet ResponseInterface sans attendre la réponse réseau. La connexion TCP, l'envoi et la réception n'ont lieu qu'au premier appel à getContent(), toArray() ou getStatusCode(). Cela permet de lancer plusieurs requêtes avant de les lire.

Sélectionner le bon client selon l'environnement :

use Symfony\Component\HttpClient\HttpClient;

// Sélection automatique du meilleur client disponible :
// → CurlHttpClient   si ext-curl est chargée  (HTTP/2, HTTP/3, concurrence native)
// → NativeHttpClient sinon (stream wrappers PHP, portable)
$client = HttpClient::create();

// La requête est enregistrée — aucune connexion TCP à ce stade
$response = $client->request('GET', 'https://api.example.com/users/42');

Les trois méthodes de lecture — chacune déclenche la requête si elle n'a pas encore été envoyée :

// Récupère le body brut sous forme de string
$body = $response->getContent(); // string

// Décode le JSON et retourne un tableau PHP associatif
$data = $response->toArray();   // array

// Retourne le code HTTP (200, 404, 503…)
$code = $response->getStatusCode(); // int

Les headers sont aussi accessibles :

// Retourne un tableau indexé par nom de header (en minuscules)
// Chaque valeur est un tableau de strings (un header peut apparaître plusieurs fois)
$headers = $response->getHeaders(); // array<string, list<string>>

// Exemple : récupérer le Content-Type
$type = $headers['content-type'][0] ?? '';
$client->request()
ResponseInterface
lazy, pas de réseau
getContent()
toArray()
getStatusCode()
Requête réseau
maintenant seulement
💡 Pourquoi lazy ? Tu peux créer N réponses en quelques microsecondes, puis les lire en parallèle. CurlHttpClient tire parti de curl_multi_exec pour traiter plusieurs connexions simultanément sans threads.
🔑 getInfo() : inspecte les métadonnées d'une réponse sans déclencher la lecture du body. Exemples : $response->getInfo('url'), $response->getInfo('http_code'), $response->getInfo('total_time').

Explore le cycle de vie lazy d'une réponse — clique sur les étapes dans l'ordre.

01 — OPTIONS DE REQUÊTE

Les options de requête

json, body, headers, auth_basic/auth_bearer, timeout, on_progress — une option par cas d'usage.

Chaque requête peut être configurée via un tableau d'options passé en troisième paramètre de request(). Voici les options les plus courantes, classées par cas d'usage.

Envoyer du JSON

// L'option 'json' encode automatiquement en JSON
// et ajoute Content-Type: application/json
$response = $client->request('POST', 'https://api.example.com/users', [
    'json' => [
        'name'  => 'Alice',
        'email' => 'alice@example.com',
    ],
]);

Envoyer un formulaire

// L'option 'body' avec un tableau envoie application/x-www-form-urlencoded
// Avec une string ou un Traversable, c'est le body brut
$response = $client->request('POST', 'https://auth.example.com/token', [
    'body' => [
        'grant_type' => 'client_credentials',
        'client_id'  => 'my-app',
    ],
]);

Authentification

// HTTP Basic Authentication
$response = $client->request('GET', 'https://api.example.com/data', [
    'auth_basic' => ['user', 'secret'], // ou 'user:secret'
]);

// Bearer Token (JWT, OAuth2…)
$response = $client->request('GET', 'https://api.example.com/me', [
    'auth_bearer' => $token, // ajoute Authorization: Bearer <token>
]);

Headers et query string

$response = $client->request('GET', 'https://api.example.com/search', [
    'headers' => [
        'Accept'    => 'application/json',
        'X-API-Key' => 'abc123',
    ],
    'query' => [
        'q'    => 'symfony',
        'page' => 2,
    ], // → ?q=symfony&page=2
]);

Timeout et durée maximale

$response = $client->request('GET', 'https://slow-api.example.com/report', [
    // timeout : délai d'inactivité réseau (en secondes, float)
    // Si aucun octet reçu pendant X secondes → TransportExceptionInterface
    'timeout'      => 5.0,

    // max_duration : durée totale maximale de la requête (0 = illimité)
    'max_duration' => 30.0,
]);

Progression

$response = $client->request('GET', 'https://files.example.com/large.zip', [
    // on_progress : callback appelé à chaque chunk reçu
    'on_progress' => function (int $dlNow, int $dlSize, array $info): void {
        if ($dlSize > 0) {
            $pct = round($dlNow / $dlSize * 100);
            echo "\rTéléchargement : {$pct} %";
        }
    },
]);
🔑 json vs body : 'json' sérialise et pose Content-Type: application/json automatiquement. 'body' avec un tableau pose Content-Type: application/x-www-form-urlencoded. Avec une string, 'body' envoie le contenu brut sans toucher aux headers.
💡 Options globales via withOptions() : pour appliquer des options à toutes les requêtes d'un client (base_uri, auth_bearer, timeout…), utilise $client->withOptions([...]) — il retourne une nouvelle instance sans modifier l'originale.
OptionTypeDéfautUsage
jsonmixednullBody JSON — encode + Content-Type auto
bodyarray|string|Traversable''Body form ou brut
headersarray[]Headers HTTP
queryarray[]Paramètres de query string
auth_basicstring|arraynullAuthentification Basic
auth_bearerstringnullAuthentification Bearer
timeoutfloatnullInactivité réseau (secondes)
max_durationfloat0Durée totale max (0 = illimité)
max_redirectsint20Nombre max de redirections
verify_peerbooltrueVérifier le certificat SSL
on_progresscallablenullCallback de progression
bufferbooltrueBufferiser le body complet en mémoire
02 — GÉRER LES EXCEPTIONS

Gérer les exceptions

4xx, 5xx, timeout, JSON invalide — chaque erreur a sa propre exception.

Le HttpClient de Symfony suit un principe clair : par défaut, toute réponse non-2xx lève une exception. Tu peux désactiver ce comportement en passant false aux méthodes de lecture.

Hiérarchie des exceptions HttpClient
Toutes héritent de HttpExceptionInterface qui étend TransportExceptionInterface. La distinction clé : les exceptions transport surviennent avant d'avoir reçu une réponse HTTP (timeout, DNS, TLS). Les exceptions HTTP surviennent après — la réponse est reçue mais son code indique une erreur.
ExceptionDéclencheur
TransportExceptionInterface Erreur réseau : timeout, DNS, TLS, connexion refusée
ClientExceptionInterface Réponse 4xx (400, 401, 403, 404…)
ServerExceptionInterface Réponse 5xx (500, 502, 503…)
RedirectionExceptionInterface Redirection 3xx non résolue (max_redirects dépassé)
DecodingExceptionInterface Body non JSON valide lors de toArray()

Exemple complet de gestion d'erreurs :

use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;

try {
    $response = $client->request('GET', 'https://api.example.com/users/42');
    $data = $response->toArray(); // déclenche les exceptions HTTP et JSON

} catch (ClientExceptionInterface $e) {
    // Erreur côté client : 404, 403, 401…
    // La réponse est accessible via $e->getResponse()
    $code = $e->getResponse()->getStatusCode();
    echo "Erreur client {$code}";

} catch (ServerExceptionInterface $e) {
    // Erreur serveur : 500, 503…
    echo "Erreur serveur, réessaie plus tard";

} catch (TransportExceptionInterface $e) {
    // Problème réseau : DNS, TLS, timeout, connexion refusée
    echo "Problème réseau : {$e->getMessage()}";

} catch (DecodingExceptionInterface $e) {
    // Le body reçu n'est pas du JSON valide
    echo "Réponse JSON invalide";
}

Pour ignorer les codes d'erreur et lire la réponse quand même :

$response = $client->request('GET', 'https://api.example.com/unknown');

// false = ne pas lancer d'exception sur les codes d'erreur
$code = $response->getStatusCode(); // lit le code sans exception
$body = $response->getContent(false); // lit le body, même si 4xx/5xx
$data = $response->toArray(false);   // décode sans lever ClientExceptionInterface

if ($code === 404) {
    // gestion manuelle
}
⚠️ Piège : getStatusCode() est la seule méthode qui ne lance jamais d'exception HTTP (4xx/5xx). Elle peut en revanche lancer TransportExceptionInterface si la connexion réseau elle-même a échoué.
💡 Accéder à la réponse depuis l'exception : ClientExceptionInterface, ServerExceptionInterface et RedirectionExceptionInterface exposent toutes getResponse(), ce qui permet d'inspecter le body de la réponse d'erreur (message d'API, détail de l'erreur…).
03 — STREAMING

Streaming — traiter les réponses volumineuses

stream() retourne un itérateur de ChunkInterface — on traite le body au fur et à mesure.

Télécharger un fichier de 500 Mo avec getContent() charge tout en mémoire. Le streaming permet de traiter le body fragment par fragment — empreinte mémoire constante quelle que soit la taille de la réponse.

Streaming avec stream()
$client->stream($response) retourne un ResponseStreamInterface, un itérateur qui produit des paires (ResponseInterface, ChunkInterface). Chaque chunk représente un fragment du body — ou un événement spécial (headers reçus, fin du body, timeout).

Lecture d'un fichier volumineux en streaming :

// buffer:false est indispensable — sinon Symfony bufferise quand même
$response = $client->request('GET', 'https://files.example.com/data.jsonl', [
    'buffer' => false,
]);

$fh = fopen('/tmp/data.jsonl', 'wb');

foreach ($client->stream($response) as $chunk) {
    if ($chunk->isFirst()) {
        // Les headers sont arrivés — on peut lire le status code
        // mais pas encore de body
        $code = $response->getStatusCode();
        if ($code !== 200) break;
    }

    if ($chunk->isLast()) {
        // Body complet reçu
        break;
    }

    if ($chunk->isTimeout()) {
        // Aucun octet reçu pendant le délai 'timeout'
        continue;
    }

    // Écriture du fragment sur disque
    fwrite($fh, $chunk->getContent());
    echo "Offset : {$chunk->getOffset()} octets reçus\n";
}

fclose($fh);

Les méthodes clés de ChunkInterface :

MéthodeDescription
isFirst()Premier chunk — les headers sont disponibles
isLast()Dernier chunk — le body est complet
isTimeout()Aucune donnée reçue pendant le délai timeout
getContent()Fragment de body reçu (string, peut être vide)
getOffset()Position en octets dans le stream total
⚠️ buffer: false obligatoire : par défaut, Symfony bufferise le body complet en mémoire même si tu utilises stream(). Sans 'buffer' => false, tu ne bénéficies pas du streaming — toute la réponse est déjà en RAM dès le premier chunk.
💡 Server-Sent Events : EventSourceHttpClient (un décorateur) simplifie la lecture de flux SSE. Il gère le parsing du protocole text/event-stream et expose les events directement via stream().

Clique sur Démarrer le stream pour simuler la réception de chunks.

04 — CONCURRENCE

Concurrence — plusieurs requêtes en parallèle

stream() sur un tableau de réponses traite chaque requête dès qu'elle a des données — pas d'attente séquentielle.

Imagine que tu dois récupérer 10 ressources d'une API. En séquentiel, tu attends chaque réponse avant d'envoyer la suivante — c'est 10× le temps d'une requête. Avec le HttpClient de Symfony, tu lances toutes les requêtes, puis stream() traite chaque réponse dès qu'elle a des données disponibles.

Concurrence avec stream()
$client->stream(iterable $responses) accepte un tableau de ResponseInterface. CurlHttpClient multiplex les connexions via curl_multi_exec — plusieurs requêtes progressent simultanément sur les mêmes threads PHP.

Pattern classique — fan-out + collect :

// 1. Lancer toutes les requêtes (aucune n'a encore bloqué)
$responses = [];
foreach ([1, 2, 3, 4, 5] as $id) {
    $responses[$id] = $client->request('GET', "https://api.example.com/users/{$id}");
}

// 2. stream() traite chaque réponse dès qu'elle a des données
// L'ordre de traitement n'est PAS garanti — c'est la plus rapide qui sort en premier
foreach ($client->stream($responses) as $response => $chunk) {
    if (!$chunk->isLast()) {
        continue; // ignore les chunks intermédiaires
    }

    // Body complet reçu — identifier la requête via getInfo()
    $url  = $response->getInfo('url');
    $data = $response->toArray();
    echo "Reçu {$url} : {$data['name']}\n";
}

Utiliser getInfo() pour identifier la réponse dans le contexte de la boucle :

// user_data permet d'attacher n'importe quelle donnée à la requête
// pour la retrouver dans stream()
$responses = [];
foreach ($userIds as $id) {
    $responses[] = $client->request('GET', "/users/{$id}", [
        'user_data' => ['id' => $id],
    ]);
}

foreach ($client->stream($responses) as $response => $chunk) {
    if (!$chunk->isLast()) continue;

    $meta = $response->getInfo('user_data');
    echo "User #{$meta['id']} reçu\n";
}

Annuler une requête pendant le streaming :

foreach ($client->stream($responses) as $response => $chunk) {
    if ($chunk->isFirst() && $response->getStatusCode() === 404) {
        // Annule la connexion — libère les ressources cURL immédiatement
        $response->cancel();
    }
}
request() ×5
aucun réseau encore
stream([$r1,$r2,…])
curl_multi_exec
foreach
$response => $chunk
isLast()
traite dès réception
⚠️ Pas de fils d'exécution : la concurrence est gérée par curl_multi_exec dans le même thread PHP. Ce n'est pas du multithreading — c'est de l'I/O multiplexée. L'ordre de retour dépend du réseau, pas de l'ordre d'envoi.
💡 Timeout sur stream() : $client->stream($responses, 1.5) — le second paramètre est un timeout en secondes. Si aucune réponse n'a de données pendant ce délai, la boucle produit un chunk avec isTimeout() === true.
05 — SCOPED CLIENTS

Scoped clients — options par base URI

ScopingHttpClient applique des options par défaut à un scope d'URLs — withOptions() retourne une nouvelle instance.

Au lieu de répéter 'auth_bearer' => $token, 'base_uri' => 'https://api.example.com' à chaque requête, tu peux créer un client scopé qui applique automatiquement ces options à toutes ses requêtes.

ScopingHttpClient
Décorateur qui applique des options par défaut aux requêtes dont l'URL correspond à un pattern. Plusieurs scopes peuvent coexister dans le même client. withOptions() retourne une nouvelle instance sans modifier l'originale.

Créer un client scopé avec HttpClient::createForBaseUri() :

use Symfony\Component\HttpClient\HttpClient;

// Toutes les requêtes relatives seront résolues contre cette base URI
// Les options s'appliquent uniquement aux URLs qui commencent par la base URI
$apiClient = HttpClient::createForBaseUri('https://api.example.com', [
    'auth_bearer' => $token,
    'timeout'     => 10.0,
    'headers'     => ['Accept' => 'application/json'],
]);

// Plus besoin de répéter auth_bearer et timeout
$user   = $apiClient->request('GET', '/users/42')->toArray();
$orders = $apiClient->request('GET', '/orders?user_id=42')->toArray();

Utiliser ScopingHttpClient directement pour plusieurs scopes :

use Symfony\Component\HttpClient\ScopingHttpClient;
use Symfony\Component\HttpClient\HttpClient;

$client = ScopingHttpClient::forBaseUri(
    HttpClient::create(),
    'https://api.github.com',
    [
        'auth_bearer' => $githubToken,
        'headers'     => ['Accept' => 'application/vnd.github+json'],
    ]
);

// Construction avancée : plusieurs regexp → options différentes
$multiClient = new ScopingHttpClient(
    HttpClient::create(),
    [
        'https://api\.github\.com'  => ['auth_bearer' => $githubToken],
        'https://api\.stripe\.com'  => ['auth_basic'  => $stripeKey],
    ],
    // regexp par défaut (quand aucune ne match)
    'https://api\.github\.com'
);

Créer un client dérivé avec des options supplémentaires via withOptions() :

// withOptions() retourne une NOUVELLE instance — n'affecte pas $apiClient
$uploadClient = $apiClient->withOptions([
    'timeout'      => 120.0, // timeout plus long pour les uploads
    'max_duration' => 300.0,
]);

// $apiClient conserve timeout:10.0 — $uploadClient a timeout:120.0

Configuration YAML — clients nommés autowirés :

# config/packages/framework.yaml
framework:
  http_client:
    clients:
      github_client:
        base_uri: 'https://api.github.com'
        auth_bearer: '%env(GITHUB_TOKEN)%'
        timeout: 10
      stripe_client:
        base_uri: 'https://api.stripe.com'
        auth_basic: '%env(STRIPE_SECRET_KEY)%'
// Injection par attribut — Symfony 6.3+
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;

public function __construct(
    #[Target('github_client')]
    private readonly HttpClientInterface $github,
    #[Target('stripe_client')]
    private readonly HttpClientInterface $stripe,
) {}
💡 Fusion des options : les options passées à request() fusionnent avec les options par défaut du client scopé, elles ne les remplacent pas. Un header ajouté à request() s'ajoute aux headers du scope.
06 — RETRY AUTOMATIQUE

RetryableHttpClient — retry automatique

RetryableHttpClient retente automatiquement sur les erreurs transitoires — codes HTTP, délai exponentiel, jitter.

Certaines erreurs sont transitoires : un 503 temporaire, un 429 (rate limit), une coupure réseau. Relancer la requête quelques instants plus tard suffit souvent à résoudre le problème. RetryableHttpClient automatise ce comportement.

RetryableHttpClient
Décorateur qui retente automatiquement les requêtes échouées selon une stratégie configurable. Par défaut : 3 tentatives maximum, délai exponentiel (1s, 2s, 4s) avec jitter de 10 % pour éviter les "thundering herds".

Usage minimal avec les valeurs par défaut :

use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\RetryableHttpClient;

// 3 tentatives max, stratégie par défaut (429, 500, 502, 503, 504…)
$client = new RetryableHttpClient(HttpClient::create());

Configuration avancée avec GenericRetryStrategy :

use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;

$strategy = new GenericRetryStrategy(
    // codes HTTP qui déclenchent un retry (0 = erreur transport)
    statusCodes: [0, 429, 500, 502, 503, 504],
    // délai initial en millisecondes
    delayMs:     500,
    // multiplicateur exponentiel (500ms → 1000ms → 2000ms…)
    multiplier:  2.0,
    // délai maximum quelle que soit l'exponentielle (0 = illimité)
    maxDelayMs:  10_000,
    // jitter : variation aléatoire de ±10 % du délai
    jitter:      0.1,
);

$client = new RetryableHttpClient(
    client:     HttpClient::create(),
    strategy:   $strategy,
    maxRetries: 5,
);

Les codes HTTP retentés par défaut :

CodeSignificationNote
0Erreur transport (DNS, TLS, timeout)Toutes méthodes
429Too Many RequestsToutes méthodes
500Internal Server ErrorMéthodes idempotentes seulement
502Bad GatewayToutes méthodes
503Service UnavailableToutes méthodes
504Gateway TimeoutToutes méthodes
507Insufficient StorageToutes méthodes
510Not ExtendedToutes méthodes
🔑 Méthodes idempotentes : GET, HEAD, PUT, DELETE, OPTIONS, TRACE. POST et PATCH ne sont pas idempotents — pour les codes 500, GenericRetryStrategy ne retente que si la méthode est idempotente, pour éviter les effets de bord (double paiement, double insertion…).

Configuration YAML avec retry_failed :

# config/packages/framework.yaml
framework:
  http_client:
    clients:
      api_client:
        base_uri: 'https://api.example.com'
        retry_failed:
          max_retries: 3
          delay:       1000
          multiplier:  2.0
          jitter:      0.1
          http_codes:  [429, 500, 503]

Implémenter une stratégie personnalisée — par exemple pour respecter le header Retry-After :

use Symfony\Component\HttpClient\Retry\RetryStrategyInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

final class RetryAfterStrategy implements RetryStrategyInterface
{
    public function shouldRetry(
        ResponseInterface $response,
        ?string $responseContent,
        ?TransportExceptionInterface $exception
    ): ?bool {
        return $response->getStatusCode() === 429;
    }

    public function getDelay(
        ResponseInterface $response,
        ?string $responseContent,
        ?TransportExceptionInterface $exception
    ): int {
        // Respecte le délai demandé par l'API (en secondes → millisecondes)
        $retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;
        return $retryAfter !== null
            ? (int) $retryAfter * 1000
            : 5000; // fallback : 5 secondes
    }
}
💡 Jitter : ajouter une variation aléatoire au délai évite que tous les clients retentent exactement au même instant après un incident — ce qui amplifierait la charge sur le serveur en récupération.
07 — MOCKHTTPCLIENT

MockHttpClient — tester sans réseau

MockHttpClient remplace le vrai client dans les tests — aucune requête réseau.

Tester du code qui fait des requêtes HTTP sans dépendre du réseau ni d'un serveur externe, c'est possible avec MockHttpClient. Il remplace le vrai client — même interface, aucune connexion réelle.

MockHttpClient
Implémentation de HttpClientInterface qui retourne des réponses prédéfinies sans effectuer de requêtes réseau. Utilisé dans les tests unitaires et d'intégration pour contrôler exactement ce que le client retourne.

Réponse statique avec MockResponse :

use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

// Une seule réponse JSON
$mock = new MockHttpClient(
    new MockResponse(
        json_encode(['id' => 42, 'name' => 'Alice']),
        ['http_code' => 200, 'response_headers' => ['Content-Type: application/json']]
    )
);

$data = $mock->request('GET', '/users/42')->toArray();
// $data = ['id' => 42, 'name' => 'Alice']

Plusieurs réponses consécutives (une par requête) :

$mock = new MockHttpClient([
    new MockResponse('{"status":"processing"}', ['http_code' => 202]),
    new MockResponse('{"status":"done"}',       ['http_code' => 200]),
]);

$r1 = $mock->request('POST', '/jobs')->toArray();    // ['status' => 'processing']
$r2 = $mock->request('GET', '/jobs/1')->toArray();  // ['status' => 'done']

Réponse dynamique avec une factory callable :

// La factory reçoit la méthode, l'URL et les options de chaque requête
// Permet de valider les paramètres et de retourner une réponse conditionnelle
$mock = new MockHttpClient(function (string $method, string $url, array $options): MockResponse {
    // Vérifier que la requête est correctement formée
    if (!str_contains($url, '/users/')) {
        return new MockResponse('', ['http_code' => 404]);
    }

    $id = basename($url);
    return new MockResponse(
        json_encode(['id' => (int) $id, 'name' => "User {$id}"]),
        ['http_code' => 200]
    );
});

Inspecter les requêtes envoyées depuis le code testé :

$responses = [
    new MockResponse('{"created":true}', ['http_code' => 201]),
];
$mock = new MockHttpClient($responses, 'https://api.example.com');

// … exécuter le code testé qui utilise $mock …

// Inspecter la requête qui a été envoyée
$sentRequest = $responses[0];
assertEquals('POST',                          $sentRequest->getRequestMethod());
assertEquals('https://api.example.com/users', $sentRequest->getRequestUrl());
$body = json_decode($sentRequest->getRequestOptions()['body'], true);
assertEquals('Alice', $body['name']);

Simuler un body streamé (utile pour tester stream()) :

// MockResponse accepte un Traversable pour simuler des chunks
$chunks = ['{"id":1}', "\n", '{"id":2}', "\n"];
$mock   = new MockHttpClient(
    new MockResponse((function () use ($chunks) { yield from $chunks; })())
);
💡 MockResponse::fromFile() : charge le body mock depuis un fichier — pratique pour des fixtures JSON complexes. MockResponse::fromFile('/path/to/fixture.json', ['http_code' => 200]).
🔑 Injection dans les tests Symfony : déclare le service MockHttpClient dans config/packages/test/framework.yaml ou via $this->getContainer()->set(HttpClientInterface::class, $mock) dans un KernelTestCase.
08 — LES DÉCORATEURS

Les décorateurs HttpClient

HttpClient suit le patron décorateur — chaque wrapper ajoute une fonctionnalité sans modifier le client de base.

Le HttpClient de Symfony est conçu autour du patron décorateur : un client de base (CurlHttpClient ou NativeHttpClient) qui implémente HttpClientInterface, encapsulé dans des wrappers qui ajoutent des fonctionnalités sans toucher au code du client de base ni à ton code applicatif.

Patron décorateur
Chaque décorateur implémente la même interface que le composant de base et le délègue. Tu peux empiler autant de décorateurs que nécessaire — l'ordre compte. Le code client ne sait pas dans combien de couches la requête passe.
Ton code
HttpClientInterface
RetryableHttpClient
CachingHttpClient
CurlHttpClient
requête réelle

Vue d'ensemble de tous les décorateurs disponibles :

DécorateurFonctionnalité ajoutée
ScopingHttpClient Options par défaut par base URI ou regexp
RetryableHttpClient Retry automatique sur erreurs transitoires
CachingHttpClient Cache RFC 9111 — GET/HEAD uniquement
ThrottlingHttpClient Rate limiting via RateLimiterInterface
NoPrivateNetworkHttpClient Bloque les requêtes vers IPs privées (sécurité SSRF)
EventSourceHttpClient Parsing Server-Sent Events (SSE)
TraceableHttpClient Profiling — enregistre toutes les requêtes
MockHttpClient Fausses réponses — tests sans réseau

CachingHttpClient — cache HTTP standard :

use Symfony\Component\HttpClient\CachingHttpClient;
use Symfony\Component\HttpKernel\HttpCache\Store;

// Store : répertoire de stockage du cache (fichiers)
$store  = new Store(__DIR__ . '/cache/http');
$client = new CachingHttpClient(HttpClient::create(), $store);

// Requête mise en cache si la réponse a des headers de cache valides
// (Cache-Control: max-age, ETag, Last-Modified…)
$response = $client->request('GET', 'https://api.example.com/config');

ThrottlingHttpClient — rate limiting :

use Symfony\Component\HttpClient\ThrottlingHttpClient;
use Symfony\Component\RateLimiter\RateLimiterFactory;

// RateLimiterFactory configuré via le composant symfony/rate-limiter
$limiter = $factory->create('github-api');
$client  = new ThrottlingHttpClient(HttpClient::create(), $limiter);

// Chaque request() attend la disponibilité d'un token avant d'envoyer

NoPrivateNetworkHttpClient — protection SSRF :

use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;

// Bloque les requêtes vers : 127.x.x.x, 10.x.x.x, 192.168.x.x,
// 172.16.x.x–172.31.x.x, ::1, fc00::/7, link-local…
// Indispensable si l'URL provient d'une entrée utilisateur
$safeClient = new NoPrivateNetworkHttpClient(HttpClient::create());

TraceableHttpClient — profiling :

use Symfony\Component\HttpClient\TraceableHttpClient;

// Utilisé automatiquement par le Profiler Symfony en mode dev/test
$client = new TraceableHttpClient(HttpClient::create());
$client->request('GET', 'https://api.example.com/data');

// Inspecter toutes les requêtes tracées
foreach ($client->getTracedRequests() as $req) {
    echo "{$req['method']} {$req['url']} → {$req['info']['http_code']}\n";
}
💡 Ordre des décorateurs : place RetryableHttpClient à l'extérieur de CachingHttpClient — tu veux retenter si le cache miss renvoie une erreur réseau, pas retenter depuis le cache. Pour NoPrivateNetworkHttpClient, il doit être le plus proche du réseau (intérieur de la pile).
09 — SYNTHÈSE

Synthèse — le guide de choix

Quel client, quelles options, quel décorateur — le guide de décision.

Un seul HttpClientInterface, plusieurs implémentations et décorateurs. Le bon choix dépend du contexte.

Choisir le bon client de base

ClientQuand l'utiliser
HttpClient::create() Toujours — sélection automatique du meilleur disponible
CurlHttpClient Production : HTTP/2, concurrence native, HTTP/3 si ext-curl récente
NativeHttpClient Environnements sans ext-curl (containers légers, scripts CLI)
MockHttpClient Tests unitaires et d'intégration uniquement

Choisir les bonnes options

BesoinOption
Envoyer du JSON'json' => $data
Envoyer un formulaire'body' => ['key' => 'value']
Authentification API'auth_bearer' => $token
Authentification Basic'auth_basic' => ['user', 'pass']
Paramètres URL'query' => ['page' => 2]
Timeout réseau'timeout' => 5.0
Streaming sans mémoire'buffer' => false + stream()
Suivi progression'on_progress' => fn(…) => …

Choisir le bon décorateur

BesoinDécorateur
Options par défaut par APIScopingHttpClient
Retry automatiqueRetryableHttpClient
Cache RFC 9111CachingHttpClient
Rate limitingThrottlingHttpClient
Sécurité SSRF (URL user)NoPrivateNetworkHttpClient
Server-Sent EventsEventSourceHttpClient
Profiling/debugTraceableHttpClient

Gérer les erreurs

Laisser les exceptions remonter

Par défaut — toArray() et getContent() lèvent une exception sur 4xx/5xx. Attrape ClientExceptionInterface, ServerExceptionInterface, TransportExceptionInterface.

try {
  $data = $response->toArray();
} catch (ClientExceptionInterface $e) {
  // 4xx
}
Désactiver les exceptions

Passe false pour inspecter manuellement le status code — utile quand une réponse 404 est un comportement attendu.

$code = $response->getStatusCode();
$body = $response->getContent(false);
if ($code === 404) { ... }

Pièges à éviter

Recette complète pour une intégration API robuste
NoPrivateNetworkHttpClient (sécurité) → CachingHttpClient (cache) → RetryableHttpClient (résilience) → ScopingHttpClient (base URI + auth) → CurlHttpClient (réseau).