Skip to content

Request & Response — Headers, Files, Cache, Streamed

TL;DRRequest et Response (composant HttpFoundation) sont des OO wrappers sur les superglobales PHP, mais immutables-ish et thread-safe. Les sous-classes (JsonResponse, StreamedResponse, BinaryFileResponse) couvrent 95% des cas. RequestStack est la seule façon correcte d'accéder à la requête courante dans un service. Les cache headers (ETag, Last-Modified, Cache-Control) bien gérés divisent ta charge serveur par 10.

🧠 Mental model — ASCII diagram + analogie

   ┌─────────────────────────────────────────────────────────────┐
   │ Request                                                      │
   │   ParameterBag query   ← $_GET                               │
   │   ParameterBag request ← $_POST                              │
   │   ParameterBag attributes ← _route, _controller, etc.        │
   │   ServerBag    server  ← $_SERVER                            │
   │   HeaderBag    headers ← extracted from $_SERVER             │
   │   FileBag      files   ← $_FILES → UploadedFile[]            │
   │   ParameterBag cookies ← $_COOKIE                            │
   │   string       content ← php://input (raw body)              │
   └─────────────────────────────────────────────────────────────┘

                              ▼ controller logic
   ┌─────────────────────────────────────────────────────────────┐
   │ Response                                                     │
   │   int    statusCode    (200, 404, etc.)                      │
   │   string content       (HTML, JSON, binary…)                 │
   │   ResponseHeaderBag    (also manages cookies)                │
   │                                                              │
   │   Subclasses:                                                │
   │   ├─ JsonResponse        : auto Content-Type, json_encode    │
   │   ├─ RedirectResponse    : 302 + Location                    │
   │   ├─ StreamedResponse    : closure(), chunked output         │
   │   ├─ BinaryFileResponse  : sendfile, X-Sendfile, Range       │
   │   └─ StreamedJsonResponse: stream JSON iterables (6.2+)      │
   └─────────────────────────────────────────────────────────────┘

Analogie : Request = courrier reçu avec enveloppe (headers), adresse retour (cookies), pièces jointes (files), contenu (body). Response = colis à expédier avec étiquette (status code), instructions de cache, contenu, et options de transport (stream, binary).

Le modèle mental qui compte vraiment

Trois idées qu'un staff engineer garde en tête et qui expliquent 90% des bugs :

  1. Request n'est PAS une copie figée des superglobales — c'est un parseur paresseux. Request::createFromGlobals() capture $_GET/$_POST/$_SERVER/... à l'instant T, mais la résolution du client IP, du scheme, du host, du format passe par des méthodes (getClientIp(), getScheme(), getHost()) qui appliquent la logique trusted proxies au moment de l'appel. Lire $request->server->get('REMOTE_ADDR') à la main court-circuite cette logique : c'est la cause n°1 des bugs « derrière un load balancer ».

  2. Response est mutable, mais le HTTP qu'elle décrit ne l'est pas une fois send() appelé. Le pattern « immutable-ish » des docs signifie : traite la Response comme un builder que tu termines avant le return. Après que le Kernel a appelé send(), toute mutation est un no-op silencieux (headers déjà flush vers le client). Voir Pitfall #3.

  3. Le « request courant » est une notion de pile, pas une variable globale. Une sub-request (ESI, fragment render() Twig, embed), un message Messenger, une commande CLI : chacun pousse/dépile un Request sur le RequestStack. getCurrentRequest() peut donc changer dans la même milliseconde, et retourner null hors contexte HTTP. C'est pourquoi on injecte RequestStack et jamais le Request directement dans un service à longue durée de vie (cf. worker Messenger, RoadRunner, FrankenPHP worker mode).

Anatomie des ParameterBag — lequel pour quoi

Confondre les bags est l'erreur la plus fréquente. Mémorise cette table :

BagSourceTypeAccès typiquePiège
query$_GETInputBag?page=2scalaire only depuis 5.1 (get() throw sur array)
request$_POST (form-urlencoded / multipart)InputBagform HTMLvide si body JSON
attributesruntime (router, listeners)ParameterBag_route, _controller, params d'URLnon sérialisé, interne
cookies$_COOKIEInputBagsession id, prefsjamais de secret en clair
files$_FILESFileBagUploadedFileborné par max_file_uploads (20)
server$_SERVERServerBagREMOTE_ADDR, HTTPSpasser par les getters, pas le bag
headersdérivé de serverHeaderBagAuthorization, Acceptcasse-insensible
getPayload()php://input parséInputBagAPI JSON ou form (6.3+)le bon défaut pour les API

Règle staff : pour une API, lis $request->getPayload() (unifie JSON et form-data, typé, validé) ; pour un form HTML classique, $request->request ; pour de la query string, $request->query. Tu ne fais quasi jamais json_decode($request->getContent()) à la main en 7.x.

🛠️ Code minimal — variantes courantes

php
// JSON Response
use Symfony\Component\HttpFoundation\JsonResponse;

return new JsonResponse([
    'id' => 42,
    'name' => 'foo',
], JsonResponse::HTTP_OK, headers: ['X-Custom' => 'v1']);
php
// Streamed Response — gros export CSV
use Symfony\Component\HttpFoundation\StreamedResponse;

$response = new StreamedResponse(function () use ($userRepo) {
    $handle = fopen('php://output', 'wb');
    fputcsv($handle, ['id', 'email', 'createdAt']);
    foreach ($userRepo->iterate() as $row) {
        fputcsv($handle, $row);
    }
    fclose($handle);
});
$response->headers->set('Content-Type', 'text/csv; charset=UTF-8');
$response->headers->set(
    'Content-Disposition',
    $response->headers->makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, 'users.csv')
);
return $response;
php
// Binary file (download + Range support natif)
use Symfony\Component\HttpFoundation\BinaryFileResponse;

$response = new BinaryFileResponse('/var/data/report.pdf');
$response->setContentDisposition(
    HeaderUtils::DISPOSITION_ATTACHMENT,
    'rapport-2026.pdf'
);
$response->setAutoLastModified();
$response->setAutoEtag();
return $response;
php
// File upload handling
use Symfony\Component\HttpFoundation\File\Exception\FileException;

#[Route('/upload', methods: ['POST'])]
public function upload(Request $request): JsonResponse
{
    /** @var UploadedFile|null $file */
    $file = $request->files->get('avatar');
    if (!$file) {
        throw new BadRequestHttpException('No file uploaded');
    }

    if (!in_array($file->getMimeType(), ['image/jpeg', 'image/png'], true)) {
        throw new UnsupportedMediaTypeHttpException();
    }

    $name = sprintf('%s.%s', bin2hex(random_bytes(16)), $file->guessExtension());
    try {
        $file->move($this->getParameter('uploads_dir'), $name);
    } catch (FileException $e) {
        throw new \RuntimeException('Upload failed', previous: $e);
    }

    return new JsonResponse(['filename' => $name], 201);
}
php
// RequestStack dans un service (jamais $_GET direct)
namespace App\Service;

use Symfony\Component\HttpFoundation\RequestStack;

final readonly class CurrentLocaleService
{
    public function __construct(private RequestStack $requestStack) {}

    public function getLocale(): string
    {
        $req = $this->requestStack->getCurrentRequest();
        return $req?->getLocale() ?? 'en';
    }
}
php
// Cache headers — ETag / Last-Modified
public function show(Request $request, Article $article): Response
{
    $response = new Response();
    $response->setEtag(md5($article->getUpdatedAt()->format('U').$article->getId()));
    $response->setLastModified($article->getUpdatedAt());
    $response->setPublic();
    $response->setMaxAge(300);

    if ($response->isNotModified($request)) {
        return $response; // 304 Not Modified, content vide
    }

    $response->setContent($this->renderView('article/show.html.twig', ['article' => $article]));
    return $response;
}
php
// Immutable response pattern (6.x best practice)
$response = (new Response())
    ->setStatusCode(201)
    ->setContent(json_encode($data))
    ->setCharset('UTF-8');
$response->headers->set('Content-Type', 'application/json');
return $response;

🎯 Patterns courants

  1. JsonResponse::fromJsonString() — si tu as déjà du JSON pré-encodé (cache), évite la double-encode. Sinon new JsonResponse($array) qui appelle json_encode interne.
  2. StreamedResponse pour export gros volume — au lieu de fetchAll() → array → encode → send (RAM 1GB), tu stream ligne par ligne (RAM ~10MB). Combine avec Doctrine iterate() ou toIterable().
  3. X-Sendfile / X-Accel-Redirect — pour servir des gros fichiers, déléguer à nginx/Apache. BinaryFileResponse::trustXSendfileTypeHeader() + config nginx → PHP n'a même pas à lire le fichier.
  4. Request::isXmlHttpRequest() — vérifie si la requête est AJAX (header X-Requested-With: XMLHttpRequest). Utile pour servir HTML partiel vs page complète.
  5. Request::getContent() vs request->get()getContent() pour le raw body (utile JSON), request->get('foo') pour form fields. request->all() retourne $_POST complet.
  6. Response::sendHeaders() + sendContent() — appelés automatiquement par Response::send(). Tu peux les appeler séparément pour du streaming custom.

🔄 Versions — Symfony 5.4 / 6.4 / 7.x

  • 5.4 : Request/Response stables depuis Symfony 2.0. getContent() retourne string|resource.
  • 6.0 : Request::HEADER_X_FORWARDED_ALL renommé/déprécié → utiliser HEADER_X_FORWARDED_FOR | HEADER_X_FORWARDED_HOST | HEADER_X_FORWARDED_PORT | HEADER_X_FORWARDED_PROTO.
  • 6.1 : Request::getPreferredFormat(), amélioration getMimeType().
  • 6.2 : 🌟 StreamedJsonResponse — stream un iterable directement en JSON sans tout charger en RAM. Game changer pour gros listings API.
  • 6.3 : amélioration BinaryFileResponse Range support, Response::setCallback() pour StreamedResponse plus expressif.
  • 6.4 LTS : Request::getPayload() (depuis 6.3) retourne un InputBag typé pour php://input parsé (JSON / form-data unifié). Préférable à $request->request->all() pour les API.
  • 7.0 : suppression HEADER_X_FORWARDED_ALL. Suppression Request::getSession() qui throw si pas de session (au lieu de retourner null). Use Request::hasSession() avant.
  • 7.1+ : Response::setCookie() accepte __Host- prefix natif, validation auto. JsonResponse cache des flags JSON_* par défaut (perf).

⚠️ Pitfalls

  1. $_GET / $_POST direct dans un service — bypass de RequestStack, casse en CLI, en worker mode, en sub-request. Toujours injecter RequestStack.
  2. Request::request->get('foo') avec JSON bodyrequest (ParameterBag) ne contient PAS le body JSON parsé. Utiliser json_decode($request->getContent(), true) ou $request->getPayload()->all() (6.3+).
  3. Response::setContent() après send() — modifier la response après envoi = silently ignored. Toujours setter avant return.
  4. Cookie path/domain manquantsetcookie('foo', 'bar') natif a des defaults différents de Cookie::create(). Toujours préciser path, domain, secure, httpOnly, samesite.
  5. getClientIp() derrière proxy — sans configurer framework.trusted_proxies, retourne l'IP du LB, pas du client réel. Configurer en prod : framework.trusted_proxies: '127.0.0.1,REMOTE_ADDR'.
  6. UploadedFile::move() après que la requête termine — le fichier temp est supprimé. Déplace immédiatement, ne stocke pas le path pour usage différé.
  7. max_file_uploads PHP — 20 par défaut. Upload multiple > 20 silently dropped. Tester $request->files->all() count.
  8. Headers sent twice — appeler header() PHP natif + retourner Response : Symfony envoie ses headers par-dessus. Reste 100% Symfony.

🔐 Trusted proxies & sécurité réseau — la partie que tout le monde rate

En prod, ton app est toujours derrière au moins un reverse proxy (ALB, Cloudflare, Nginx, Traefik). Le client réel ne parle pas à PHP ; il parle au proxy, qui ré-émet la requête en ajoutant X-Forwarded-For/Proto/Host/Port. Sans configuration, Symfony ignore ces headers (c'est volontaire : les faire confiance par défaut serait une faille d'usurpation d'IP). Conséquences :

  • getClientIp() retourne l'IP du load balancer → rate-limiter cassé, géoloc fausse, audit logs inutiles.
  • isSecure() retourne false (le proxy a terminé le TLS) → Symfony génère des URLs http://, force des redirects, et tes cookies secure peuvent partir en boucle de redirection.
yaml
# config/packages/framework.yaml
framework:
    # IPs/CIDR de tes proxies. 'REMOTE_ADDR' = "fais confiance au proxy juste devant moi"
    # (pratique quand l'IP du LB est dynamique, ex. ECS/Fargate).
    trusted_proxies: '%env(TRUSTED_PROXIES)%'   # ex: '10.0.0.0/8,172.16.0.0/12'
    trusted_headers:
        - 'x-forwarded-for'
        - 'x-forwarded-host'
        - 'x-forwarded-proto'
        - 'x-forwarded-port'
        # PAS 'x-forwarded-prefix' sauf si tu sers sous un sous-chemin.

Mental model de la confiance : X-Forwarded-For est une liste (client, proxy1, proxy2). Symfony remonte la chaîne depuis REMOTE_ADDR et s'arrête au premier hop non déclaré dans trusted_proxies. Le résultat est l'IP réelle du client. Si tu déclares trop large (0.0.0.0/0), n'importe qui peut forger X-Forwarded-For: 1.2.3.4 et usurper une IP → contournement de rate-limit et de blocklist. Déclare exactement le CIDR de ton infra, rien de plus.

Cas Cloudflare / multi-CDN : tu dois soit déclarer toutes les plages CF comme trusted, soit (plus robuste) faire réécrire X-Forwarded-For par ton ingress avec uniquement CF-Connecting-IP. Ne mélange pas les deux schémas sans réflexion.

php
// Vérifier en CLI que la conf est correcte (debug rapide)
$r = Request::create('https://app/x', server: [
    'REMOTE_ADDR' => '10.0.0.5',
    'HTTP_X_FORWARDED_FOR' => '203.0.113.9, 10.0.0.5',
    'HTTP_X_FORWARDED_PROTO' => 'https',
]);
Request::setTrustedProxies(['10.0.0.0/8'], Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_PROTO);
$r->getClientIp();   // '203.0.113.9'  ✅
$r->isSecure();      // true           ✅

Autres réflexes sécurité sur ce composant :

  • __Host- / __Secure- cookie prefixes : __Host-session impose Secure, Path=/, et interdit Domain → immunise contre la fixation de cookie par un sous-domaine compromis. À partir de Symfony 7.1, Cookie::create() valide ces préfixes nativement.
  • SameSite : Lax par défaut suffit pour la session ; Strict pour les cookies ultra-sensibles ; None uniquement avec Secure (sinon le browser le rejette).
  • Taille de body : HttpFoundation ne limite rien — c'est post_max_size/upload_max_filesize (PHP) et client_max_body_size (Nginx) qui te protègent du DoS par upload. Un body > post_max_size arrive avec $_POST vide et sans erreur : valide explicitement Content-Length sur les endpoints d'upload.
  • Mime sniffing : UploadedFile::getMimeType() lit le contenu réel (magic bytes via finfo), pas l'extension ni le Content-Type client (falsifiable). Valide toujours sur getMimeType(), jamais sur getClientMimeType().

📈 Observabilité & performance — comment un staff engineer instrumente la couche HTTP

MétriqueOù la prendrePourquoi
Time-to-first-byte (TTFB)listener kernel.response vs kernel.requestun StreamedResponse doit avoir un TTFB bas même si le download dure 30 s
Taille de réponseContent-Length (ou compteur dans le callback streamé)détecter les payloads JSON qui enflent
Cache hit/miss CDNheader Age / X-Cache en avalvalider que tes ETag/s-maxage font effet
Taux de 304code statut dans les access logsun ratio bas = revalidation conditionnelle cassée
RSS mémoire picmemory_get_peak_usage() en fin de requêterepérer un export qui charge tout en RAM au lieu de streamer

Points de perf non-évidents :

  • Un StreamedResponse ne porte pas de Content-Length (le serveur passe en Transfer-Encoding: chunked). C'est voulu, mais le client n'a pas de barre de progression. Pour un BinaryFileResponse, la taille est connue → barre de progression + support Range. Choisis en fonction de l'UX voulue.
  • Buffering proxy : Nginx bufferise par défaut la réponse PHP → annule l'intérêt du streaming (le TTFB redevient le temps total). Header X-Accel-Buffering: no côté réponse, ou proxy_buffering off; côté Nginx. Sans ça, ton « streaming » streame pour le néant.
  • JsonResponse ré-encode à chaque setData() : si tu builds une grosse réponse, set les données une fois. fromJsonString() saute entièrement json_encode quand tu as déjà du JSON (cache Redis qui stocke des strings JSON).
  • flush() dans un callback streamé : sans flush() explicite (ou fflush() sur le handle), PHP bufferise jusqu'à output_buffering octets. Flush par chunks de ~8–64 KB : trop souvent = syscalls coûteux, jamais = pas de streaming.
  • kernel.terminate pour le travail post-réponse (audit log, métriques, webhook) : la réponse est déjà partie au client, le worker fait son ménage sans pénaliser le TTFB. C'est le bon endroit pour logguer un download d'acte notarié (cf. Scénario 2).

🧪 Testing

php
// tests/Http/CsvExportTest.php
final class CsvExportTest extends WebTestCase
{
    public function testStreamedCsvIsCorrect(): void
    {
        $client = static::createClient();
        $client->request('GET', '/api/users/export');

        self::assertResponseIsSuccessful();
        self::assertResponseHeaderSame('Content-Type', 'text/csv; charset=UTF-8');

        // StreamedResponse content captured via output buffering
        $content = $client->getResponse()->getContent();
        self::assertStringStartsWith('id,email,createdAt', $content);
    }

    public function testNotModifiedReturns304(): void
    {
        $client = static::createClient();
        // First request to get ETag
        $client->request('GET', '/articles/1');
        $etag = $client->getResponse()->headers->get('ETag');

        // Second request with If-None-Match
        $client->request('GET', '/articles/1', server: ['HTTP_IF_NONE_MATCH' => $etag]);
        self::assertResponseStatusCodeSame(304);
    }

    public function testFileUpload(): void
    {
        $client = static::createClient();
        $client->loginUser(self::createUser());

        $file = new UploadedFile(
            __DIR__.'/Fixtures/avatar.png',
            'avatar.png',
            'image/png',
            null,
            test: true,
        );
        $client->request('POST', '/upload', files: ['avatar' => $file]);

        self::assertResponseStatusCodeSame(201);
    }
}

Tester un service qui utilise RequestStack :

php
$stack = new RequestStack();
$stack->push(Request::create('/foo', server: ['HTTP_ACCEPT_LANGUAGE' => 'fr-FR']));

$service = new CurrentLocaleService($stack);
self::assertSame('fr', $service->getLocale());

🎬 Cas d'usage concrets

Scénario 1 — Cabinet comptable (Pennylane-like) : export grand livre CSV en StreamedResponse

Contexte : un expert-comptable exporte le grand livre annuel d'un client (~280 000 lignes d'écritures comptables). En mode classique Response, le serveur charge 180 MB en RAM, le client attend 25 s un blank screen, et le worker PHP s'écroule sur le 3e export concurrent.

L'équipe refactore vers StreamedResponse : un générateur PHP itère sur les EcritureComptable page par page (Doctrine pagination avec iterate(), 500 lignes par chunk), encode chaque batch en CSV avec SplFileObject sur php://output, et flush() après chaque chunk. Headers : Content-Type: text/csv; charset=utf-8, Content-Disposition: attachment; filename=..., X-Accel-Buffering: no pour Nginx ne pas bufferiser.

Mesures : RAM passée de 180 MB à 18 MB constant, temps de première byte de 25 s à 600 ms, le browser télécharge le fichier progressivement. Les concurrent exports ne saturent plus les workers.

Scénario 2 — Cabinet juridique (Documate-like) : téléchargement sécurisé d'actes notariés en BinaryFileResponse

Contexte : un cabinet notarial génère des actes PDF stockés sur S3 chiffré. Le téléchargement doit : (1) vérifier que le client connecté a bien accès à cet acte (cas pratique : ACL), (2) journaliser l'accès pour audit règlementaire, (3) servir le PDF avec un nom de fichier humain et Content-Disposition: attachment, (4) supporter le resume (Range requests) pour les clients mobiles qui peuvent reprendre un download interrompu.

Le controller utilise BinaryFileResponse qui sait gérer Range, le mime-type sniffing, et X-Sendfile (ou X-Accel-Redirect pour Nginx). Avec X-Accel-Redirect, le PHP retourne juste un header X-Accel-Redirect: /internal/s3-proxy/{key} et Nginx prend le relais — le PHP se libère immédiatement, le file streaming est géré par Nginx ultra-efficacement.

Bénéfice : un download de 80 MB ne bloque pas le worker PHP, et les audits ACPR sont satisfaits via un listener sur kernel.terminate qui logue après envoi.

Scénario 3 — API e-commerce (Backmarket-like) : JsonResponse + ETag pour caching aggressif côté CDN

Contexte : marketplace tech refurbished, endpoint /api/products/{sku} consulté ~20 000 fois/seconde. Sans cache, c'est ingérable. Le contenu change ~1 fois/jour (stock, prix). Idéal pour cache CDN avec ETag.

Le controller calcule un ETag basé sur sha256(updatedAt . stock . price) du produit, le compare au If-None-Match request header. Si match → 304 Not Modified sans body (1 KB vs 25 KB). Sinon, retourne JsonResponse avec ETag, Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60. Le CDN (Cloudflare) cache 300 s côté edge ; les clients revalident toutes les 60 s.

Mesures : 96% de cache hit ratio côté CDN, charge backend divisée par 25, P99 latency mobile de 380 ms à 95 ms.

🛠️ Exemple end-to-end

Use case : système de bordereaux récap pour une plateforme compta. Un expert peut exporter en CSV (volumineux, streamed), télécharger un PDF déjà généré (binary file), ou consulter une fiche JSON (avec ETag).

php
// src/Accounting/Controller/LedgerExportController.php
<?php
declare(strict_types=1);

namespace App\Accounting\Controller;

use App\Accounting\Repository\EcritureRepository;
use App\Accounting\Security\AccountantVoter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class LedgerExportController extends AbstractController
{
    public function __construct(private readonly EcritureRepository $ecritures) {}

    #[Route('/comptes/{clientId}/grand-livre.csv', name: 'ledger_export_csv', methods: ['GET'])]
    #[IsGranted(AccountantVoter::EXPORT, subject: 'clientId')]
    public function exportCsv(
        string $clientId,
        #[MapQueryParameter] int $year,
    ): StreamedResponse {
        $response = new StreamedResponse(function () use ($clientId, $year) {
            $out = fopen('php://output', 'w');
            fputcsv($out, ['Date', 'Compte', 'Libellé', 'Débit', 'Crédit']);
            foreach ($this->ecritures->streamByClientAndYear($clientId, $year, batchSize: 500) as $ec) {
                fputcsv($out, [
                    $ec->date->format('Y-m-d'),
                    $ec->compte,
                    $ec->libelle,
                    $ec->debit?->getAmount() ?? '',
                    $ec->credit?->getAmount() ?? '',
                ]);
                if (\ftell($out) > 65536) {
                    \fflush($out);
                }
            }
            fclose($out);
        });

        $disposition = HeaderUtils::makeDisposition(
            ResponseHeaderBag::DISPOSITION_ATTACHMENT,
            "grand-livre-{$clientId}-{$year}.csv",
        );
        $response->headers->set('Content-Type', 'text/csv; charset=utf-8');
        $response->headers->set('Content-Disposition', $disposition);
        $response->headers->set('X-Accel-Buffering', 'no');

        return $response;
    }

    #[Route('/comptes/{clientId}/bilan/{year}.pdf', name: 'ledger_download_pdf', methods: ['GET'])]
    #[IsGranted(AccountantVoter::DOWNLOAD, subject: 'clientId')]
    public function downloadPdf(string $clientId, int $year): BinaryFileResponse
    {
        $path = $this->getParameter('app.documents_dir') . "/bilans/{$clientId}/{$year}.pdf";
        if (!is_file($path)) {
            throw $this->createNotFoundException();
        }

        $response = new BinaryFileResponse($path);
        $response->setContentDisposition(
            ResponseHeaderBag::DISPOSITION_ATTACHMENT,
            "bilan-{$clientId}-{$year}.pdf",
        );
        $response->headers->set('Content-Type', 'application/pdf');
        $response->setAutoEtag();

        return $response;
    }

    #[Route('/comptes/{clientId}/synthese.json', name: 'ledger_synthese_json', methods: ['GET'])]
    #[IsGranted(AccountantVoter::READ, subject: 'clientId')]
    public function syntheseJson(string $clientId, Request $request): JsonResponse
    {
        $snapshot = $this->ecritures->buildSyntheseSnapshot($clientId);
        $etag = '"' . substr(hash('sha256', serialize($snapshot)), 0, 16) . '"';

        $response = new JsonResponse($snapshot);
        $response->setEtag($etag);
        $response->setPublic();
        $response->setMaxAge(60);
        $response->setSharedMaxAge(300);
        $response->headers->addCacheControlDirective('stale-while-revalidate', 60);

        if ($response->isNotModified($request)) {
            return $response; // 304 sans body
        }

        return $response;
    }
}

Trois patterns en un controller :

  • StreamedResponse pour export volumineux (CSV potentiellement 200 MB), RAM constante.
  • BinaryFileResponse pour PDF pré-généré, supporte Range, ETag auto, mime-type sniffé.
  • JsonResponse + ETag pour fiche JSON cacheable côté CDN.

L'expert comptable peut télécharger un grand-livre annuel sans saturer le serveur, l'API mobile lit la synthèse en 304 Not Modified la plupart du temps.


🔁 Quand utiliser / éviter

  • JsonResponse : 99% des APIs. Évite new Response(json_encode(...)) — moins clair, pas d'auto Content-Type.
  • StreamedResponse : export CSV/XML/grosse archive > 50MB ou inconnu. Évite pour réponses < 1MB (overhead pour rien).
  • StreamedJsonResponse (6.2+) : API qui retourne un gros tableau d'objets. Évite si tu as < 100 items ou si tu dois inclure des metadata (pagination) — utilise plutôt JsonResponse classique.
  • BinaryFileResponse : downloads de fichiers générés (PDF, ZIP). Évite pour fichiers statiques < 1MB (sert via web server directement, plus rapide).
  • Cache headers (ETag/Last-Modified) : tout GET idempotent. Évite sur les endpoints "live" (notifications, prices).

🏋️ Exercices

Progression : implémenter → durcir pour la prod → casser puis réparer. Chaque exercice se teste en isolation avec Request::create() ou WebTestCase.

1. (échauffement) Négociation de format

Objectif : un endpoint /report/{id} qui sert du JSON, du CSV ou du PDF selon Accept ou l'extension d'URL, avec un fallback propre en 406 si rien ne matche.

Indice/Solution : $request->getPreferredFormat('json') + $request->getAcceptableContentTypes(). Mappe format→Response (JsonResponse / StreamedResponse / BinaryFileResponse). Si le format demandé n'est pas supporté, throw new NotAcceptableHttpException(). Teste avec server: ['HTTP_ACCEPT' => 'text/csv'].

2. Upload robuste avec validation défensive

Objectif : un endpoint d'upload d'avatar qui rejette correctement : aucun fichier, fichier > 2 MB, mime ≠ image, upload partiel (réseau coupé), et plus de max_file_uploads fichiers.

Indice/Solution : vérifie $file->getError() (UPLOAD_ERR_*) avant tout le reste — UPLOAD_ERR_INI_SIZE/UPLOAD_ERR_PARTIAL arrivent sans exception. Valide sur getMimeType() (magic bytes), pas getClientMimeType(). Nomme via bin2hex(random_bytes(16)) + guessExtension(), jamais le nom client (path traversal). Déplace immédiatement (move()), le temp meurt à la fin de la requête.

3. (production-grade) Export streamé avec backpressure & RAM constante

Objectif : exporter 500 000 lignes en CSV avec une RAM plafonnée à 20 MB et un TTFB < 800 ms, vérifiable par un test qui asserte memory_get_peak_usage().

Indice/Solution : StreamedResponse + Doctrine toIterable() (jamais findAll()). $em->clear() tous les N rows pour vider l'UnitOfWork (sinon fuite mémoire malgré le stream). fflush() par chunks de ~32 KB. Header X-Accel-Buffering: no. Piège : un QueryBuilder avec fetchJoin casse toIterable() — hydrate sans collection.

4. (production-grade) Cache conditionnel HTTP correct de bout en bout

Objectif : /products/{sku} qui répond 304 quand rien n'a changé, gère If-None-Match ET If-Modified-Since, et expose Cache-Control: public, s-maxage=300, stale-while-revalidate=60 pour le CDN.

Indice/Solution : setEtag() dérivé d'un hash stable (updatedAt.stock.price), setLastModified(), puis isNotModified($request) qui retourne true si l'un des deux matche et vide le body. Attention : un ETag weak (W/"...") ne matche pas un If-None-Match strict — sois cohérent. Teste le second appel avec HTTP_IF_NONE_MATCH.

5. (break-then-fix) Le bug du client IP en prod

Objectif : on te donne un rate-limiter clé-par-IP qui « marche en local mais bloque tout le monde en prod, ou personne ». Reproduis et corrige.

Indice/Solution : en prod derrière un ALB, getClientIp() renvoie l'IP du LB → soit tout le monde partage le même bucket (tous bloqués), soit chacun forge X-Forwarded-For (personne bloqué). Fix : configurer trusted_proxies au CIDR exact du LB + trusted_headers. Reproduis en test avec Request::setTrustedProxies() et deux HTTP_X_FORWARDED_FOR différents. Bonus : montre qu'un CIDR trop large (0.0.0.0/0) réintroduit l'usurpation.

6. (break-then-fix) Le « streaming » qui ne streame pas

Objectif : un StreamedResponse qui, en prod derrière Nginx, met 25 s à afficher quoi que ce soit alors qu'en local c'est instantané. Diagnostique et corrige.

Indice/Solution : deux coupables empilés — (1) Nginx proxy_buffering on (défaut) accumule toute la réponse → ajoute X-Accel-Buffering: no ou proxy_buffering off; (2) PHP output_buffering / un ob_start() traînant dans un listener → la sortie n'atteint jamais le socket sans flush(). Vérifie aussi qu'aucun middleware (Profiler en dev, compression gzip) ne re-bufferise. Mesure le TTFB avec curl -w '%{time_starttransfer}'.

🎤 En entretien

  • « Pourquoi injecter RequestStack plutôt que Request dans un service ? » — Parce qu'un service est instancié une fois par le container alors que le Request change : sub-requests, fragments, worker Messenger, CLI où il n'existe pas. RequestStack::getCurrentRequest() reflète la pile courante (et peut être null) ; injecter le Request figerait une référence périmée. En worker mode (RoadRunner/FrankenPHP) c'est une source de bugs majeure.

  • « $request->request->get() retourne vide sur mon POST JSON, pourquoi ? »request mappe $_POST, qui n'est peuplé que pour application/x-www-form-urlencoded et multipart/form-data. Un body JSON arrive dans php://input. En 7.x on lit $request->getPayload() (InputBag typé, unifie JSON+form) ; le json_decode($request->getContent()) à la main est l'ancien réflexe.

  • « Différence entre StreamedResponse, StreamedJsonResponse et BinaryFileResponse ? »StreamedResponse = callback générique, sortie chunked, pas de Content-Length, RAM constante (CSV/exports). StreamedJsonResponse (6.2+) = streame un iterable en JSON valide sans tout charger (gros listings API). BinaryFileResponse = fichier sur disque, taille connue donc support Range/resume + ETag auto + délégation X-Sendfile/X-Accel-Redirect au web server (le worker PHP se libère). On choisit selon : source (mémoire vs fichier), besoin de progression/resume, et offload au front Nginx.

  • « Comment garantir un client IP fiable derrière un CDN ? » — Configurer trusted_proxies au CIDR exact de l'infra (jamais 0.0.0.0/0, sinon usurpation via X-Forwarded-For forgé) et trusted_headers. Symfony remonte la chaîne X-Forwarded-For en s'arrêtant au premier hop non-trusted. Avec Cloudflare, soit on trust toutes les plages CF, soit l'ingress réécrit X-Forwarded-For à partir de CF-Connecting-IP. Sans ça : géoloc, rate-limit et audit logs sont tous faux.

🔗 Liens

Bibliothèque tech perso — Achref