Diffuser un gros PDF généré comme réponse HTTP
Tu génères un gros PDF dans un contrôleur et tu veux renvoyer les octets sans conserver une seconde copie complète dans le buffer de réponse. Chaque intégration de framework fournit une variante diffusée de sa fabrique PdfResponse, streamInline() et streamDownload(). Chacune renvoie une StreamedResponse du framework dont le callback écrit le corps du PDF vers le client en chunks fixes de 64 Ko.
Lis attentivement le modèle mémoire avant de choisir cette approche. Le moteur construit d’abord le document complet en mémoire. Le callback de diffusion appelle getPdfData(), qui matérialise tout le PDF sous forme d’une seule chaîne, puis parcourt cette chaîne par tranches de 64 Ko. Le pic que tu économises correspond à la seconde copie qu’une Illuminate\Http\Response ou Symfony\Component\HttpFoundation\Response bufferisée garderait pendant que le framework mesure Content-Length. La variante diffusée ne mesure pas la longueur ; elle omet donc Content-Length. Elle ne garde jamais le corps de la réponse et la chaîne du document en même temps. Ce n’est pas de la vraie diffusion incrémentale : NextPDF n’expose aucune surface d’écriture incrémentale, donc le document est entièrement réalisé avant que le premier octet n’atteigne le socket.
Voici les prérequis, posés d’emblée pour éviter toute surprise en cours de route :
- Le cœur de NextPDF est installé, et l’intégration de ton framework,
nextpdf/laravelounextpdf/symfony, est installée et découverte. - Tu sais déjà router une requête vers un contrôleur dans ton framework.
- Tu as lu Renvoyer un PDF généré depuis un contrôleur, qui couvre les fabriques bufferisées
inline()etdownload()sur lesquelles s’appuie ce guide pratique.
Ce guide pratique se concentre sur le pattern StreamedResponse partagé par Laravel et Symfony. CodeIgniter 4 fournit les mêmes noms de méthode streamInline() / streamDownload(), mais elles enveloppent les octets dans une CodeIgniter\HTTP\DownloadResponse plutôt que dans une StreamedResponse pilotée par callback. La section Cas limites consigne cette différence.
Installation
Section intitulée « Installation »Installe l’intégration qui correspond à ton framework. Exécute l’une des commandes suivantes.
composer require nextpdf/laravelcomposer require nextpdf/symfonyPour Laravel, publie la configuration après l’installation.
php artisan vendor:publish --tag=nextpdf-configSymfony enregistre automatiquement le bundle via Flex. Vérifie la découverte sur la page d’installation de ton framework avant de continuer.
Vue d’ensemble conceptuelle
Section intitulée « Vue d’ensemble conceptuelle »Une fabrique de réponse bufferisée, PdfResponse::download() ou PdfResponse::inline(), appelle getPdfData(), stocke la chaîne renvoyée sur un objet Response, et définit Content-Length à partir de strlen(). Le framework garde ensuite cette chaîne pendant toute la durée de vie de la réponse. Pour un gros document, cela signifie que la chaîne du document et la chaîne du corps de la réponse coexistent en mémoire.
La fabrique diffusée adopte une forme différente. PdfResponse::streamDownload() et PdfResponse::streamInline() renvoient une StreamedResponse construite avec un callback. Le framework n’invoque ce callback qu’au moment d’envoyer le corps. À l’intérieur du callback, l’intégration appelle getPdfData() une fois, découpe la chaîne renvoyée en chunks de 64 Ko, puis echo chaque chunk suivi d’un flush(). Aucune seconde copie persistante du corps n’est conservée, et aucun en-tête Content-Length n’est émis.
Deux faits guident toutes les décisions de cette page :
- La construction est gourmande, le transfert se fait en chunks.
getPdfData()surNextPDF\Core\Documentappelle l’écrivain et renvoie tout le PDF sous forme d’une seule chaîne. Le découpage en chunks de 64 Ko régit seulement la manière dont les octets déjà construits quittent le processus. Le pic mémoire est borné par la taille d’un document terminé, pas par une petite fenêtre de diffusion. - Pas de
Content-Length. La variante diffusée ne peut pas connaître la longueur du corps sans le construire à l’intérieur du callback, donc elle omet l’en-tête. Une barre de progression côté client, une requêteRangeou un proxy sensible à la longueur ne verront aucune taille. Choisis la variante bufferiséedownload()/inline()quand il est plus important de connaître la longueur que d’économiser la copie de réponse.
Obtiens le document via le mécanisme de résolution idiomatique du framework :
- Laravel : récupère
NextPDF\Contracts\DocumentFactoryInterfacedans le conteneur et appellecreate(). Cela renvoie unNextPDF\Core\Documenttout neuf, le type concret que les fabriques diffusées acceptent. - Symfony : injecte
NextPDF\Symfony\Service\PdfFactoryet appellecreate(). Cela renvoie unNextPDF\Core\Documenttout neuf avec les valeurs par défaut configurées appliquées.
Surface de l’API
Section intitulée « Surface de l’API »| Préoccupation | Laravel | Symfony |
|---|---|---|
| Document tout neuf | app(DocumentFactoryInterface::class)->create() | PdfFactory::create() |
| Diffusion inline | PdfResponse::streamInline($doc, $name) | PdfResponse::streamInline($doc, $name) |
| Diffusion en téléchargement | PdfResponse::streamDownload($doc, $name) | PdfResponse::streamDownload($doc, $name) |
| Type renvoyé | Symfony\Component\HttpFoundation\StreamedResponse | Symfony\Component\HttpFoundation\StreamedResponse |
| Appel de construction dans le callback | NextPDF\Core\Document::getPdfData() | NextPDF\Core\Document::getPdfData() |
| Taille de chunk | 64 Ko (str_split déterministe) | 64 Ko (boucle substr déterministe) |
La PdfResponse Laravel se trouve à NextPDF\Laravel\Http\PdfResponse ; celle de Symfony à NextPDF\Symfony\Http\PdfResponse. Leurs fabriques diffusées renvoient toutes deux le même type Symfony\Component\HttpFoundation\StreamedResponse. Toutes deux appliquent le même jeu fixe d’en-têtes OWASP de durcissement de réponse (X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Content-Security-Policy: default-src 'none', X-Robots-Tag: noindex, nofollow, Referrer-Policy: no-referrer), et toutes deux assainissent le nom de fichier du téléchargement. Tu n’ajoutes pas ces en-têtes toi-même.
Les deux fabriques appellent la même surface cœur sous-jacente, NextPDF\Core\Document::getPdfData(): string, qui construit et renvoie tout le binaire PDF. Sa méthode sœur save(string $path): void écrit les mêmes octets sur le disque via un écrivain atomique. Ce guide pratique utilise getPdfData() parce que la cible est un socket HTTP, pas un fichier.
Exemple de code — Démarrage rapide
Section intitulée « Exemple de code — Démarrage rapide »L’action minimale de diffusion en téléchargement dans chaque framework. Les appels liés au document relèvent de la même surface cœur ; seul l’échafaudage du contrôleur diffère. La fabrique diffusée remet un callback au framework, donc ton action renvoie immédiatement. Le corps est construit et flushé quand le framework envoie la réponse.
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use NextPDF\Contracts\DocumentFactoryInterface;use NextPDF\Laravel\Http\PdfResponse;use Symfony\Component\HttpFoundation\StreamedResponse;
final class ReportController extends Controller{ public function annualReport(): StreamedResponse { $document = app(DocumentFactoryInterface::class)->create(); $document->addPage(); $document->cell(0, 10, 'Annual report', newLine: true);
return PdfResponse::streamDownload($document, 'annual-report.pdf'); }}<?php
declare(strict_types=1);
namespace App\Controller;
use NextPDF\Symfony\Http\PdfResponse;use NextPDF\Symfony\Service\PdfFactory;use Symfony\Component\HttpFoundation\StreamedResponse;use Symfony\Component\Routing\Attribute\Route;
final class ReportController{ #[Route('/report', name: 'report_pdf')] public function annualReport(PdfFactory $pdf): StreamedResponse { $document = $pdf->create(); $document->addPage(); $document->cell(0, 10, 'Annual report', newLine: true);
return PdfResponse::streamDownload($document, 'annual-report.pdf'); }}Pour prévisualiser dans un onglet de navigateur au lieu de forcer le téléchargement, appelle streamInline(...) à la place de streamDownload(...). Le Content-Disposition devient inline, et tous les autres en-têtes restent les mêmes.
Exemple de code — Production
Section intitulée « Exemple de code — Production »Une action de production injecte ses dépendances, valide le paramètre de chemin, attrape l’exception la plus spécifique que la construction peut lever, journalise la classe de l’échec sans divulguer de trace, et renvoie une erreur HTTP définie. L’exemple ci-dessous utilise l’injection par constructeur de Laravel. L’équivalent Symfony suit la même forme, avec PdfFactory injecté dans l’action.
getPdfData() s’exécute à l’intérieur du callback de diffusion ; toute exception qu’il lève remonte donc après que le framework a commencé à envoyer les en-têtes. Pour garder une gestion d’erreurs utile, construis le document (l’étape qui peut échouer) avant de renvoyer la réponse, et attrape l’échec de construction à cet endroit. Seul le transfert en chunks des octets déjà construits se produit alors à l’intérieur du callback.
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Response;use NextPDF\Contracts\DocumentFactoryInterface;use NextPDF\Core\Document;use NextPDF\Exception\NextPdfException;use NextPDF\Laravel\Http\PdfResponse;use Psr\Log\LoggerInterface;use Symfony\Component\HttpFoundation\StreamedResponse;
final class StatementController extends Controller{ private const int MAX_STATEMENT_ID = 9_999_999;
public function __construct( private readonly DocumentFactoryInterface $documents, private readonly LoggerInterface $logger, ) {}
public function show(int $statementId): StreamedResponse|Response { // Validate input at the boundary before any build work runs. if ($statementId < 1 || $statementId > self::MAX_STATEMENT_ID) { return new Response('Invalid statement identifier.', 422); }
try { // Build the whole document up front. getPdfData(), invoked inside // the streamed callback, materializes the full PDF in memory, so // do the failure-prone build here, where the catch can still set a // clean HTTP status before any byte is sent. $document = $this->buildStatement($statementId); $document->getPdfData(); } catch (NextPdfException $exception) { // Log the exception class, never the message or a stack trace, so // internal detail does not leak into the log sink. $this->logger->error('Statement PDF build failed', [ 'statement_id' => $statementId, 'exception' => $exception::class, ]);
return new Response('Could not generate the statement PDF.', 500); }
// The build succeeded. The streamed factory rebuilds the bytes inside // its callback and flushes them to the client in 64 KB chunks. return PdfResponse::streamDownload( $document, "statement-{$statementId}.pdf", ); }
private function buildStatement(int $statementId): Document { $document = $this->documents->create(); $document->addPage(); $document->cell(0, 10, "Statement #{$statementId}", newLine: true);
return $document; }}Attrape NextPDF\Exception\NextPdfException, la base abstraite dont héritent toutes les exceptions NextPDF, quand tu veux un gestionnaire unique pour n’importe quel échec de construction. Pour réagir à des causes spécifiques, attrape d’abord les sous-types concrets que getPdfData() peut lever : NextPDF\Exception\PageLayoutException quand le contenu ne tient pas dans la géométrie de la page, NextPDF\Exception\CompressionException quand la compression de stream échoue, et NextPDF\Exception\InvalidConfigException pour une configuration de sortie invalide. N’écris jamais de bloc catch vide. Chaque branche ici journalise la classe de l’échec et renvoie un statut défini.
Récupérer un document tout neuf par action permet de remplacer la fabrique dans les tests. Ne réutilise pas une seule instance de contrôleur pour deux documents sans rapport dans un même processus worker de longue durée, car l’état de contenu obsolète se reporte.
Cas limites & pièges
Section intitulée « Cas limites & pièges »- Le document est construit deux fois dans le pattern valider-puis-diffuser. L’exemple de production appelle
getPdfData()une fois pour valider la construction, puis la fabrique l’appelle de nouveau à l’intérieur du callback. C’est le coût du déplacement du point d’échec en amont des en-têtes. Quand une double construction est trop coûteuse pour un document donné, saute l’étape de pré-construction et accepte qu’un échec de construction à l’intérieur du callback tronque une réponse déjà démarrée. - Pas de
Content-Length. La variante diffusée omet l’en-tête. Les barres de progression de téléchargement et les requêtesRangene fonctionneront pas. Utilise la variante bufferiséedownload()/inline()quand une longueur connue est requise. - Un proxy de bufferisation annule le bénéfice. Un reverse proxy ou un buffer de sortie PHP qui capture tout le corps avant de le transmettre garde de nouveau tout le PDF, ce qui annule l’économie de copie. Configure le proxy pour diffuser les réponses
application/pdf, ou utilise une réponse bufferisée sur ce chemin. - CodeIgniter 4 n’est pas diffusé par callback. L’intégration CodeIgniter livre les mêmes noms de méthode
streamInline()/streamDownload(), mais elles renvoient uneCodeIgniter\HTTP\DownloadResponsequi garde tout le corps, pas uneStreamedResponsepilotée par callback. Le pattern StreamedResponse de cette page s’applique uniquement à Laravel et Symfony. - N’écris pas dans le corps après le renvoi. Le callback de diffusion est propriétaire de la sortie. Ne fais pas d’
echoet n’écris pas toi-même dans le corps de la réponse après avoir remis laStreamedResponseau framework. - Les documents signés échouent rapidement. Appeler
getPdfData()sur un document configuré pour une signature PAdES de haut niveau lèveNextPDF\Exception\NotImplementedExceptionplutôt que d’émettre un fichier non signé. Diffuse la sortie signée via le chemin de signature documenté, pas via ce guide pratique.
Performance
Section intitulée « Performance »La diffusion limite la copie de réponse, pas la construction du document. Le pic mémoire correspond grosso modo à la taille d’un PDF terminé, parce que getPdfData() réalise tout le document avant que le premier chunk ne soit envoyé. Pour un document véritablement gros ou multi-pages, c’est la construction elle-même, pas le transfert, qui domine le budget de la requête. Déplace la génération hors du thread de requête avec un job en file d’attente. Voir Générer un PDF dans un job en file d’attente.
La taille de chunk de 64 Ko est fixe et déterministe dans les deux intégrations. Elle régit seulement la granularité de transfert et ne change ni le total des octets envoyés ni le pic mémoire. Choisis la variante diffusée quand la copie de réponse économisée est la contrainte et qu’une barre de progression n’est pas requise. Choisis la variante bufferisée pour des réponses petites et sensibles à la latence qui bénéficient d’un Content-Length connu.
Notes de sécurité
Section intitulée « Notes de sécurité »- Valide l’entrée avant de construire. L’action de production rejette un identifiant hors plage avec un
422avant que le moindre travail de construction ne s’exécute. N’interpole jamais d’entrée non validée dans la construction ni dans le nom de fichier. - L’assainissement du nom de fichier est appliqué pour toi. Les deux fabriques diffusées assainissent le nom de fichier et ajoutent le jeu d’en-têtes OWASP de durcissement de réponse. Passe une valeur que tu contrôles et laisse la fabrique l’assainir comme seconde couche. N’encode pas le nom de fichier à la main.
- Borne la mémoire concurrente. Comme tout le PDF est matérialisé en mémoire par requête, un trafic concurrent important multiplie le pic mémoire. Impose des limites de taille et de débit sur les entrées qui déclenchent une construction pour atténuer le déni de service par épuisement mémoire.
- Journalise la classe de l’échec, pas le message. Le bloc catch journalise
$exception::classet un identifiant de corrélation, jamais le message de l’exception ni une trace d’appel. Une trace brute dans une destination de logs est une fuite d’information. - Pas de catch vide. Chaque branche catch de cette page journalise et renvoie une réponse d’erreur définie.
Conformité
Section intitulée « Conformité »Ce guide ne fait aucune revendication normative de conformité à un standard. Chaque classe, méthode et en-tête montré relève de la surface publique vérifiée des intégrations nommées : NextPDF\Core\Document::getPdfData(), les fabriques diffusées NextPDF\Laravel\Http\PdfResponse et NextPDF\Symfony\Http\PdfResponse, et le type de retour Symfony\Component\HttpFoundation\StreamedResponse. La sémantique des en-têtes OWASP de durcissement de réponse que les fabriques appliquent est documentée, avec leurs citations, sur la page sécurité-et-opérations de chaque intégration, liée sous Voir aussi. Cette page du Cookbook reprend l’usage et renvoie les citations normatives à ces pages.
Voir aussi
Section intitulée « Voir aussi »- Renvoyer un PDF généré depuis un contrôleur : les homologues bufferisés
inline()etdownload(). - Générer un PDF dans un job en file d’attente : déplace la construction hors du thread de requête.
- Utilisation en production avec Laravel : contrôleur câblé par DI, jeu d’en-têtes OWASP et contrat de binding du conteneur.
- Utilisation en production avec Symfony : callback de diffusion, émetteur de chunks de 64 Ko et localisateur de builder.