Request & Response — Headers, Files, Cache, Streamed
TL;DR —
RequestetResponse(composantHttpFoundation) sont des OO wrappers sur les superglobales PHP, mais immutables-ish et thread-safe. Les sous-classes (JsonResponse,StreamedResponse,BinaryFileResponse) couvrent 95% des cas.RequestStackest 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 :
Requestn'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 ».Responseest mutable, mais le HTTP qu'elle décrit ne l'est pas une foissend()appelé. Le pattern « immutable-ish » des docs signifie : traite laResponsecomme un builder que tu termines avant lereturn. Après que leKernela appelésend(), toute mutation est un no-op silencieux (headers déjà flush vers le client). Voir Pitfall #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 unRequestsur leRequestStack.getCurrentRequest()peut donc changer dans la même milliseconde, et retournernullhors contexte HTTP. C'est pourquoi on injecteRequestStacket jamais leRequestdirectement 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 :
| Bag | Source | Type | Accès typique | Piège |
|---|---|---|---|---|
query | $_GET | InputBag | ?page=2 | scalaire only depuis 5.1 (get() throw sur array) |
request | $_POST (form-urlencoded / multipart) | InputBag | form HTML | vide si body JSON |
attributes | runtime (router, listeners) | ParameterBag | _route, _controller, params d'URL | non sérialisé, interne |
cookies | $_COOKIE | InputBag | session id, prefs | jamais de secret en clair |
files | $_FILES | FileBag | UploadedFile | borné par max_file_uploads (20) |
server | $_SERVER | ServerBag | REMOTE_ADDR, HTTPS | passer par les getters, pas le bag |
headers | dérivé de server | HeaderBag | Authorization, Accept | casse-insensible |
getPayload() | php://input parsé | InputBag | API 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
// JSON Response
use Symfony\Component\HttpFoundation\JsonResponse;
return new JsonResponse([
'id' => 42,
'name' => 'foo',
], JsonResponse::HTTP_OK, headers: ['X-Custom' => 'v1']);// 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;// 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;// 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);
}// 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';
}
}// 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;
}// 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
JsonResponse::fromJsonString()— si tu as déjà du JSON pré-encodé (cache), évite la double-encode. Sinonnew JsonResponse($array)qui appellejson_encodeinterne.- StreamedResponse pour export gros volume — au lieu de
fetchAll() → array → encode → send(RAM 1GB), tu stream ligne par ligne (RAM ~10MB). Combine avec Doctrineiterate()outoIterable(). 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.Request::isXmlHttpRequest()— vérifie si la requête est AJAX (headerX-Requested-With: XMLHttpRequest). Utile pour servir HTML partiel vs page complète.Request::getContent()vsrequest->get()—getContent()pour le raw body (utile JSON),request->get('foo')pour form fields.request->all()retourne$_POSTcomplet.Response::sendHeaders()+sendContent()— appelés automatiquement parResponse::send(). Tu peux les appeler séparément pour du streaming custom.
🔄 Versions — Symfony 5.4 / 6.4 / 7.x
- 5.4 :
Request/Responsestables depuis Symfony 2.0.getContent()retournestring|resource. - 6.0 :
Request::HEADER_X_FORWARDED_ALLrenommé/déprécié → utiliserHEADER_X_FORWARDED_FOR | HEADER_X_FORWARDED_HOST | HEADER_X_FORWARDED_PORT | HEADER_X_FORWARDED_PROTO. - 6.1 :
Request::getPreferredFormat(), améliorationgetMimeType(). - 6.2 : 🌟
StreamedJsonResponse— stream un iterable directement en JSON sans tout charger en RAM. Game changer pour gros listings API. - 6.3 : amélioration
BinaryFileResponseRange support,Response::setCallback()pour StreamedResponse plus expressif. - 6.4 LTS :
Request::getPayload()(depuis 6.3) retourne unInputBagtypé pourphp://inputparsé (JSON / form-data unifié). Préférable à$request->request->all()pour les API. - 7.0 : suppression
HEADER_X_FORWARDED_ALL. SuppressionRequest::getSession()qui throw si pas de session (au lieu de retourner null). UseRequest::hasSession()avant. - 7.1+ :
Response::setCookie()accepte__Host-prefix natif, validation auto.JsonResponsecache des flagsJSON_*par défaut (perf).
⚠️ Pitfalls
$_GET/$_POSTdirect dans un service — bypass deRequestStack, casse en CLI, en worker mode, en sub-request. Toujours injecterRequestStack.Request::request->get('foo')avec JSON body —request(ParameterBag) ne contient PAS le body JSON parsé. Utiliserjson_decode($request->getContent(), true)ou$request->getPayload()->all()(6.3+).Response::setContent()aprèssend()— modifier la response après envoi = silently ignored. Toujours setter avant return.- Cookie path/domain manquant —
setcookie('foo', 'bar')natif a des defaults différents deCookie::create(). Toujours préciserpath,domain,secure,httpOnly,samesite. getClientIp()derrière proxy — sans configurerframework.trusted_proxies, retourne l'IP du LB, pas du client réel. Configurer en prod :framework.trusted_proxies: '127.0.0.1,REMOTE_ADDR'.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é.max_file_uploadsPHP — 20 par défaut. Upload multiple > 20 silently dropped. Tester$request->files->all()count.- 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()retournefalse(le proxy a terminé le TLS) → Symfony génère des URLshttp://, force des redirects, et tes cookiessecurepeuvent partir en boucle de redirection.
# 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.
// 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-sessionimposeSecure,Path=/, et interditDomain→ immunise contre la fixation de cookie par un sous-domaine compromis. À partir de Symfony 7.1,Cookie::create()valide ces préfixes nativement.SameSite:Laxpar défaut suffit pour la session ;Strictpour les cookies ultra-sensibles ;Noneuniquement avecSecure(sinon le browser le rejette).- Taille de body :
HttpFoundationne limite rien — c'estpost_max_size/upload_max_filesize(PHP) etclient_max_body_size(Nginx) qui te protègent du DoS par upload. Un body >post_max_sizearrive avec$_POSTvide et sans erreur : valide explicitementContent-Lengthsur les endpoints d'upload. - Mime sniffing :
UploadedFile::getMimeType()lit le contenu réel (magic bytes viafinfo), pas l'extension ni leContent-Typeclient (falsifiable). Valide toujours surgetMimeType(), jamais surgetClientMimeType().
📈 Observabilité & performance — comment un staff engineer instrumente la couche HTTP
| Métrique | Où la prendre | Pourquoi |
|---|---|---|
| Time-to-first-byte (TTFB) | listener kernel.response vs kernel.request | un StreamedResponse doit avoir un TTFB bas même si le download dure 30 s |
| Taille de réponse | Content-Length (ou compteur dans le callback streamé) | détecter les payloads JSON qui enflent |
| Cache hit/miss CDN | header Age / X-Cache en aval | valider que tes ETag/s-maxage font effet |
| Taux de 304 | code statut dans les access logs | un ratio bas = revalidation conditionnelle cassée |
| RSS mémoire pic | memory_get_peak_usage() en fin de requête | repérer un export qui charge tout en RAM au lieu de streamer |
Points de perf non-évidents :
- Un
StreamedResponsene porte pas deContent-Length(le serveur passe enTransfer-Encoding: chunked). C'est voulu, mais le client n'a pas de barre de progression. Pour unBinaryFileResponse, la taille est connue → barre de progression + supportRange. 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: nocôté réponse, ouproxy_buffering off;côté Nginx. Sans ça, ton « streaming » streame pour le néant. JsonResponseré-encode à chaquesetData(): si tu builds une grosse réponse, set les données une fois.fromJsonString()saute entièrementjson_encodequand tu as déjà du JSON (cache Redis qui stocke des strings JSON).flush()dans un callback streamé : sansflush()explicite (oufflush()sur le handle), PHP bufferise jusqu'àoutput_bufferingoctets. Flush par chunks de ~8–64 KB : trop souvent = syscalls coûteux, jamais = pas de streaming.kernel.terminatepour 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
// 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 :
$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).
// 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 :
StreamedResponsepour export volumineux (CSV potentiellement 200 MB), RAM constante.BinaryFileResponsepour PDF pré-généré, supporteRange, 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. Évitenew 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
RequestStackplutôt queRequestdans un service ? » — Parce qu'un service est instancié une fois par le container alors que leRequestchange : sub-requests, fragments, worker Messenger, CLI où il n'existe pas.RequestStack::getCurrentRequest()reflète la pile courante (et peut êtrenull) ; injecter leRequestfigerait 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 ? » —requestmappe$_POST, qui n'est peuplé que pourapplication/x-www-form-urlencodedetmultipart/form-data. Un body JSON arrive dansphp://input. En 7.x on lit$request->getPayload()(InputBag typé, unifie JSON+form) ; lejson_decode($request->getContent())à la main est l'ancien réflexe.« Différence entre
StreamedResponse,StreamedJsonResponseetBinaryFileResponse? » —StreamedResponse= callback générique, sortie chunked, pas deContent-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 supportRange/resume + ETag auto + délégationX-Sendfile/X-Accel-Redirectau 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_proxiesau CIDR exact de l'infra (jamais0.0.0.0/0, sinon usurpation viaX-Forwarded-Forforgé) ettrusted_headers. Symfony remonte la chaîneX-Forwarded-Foren s'arrêtant au premier hop non-trusted. Avec Cloudflare, soit on trust toutes les plages CF, soit l'ingress réécritX-Forwarded-Forà partir deCF-Connecting-IP. Sans ça : géoloc, rate-limit et audit logs sont tous faux.