Zum Inhalt springen

Großes generiertes PDF als HTTP-Response streamen

Sie generieren in einem Controller ein großes PDF und möchten die Bytes zurückgeben, ohne eine zweite vollständige Kopie im Response-Buffer zu halten. Jede Framework-Integration liefert mit streamInline() und streamDownload() eine gestreamte Variante ihrer PdfResponse-Factory mit. Beide geben eine StreamedResponse des Frameworks zurück, deren Callback den PDF-Body in festen 64-KB-Chunks an den Client schreibt.

Prüfen Sie das Speichermodell sorgfältig, bevor Sie sich für diesen Weg entscheiden. Die Engine baut zuerst das vollständige Dokument im Arbeitsspeicher auf. Der gestreamte Callback ruft getPdfData() auf, das das gesamte PDF als einen String materialisiert, und durchläuft diesen String dann in 64-KB-Slices. Die Speicherersparnis betrifft die zweite Kopie, die eine gepufferte Illuminate\Http\Response oder Symfony\Component\HttpFoundation\Response halten würde, während das Framework Content-Length ermittelt. Die gestreamte Variante ermittelt die Länge nicht und lässt Content-Length daher weg. Sie hält den Response-Body und den Dokument-String nie gleichzeitig. Das ist kein echtes inkrementelles Streaming: NextPDF hat keine inkrementelle Writer-Schnittstelle, daher wird das Dokument vollständig realisiert, bevor das erste Byte den Socket erreicht.

Voraussetzungen, vorab genannt, damit Sie mitten in der Aufgabe nichts überrascht:

  • NextPDF-Core ist installiert und eine Framework-Integration, nextpdf/laravel oder nextpdf/symfony, ist installiert und erkannt.
  • Sie wissen bereits, wie Sie in Ihrem Framework eine Anfrage an einen Controller routen.
  • Sie haben Ein generiertes PDF aus einem Controller zurückgeben gelesen; dort werden die gepufferten Factories inline() und download() behandelt, auf denen dieses Recipe aufbaut.

Diese Anleitung dreht sich um das StreamedResponse-Muster, das Laravel und Symfony gemeinsam haben. CodeIgniter 4 liefert dieselben Methodennamen streamInline() / streamDownload(), doch sie verpacken die Bytes in eine CodeIgniter\HTTP\DownloadResponse statt in eine Callback-getriebene StreamedResponse. Der Abschnitt zu Randfällen hält diesen Unterschied fest.

Installieren Sie die Integration, die zu Ihrem Framework passt. Führen Sie einen der folgenden Befehle aus.

Terminal-Fenster
composer require nextpdf/laravel
Terminal-Fenster
composer require nextpdf/symfony

Veröffentliche bei Laravel die Konfiguration nach der Installation.

Terminal-Fenster
php artisan vendor:publish --tag=nextpdf-config

Symfony registriert das Bundle automatisch über Flex. Bestätigen Sie die Erkennung auf der Installationsseite Ihres Frameworks, bevor Sie fortfahren.

Eine gepufferte Response-Factory, PdfResponse::download() oder PdfResponse::inline(), ruft getPdfData() auf, legt den zurückgegebenen String in einem Response-Objekt ab und setzt Content-Length anhand von strlen(). Das Framework hält diesen String dann während der gesamten Lebensdauer der Response. Bei einem großen Dokument bedeutet das, dass der Dokument-String und der Response-Body-String gleichzeitig im Arbeitsspeicher liegen.

Die gestreamte Factory hat eine andere Form. PdfResponse::streamDownload() und PdfResponse::streamInline() geben eine StreamedResponse zurück, die mit einem Callback erstellt wird. Das Framework ruft diesen Callback erst auf, wenn es bereit ist, den Body zu senden. Innerhalb des Callbacks ruft die Integration getPdfData() einmal auf, zerlegt den zurückgegebenen String in 64-KB-Chunks und gibt jeden Chunk per echo aus, gefolgt von einem flush(). Es wird keine zweite dauerhafte Kopie des Bodys behalten und kein Content-Length-Header ausgegeben.

Zwei Tatsachen prägen jede Entscheidung auf dieser Seite:

  • Der Aufbau erfolgt eager, die Übertragung chunkweise. getPdfData() auf NextPDF\Core\Document ruft den Writer auf und gibt das gesamte PDF als einen String zurück. Das 64-KB-Chunking bestimmt nur, wie die bereits aufgebauten Bytes den Prozess verlassen. Der Spitzen-Arbeitsspeicher wird durch die Größe eines fertigen Dokuments begrenzt, nicht durch ein kleines Streaming-Fenster.
  • Kein Content-Length. Die gestreamte Variante kann die Body-Länge nicht kennen, ohne sie im Callback aufzubauen, und lässt den Header daher weg. Ein Fortschrittsbalken auf der Client-Seite, eine Range-Anfrage oder ein längenabhängiger Proxy sehen keine Größe. Wählen Sie das gepufferte download() / inline(), wenn eine bekannte Länge wichtiger ist als die eingesparte Response-Kopie.

Beziehen Sie das Dokument über den idiomatischen Auflösungsweg des Frameworks:

  • Laravel: Lösen Sie NextPDF\Contracts\DocumentFactoryInterface aus dem Container auf und rufen Sie create() auf. Es gibt ein frisches NextPDF\Core\Document zurück, den konkreten Typ, den die gestreamten Factories akzeptieren.
  • Symfony: Injizieren Sie NextPDF\Symfony\Service\PdfFactory und rufen Sie create() auf. Es gibt ein frisches NextPDF\Core\Document zurück, auf das die konfigurierten Standardwerte angewendet sind.
AspektLaravelSymfony
Frisches Dokumentapp(DocumentFactoryInterface::class)->create()PdfFactory::create()
Gestreamtes InlinePdfResponse::streamInline($doc, $name)PdfResponse::streamInline($doc, $name)
Gestreamter DownloadPdfResponse::streamDownload($doc, $name)PdfResponse::streamDownload($doc, $name)
RückgabetypSymfony\Component\HttpFoundation\StreamedResponseSymfony\Component\HttpFoundation\StreamedResponse
Build-Aufruf im CallbackNextPDF\Core\Document::getPdfData()NextPDF\Core\Document::getPdfData()
Chunk-Größe64 KB (deterministisches str_split)64 KB (deterministische substr-Schleife)

Die PdfResponse von Laravel liegt unter NextPDF\Laravel\Http\PdfResponse; die von Symfony unter NextPDF\Symfony\Http\PdfResponse. Ihre gestreamten Factories geben beide denselben Typ Symfony\Component\HttpFoundation\StreamedResponse zurück. Beide wenden denselben festen Response-Hardening-Header-Satz des Open Web Application Security Project (OWASP) an (X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Content-Security-Policy: default-src 'none', X-Robots-Tag: noindex, nofollow, Referrer-Policy: no-referrer) und bereinigen beide den Download-Dateinamen. Diese Header fügen Sie nicht selbst hinzu.

Beide Factories rufen dieselbe zugrunde liegende Core-Schnittstelle auf, NextPDF\Core\Document::getPdfData(): string, die das gesamte PDF-Binary aufbaut und zurückgibt. Ihr Pendant save(string $path): void schreibt dieselben Bytes über einen atomaren Writer auf die Festplatte. Dieses Recipe nutzt getPdfData(), weil das Ziel ein HTTP-Socket und keine Datei ist.

Die minimale gestreamte Download-Action in jedem Framework. Die Dokument-Aufrufe verwenden dieselbe Core-Schnittstelle; nur das Controller-Gerüst unterscheidet sich. Die gestreamte Factory übergibt dem Framework einen Callback, sodass Ihre Action sofort zurückkehrt. Der Body wird aufgebaut und geflusht, wenn das Framework die Response sendet.

Laravel: app/Http/Controllers/ReportController.php
<?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');
}
}
Symfony: src/Controller/ReportController.php
<?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');
}
}

Um in einem Browser-Tab eine Vorschau anzuzeigen, statt einen Download zu erzwingen, rufen Sie streamInline(...) anstelle von streamDownload(...) auf. Die Content-Disposition wird zu inline, alle anderen Header bleiben gleich.

Eine produktionsreife Action injiziert ihre Abhängigkeiten, validiert die Pfad-Eingabe, fängt die spezifischste Exception ab, die der Build auslösen kann, loggt die Fehlerklasse, ohne einen Trace preiszugeben, und gibt einen definierten HTTP-Fehler zurück. Das folgende Beispiel nutzt Laravel-Constructor-Injection. Das Symfony-Pendant hat dieselbe Form, wobei PdfFactory pro Action injiziert wird.

getPdfData() läuft innerhalb des gestreamten Callbacks; eine dadurch ausgelöste Exception tritt daher erst nachdem das Framework begonnen hat, Header zu senden, in Erscheinung. Damit die Fehlerbehandlung sinnvoll bleibt, bauen Sie das Dokument, also den potenziell fehlschlagenden Schritt, bevor Sie die Response zurückgeben, und fangen Sie den Build-Fehler dort ab. Innerhalb des Callbacks findet dann nur noch die chunkweise Übertragung der bereits aufgebauten Bytes statt.

Laravel: app/Http/Controllers/StatementController.php
<?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;
}
}

Fangen Sie NextPDF\Exception\NextPdfException ab, die abstrakte Basis, die jede NextPDF-Exception erweitert, wenn Sie einen einzigen Handler für jeden Build-Fehler benötigen. Um auf bestimmte Ursachen zu reagieren, fangen Sie zuerst die konkreten Untertypen ab, die getPdfData() auslösen kann: NextPDF\Exception\PageLayoutException, wenn Inhalt nicht in die Seitengeometrie passt, NextPDF\Exception\CompressionException, wenn die Stream-Komprimierung fehlschlägt, und NextPDF\Exception\InvalidConfigException für eine ungültige Ausgabekonfiguration. Schreiben Sie niemals einen leeren catch-Block. Jeder Zweig hier loggt die Fehlerklasse und gibt einen definierten Status zurück.

Wenn Sie pro Action ein frisches Dokument auflösen, bleibt die Factory in Tests austauschbar. Verwenden Sie eine Controller-Instanz nicht für zwei voneinander unabhängige Dokumente innerhalb eines einzigen langlaufenden Worker-Prozesses wieder, weil veralteter Content-State übernommen wird.

  • Beim Validate-dann-Stream-Muster wird das Dokument zweimal aufgebaut. Das Produktionsbeispiel ruft getPdfData() einmal auf, um den Build zu validieren, dann ruft die Factory es im Callback erneut auf. Das ist der Preis dafür, den Fehlerpunkt vor die Header zu verschieben. Wenn ein doppelter Build für ein bestimmtes Dokument zu teuer ist, überspringen Sie die Pre-Build-Probe und nehmen Sie in Kauf, dass ein Build-Fehler im Callback eine bereits gestartete Response abschneidet.
  • Kein Content-Length. Die gestreamte Variante lässt den Header weg. Download-Fortschrittsbalken und Range-Anfragen funktionieren nicht. Nutzen Sie das gepufferte download() / inline(), wenn eine bekannte Länge erforderlich ist.
  • Ein puffernder Proxy hebt den Vorteil auf. Ein Reverseproxy oder ein PHP-Output-Buffer, der den gesamten Body erfasst, bevor er ihn weiterleitet, hält das vollständige PDF erneut und macht die eingesparte Kopie zunichte. Konfigurieren Sie den Proxy so, dass er application/pdf-Responses streamt, oder nutzen Sie auf diesem Pfad eine gepufferte Response.
  • CodeIgniter 4 ist nicht Callback-gestreamt. Die CodeIgniter-Integration liefert dieselben Methodennamen streamInline() / streamDownload(), doch sie geben eine CodeIgniter\HTTP\DownloadResponse zurück, die den vollständigen Body hält, keine Callback-getriebene StreamedResponse. Das StreamedResponse-Muster auf dieser Seite gilt nur für Laravel und Symfony.
  • Schreiben Sie nach der Rückgabe nicht in den Body. Der gestreamte Callback besitzt die Ausgabe. Geben Sie nicht selbst per echo aus und schreiben Sie nicht in den Response-Body, nachdem Sie die StreamedResponse an das Framework zurückgegeben haben.
  • Signierte Dokumente schlagen sofort fehl. Wenn Sie getPdfData() auf einem Dokument aufrufen, das für eine High-Level-PAdES-Signatur eingerichtet ist, wird NextPDF\Exception\NotImplementedException ausgelöst, statt eine unsignierte Datei auszugeben. Streamen Sie signierte Ausgaben über den dokumentierten Signatur-Pfad, nicht über dieses Recipe.

Streaming begrenzt die Response-Kopie, nicht den Dokument-Build. Der Spitzen-Arbeitsspeicher entspricht ungefähr der Größe eines fertigen PDFs, weil getPdfData() das gesamte Dokument realisiert, bevor der erste Chunk gesendet wird. Bei einem wirklich großen oder mehrseitigen Dokument dominiert der Build selbst, nicht die Übertragung, das Request-Budget. Verlagern Sie die Generierung mit einem Queue-Job vom Request-Thread weg. Siehe Ein PDF in einem Queue-Job generieren.

Die 64-KB-Chunk-Größe ist in beiden Integrationen fest und deterministisch. Sie bestimmt nur die Übertragungsgranularität und ändert weder die insgesamt gesendeten Bytes noch den Spitzen-Arbeitsspeicher. Wählen Sie die gestreamte Variante, wenn die eingesparte Response-Kopie die Einschränkung ist und kein Fortschrittsbalken erforderlich ist. Wählen Sie die gepufferte Variante für kleine, latenzempfindliche Responses, die von einer bekannten Content-Length profitieren.

  • Validieren Sie die Eingabe vor dem Build. Die produktionsreife Action weist einen Identifier außerhalb des gültigen Bereichs mit einem 422 zurück, bevor irgendeine Build-Arbeit läuft. Interpolieren Sie niemals nicht validierte Eingaben in den Build oder den Dateinamen.
  • Die Bereinigung des Dateinamens wird für Sie übernommen. Beide gestreamten Factories bereinigen den Dateinamen und fügen den OWASP-Response-Hardening-Header-Satz hinzu. Übergeben Sie einen Wert, den Sie kontrollieren, und lassen Sie die Factory ihn als zweite Schicht bereinigen. Kodieren Sie den Dateinamen nicht von Hand.
  • Begrenzen Sie den nebenläufigen Arbeitsspeicher. Weil das gesamte PDF pro Anfrage im Arbeitsspeicher materialisiert wird, vervielfacht hoher nebenläufiger Traffic den Spitzen-Arbeitsspeicher. Erzwingen Sie Größen- und Rate-Limits für die Eingaben, die einen Build antreiben, um einen Denial of Service durch Speichererschöpfung abzuschwächen.
  • Loggen Sie die Fehlerklasse, nicht die Nachricht. Der catch-Block loggt $exception::class und einen Korrelations-Identifier, niemals die Exception-Nachricht oder einen Stacktrace. Ein roher Trace in einer Log-Senke ist ein Informationsleck.
  • Kein leeres catch. Jeder catch-Zweig auf dieser Seite loggt und gibt eine definierte Fehler-Response zurück.

Diese Anleitung erhebt keinen normativen Standards-Anspruch. Jede gezeigte Klasse, Methode und jeder Header ist die verifizierte öffentliche Oberfläche der genannten Integration: NextPDF\Core\Document::getPdfData(), die gestreamten Factories NextPDF\Laravel\Http\PdfResponse und NextPDF\Symfony\Http\PdfResponse sowie der Rückgabetyp Symfony\Component\HttpFoundation\StreamedResponse. Die OWASP-Response-Hardening-Header-Semantik, die die Factories anwenden, ist mitsamt ihren Zitaten auf der Security-and-Operations-Seite jeder Integration dokumentiert, die unter „Siehe auch“ verlinkt ist. Diese Cookbook-Seite gibt die Verwendung wieder und überlässt die normativen Zitate jenen Seiten.