Requête classique ou flux continu ?
HTTP peut répondre en un seul bloc — ou envoyer les données au fil de l'eau. Découvre quand et pourquoi streamer.
Dans le web classique, une requête HTTP suit un schéma simple : tu envoies une demande, le serveur traite tout, puis répond d'un seul bloc. C'est parfait pour une page HTML ou un petit JSON. Mais que se passe-t-il quand la réponse prend du temps à se construire, ou qu'elle est volumineuse ?
Le streaming HTTP casse ce modèle. Au lieu d'attendre que tout soit prêt, le serveur envoie les données au fur et à mesure, par morceaux. Le client reçoit et affiche pendant que le serveur produit encore.
Technique qui consiste à envoyer une réponse HTTP en plusieurs fragments plutôt qu'en un seul bloc. Le client commence à traiter les premières données avant que les dernières soient produites côté serveur.
envoie GET
produit en continu
reçoit au fil de l'eau
Trois mécanismes couvrent la quasi-totalité des besoins :
| Mécanisme | Direction | Usage typique |
|---|---|---|
Transfer-Encoding: chunked |
Serveur → Client | Réponse longue à calculer, export progressif |
| SSE Server-Sent Events | Serveur → Client (push continu) | Notifications, logs en direct, tokens d'un LLM |
readfile() / BinaryFileResponse |
Serveur → Client | Téléchargement de fichiers volumineux (PDF, archives) |
Chunked Transfer Encoding
Le mécanisme HTTP/1.1 qui permet d'envoyer une réponse sans connaître sa taille à l'avance.
Imagine que tu veux livrer une pizza, mais la boulangerie la prépare tranche par tranche. Au lieu d'attendre que tout soit prêt, tu livres chaque tranche dès qu'elle sort du four. Le Chunked Transfer Encoding fonctionne exactement ainsi.
Extension HTTP/1.1 (RFC 7230) qui découpe le corps de la réponse en fragments de taille variable. Chaque fragment (chunk) est précédé de sa taille en hexadécimal. Un chunk de taille
0 signale la fin du flux. Remplace Content-Length quand la taille totale est inconnue.
Structure d'une réponse chunked sur le fil :
HTTP/1.1 200 OK Transfer-Encoding: chunked Content-Type: text/plain 1a ← taille du chunk en hex (= 26 octets) abcdefghijklmnopqrstuvwxyz 0d ← 13 octets Hello, World! 0 ← fin du flux
Transfer-Encoding: chunked et Content-Length ne peuvent pas coexister dans la même réponse.
En PHP, activer le streaming est une question de buffers. Par défaut, PHP accumule la sortie avant de l'envoyer — il faut désactiver ce comportement :
// Désactiver la mise en tampon PHP ob_implicit_flush(true); if (ob_get_level() > 0) { ob_end_flush(); } header('Content-Type: text/plain; charset=utf-8'); header('X-Accel-Buffering: no'); // désactive le buffer Nginx/FrankenPHP for ($i = 1; $i <= 10; $i++) { echo "Étape $i traitée\n"; flush(); // envoie immédiatement au client sleep(1); // simule un traitement long }
Avec Symfony, utilise StreamedResponse pour garder l'architecture propre :
use Symfony\Component\HttpFoundation\StreamedResponse; return new StreamedResponse(function() { for ($i = 1; $i <= 10; $i++) { echo "data: {\"step\":$i}\n\n"; ob_flush(); flush(); sleep(1); } }, 200, [ 'Content-Type' => 'text/plain', 'Cache-Control' => 'no-cache', 'X-Accel-Buffering' => 'no', ]);
Le client attend que les 10 étapes soient terminées. Dix secondes de page blanche, puis tout apparaît.
Latence perçue = durée totaleLe client voit chaque étape apparaître au fil de l'eau. L'interface reste vivante.
Premier octet immédiatServer-Sent Events — push du serveur vers le client
Un protocole dédié aux notifications en temps réel : notifications, logs live, tokens de LLM.
Le Chunked Encoding convient aux réponses longues à construire. Mais pour pousser des événements en continu — mises à jour, notifications, tokens d'un LLM — il existe un protocole dédié et standardisé : les Server-Sent Events.
Protocole HTTP uni-directionnel (serveur → client) défini par le W3C. Le serveur ouvre une connexion HTTP persistante avec le
Content-Type: text/event-stream et envoie des événements textuels. Le navigateur les reçoit via l'API EventSource. La reconnexion automatique est intégrée.
Format d'un message SSE — texte brut, champs séparés par \n, message terminé par \n\n :
# Message simple data: {"type":"metric","value":42} # Message nommé avec ID (pour la reprise) event: notification data: Nouvelle commande #1024 id: 1024
Côté serveur PHP :
header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); $id = 0; while (true) { $payload = json_encode(['id' => ++$id, 'v' => rand(0, 100)]); echo "id: $id\ndata: $payload\n\n"; flush(); sleep(1); if (connection_aborted()) break; // client déconnecté }
Côté client JavaScript :
const source = new EventSource('/api/events'); source.onmessage = (e) => { const data = JSON.parse(e.data); console.log(`Reçu id=${data.id} v=${data.v}`); }; // Écouter un event nommé source.addEventListener('notification', (e) => { alert(e.data); }); source.onerror = () => { // EventSource se reconnecte automatiquement console.warn('SSE: reconnexion...'); };
EventSource se reconnecte si la connexion est perdue. Le navigateur envoie l'en-tête Last-Event-ID avec le dernier ID reçu — utilise-le côté serveur pour reprendre sans perte.
Clique sur Démarrer pour simuler un flux SSE — les événements arrivent au fil de l'eau, comme un vrai stream.
Streaming de fichiers et PDF
Télécharger un fichier volumineux sans saturer la RAM — readfile(), BinaryFileResponse et StreamedResponse.
Tu as une facture de 50 Mo à envoyer. L'erreur classique : charger l'intégralité du fichier en mémoire PHP, puis l'envoyer. Sur un serveur avec 128 Mo de RAM et dix téléchargements simultanés, c'est le plantage assuré.
Lire et envoyer un fichier par petits blocs sans jamais l'avoir entièrement en mémoire. PHP maintient une empreinte mémoire constante (quelques Ko) quelle que soit la taille du fichier.
❌ Anti-pattern
// Charge TOUT le fichier en RAM $content = file_get_contents($path); echo $content;
50 Mo de RAM par requête. Dix requêtes = 500 Mo. Mort.
✓ Streaming
// Lit et envoie par blocs de 8 Ko readfile($path);
Mémoire constante ≈ 8 Ko quelle que soit la taille.
En PHP natif, pour forcer le téléchargement d'un PDF avec les bons en-têtes :
$path = '/var/storage/invoices/2024-001.pdf'; if (!is_file($path)) { http_response_code(404); exit; } header('Content-Type: application/pdf'); header('Content-Disposition: attachment; filename="facture-2024-001.pdf"'); header('Content-Length: ' . filesize($path)); header('Cache-Control: private, no-store'); readfile($path);
Avec Symfony, utilise les classes dédiées — elles gèrent automatiquement les en-têtes, les requêtes partielles (Range) et le streaming :
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpFoundation\StreamedResponse; // Fichier sur disque — la solution la plus simple return new BinaryFileResponse( $path, 200, [], true, // X-Sendfile si dispo ResponseHeaderBag::DISPOSITION_ATTACHMENT, ); // Contenu généré à la volée (ex: export CSV, PDF généré par DomPDF) return new StreamedResponse(function () use ($generator) { foreach ($generator->rows() as $row) { fputcsv(STDOUT, $row); ob_flush(); flush(); } }, 200, [ 'Content-Type' => 'text/csv; charset=utf-8', 'Content-Disposition' => 'attachment; filename="export.csv"', ]);
Content-Disposition: inline ouvre le PDF dans le navigateur. attachment force le téléchargement. Les deux se gèrent via ResponseHeaderBag::DISPOSITION_INLINE et DISPOSITION_ATTACHMENT.
$path venant d'un paramètre HTTP. Valide que le fichier est bien dans un répertoire autorisé avec realpath() avant de le servir.
Synthèse — choisir la bonne approche
Chunked, SSE, BinaryFileResponse, StreamedResponse : guide de décision pour chaque cas d'usage.
Trois mécanismes, trois cas d'usage. Le tableau ci-dessous est ton guide de décision.
| Besoin | Solution | Pourquoi |
|---|---|---|
| Réponse longue à calculer (export, rapport) | StreamedResponse + chunked |
Le client voit la progression, pas de timeout |
| Notifications en temps réel, logs live, tokens LLM | Server-Sent Events (text/event-stream) |
Protocole dédié, reconnexion auto, API simple |
| Téléchargement de fichier existant sur disque | BinaryFileResponse |
Gère Range, X-Sendfile, disposition automatiquement |
| Génération de fichier à la volée (CSV, PDF) | StreamedResponse |
Mémoire constante, envoi progressif |
| Communication bi-directionnelle temps réel | WebSocket (hors scope HTTP) | SSE ne va que dans un sens — pour les deux sens, WebSocket |
Streame dès que la réponse dépasse quelques Mo, ou dès que la construction dépasse quelques secondes. L'utilisateur perçoit une interface vivante plutôt qu'une page blanche.
Rappel des points clés à retenir :
- Chunked : désactive le buffer PHP (
ob_implicit_flush(true)), appelleflush()après chaque écho, ajouteX-Accel-Buffering: nopour bypasser le proxy. - SSE :
Content-Type: text/event-stream, formatdata: ...\n\n,connection_aborted()pour détecter la déconnexion côté serveur. - Fichiers :
BinaryFileResponsepour les fichiers disque,StreamedResponsepour la génération à la volée. Jamaisfile_get_contents()pour de gros fichiers. - Sécurité fichiers : valide toujours le chemin avec
realpath()avant de le servir.