Stream a large generated PDF as an HTTP response
At a glance
Section titled “At a glance”You generate a large PDF inside a controller and want to return the bytes
without keeping a second full copy in the response buffer. Each framework
integration includes streamed variants of its PdfResponse factory:
streamInline() and streamDownload(). Each method returns a framework
StreamedResponse with a callback that writes the PDF body to the client in
fixed 64 KB chunks.
Read the memory model before you choose this path. The engine builds the
complete document in memory first. The streamed callback calls getPdfData(),
which materializes the whole PDF as one string, then walks that string in
64 KB slices. You save the peak cost of the second copy that a buffered
Illuminate\Http\Response or Symfony\Component\HttpFoundation\Response would
hold while the framework measures Content-Length. The streamed variant does
not measure length, so it omits Content-Length. It never holds the response
body and the document string at the same time. It is not true incremental
streaming: NextPDF has no incremental writer surface, so the document is fully
realized before the first byte reaches the socket.
Before you start, make sure these pieces are in place:
- NextPDF core is installed and one framework integration,
nextpdf/laravelornextpdf/symfony, is installed and discovered. - You already know how to route a request to a controller in your framework.
- You have read Return a generated PDF from a controller,
which covers the buffered
inline()anddownload()factories this recipe builds on.
This how-to focuses on the StreamedResponse pattern shared by Laravel and
Symfony. CodeIgniter 4 ships the same streamInline() / streamDownload()
method names, but wraps the bytes in a CodeIgniter\HTTP\DownloadResponse
instead of a callback-driven StreamedResponse. The Edge cases section covers
that difference.
Install
Section titled “Install”Install the integration for your framework. Run one of the following commands.
composer require nextpdf/laravelcomposer require nextpdf/symfonyFor Laravel, publish the configuration after installation.
php artisan vendor:publish --tag=nextpdf-configSymfony registers the bundle through Flex. Confirm discovery on your framework’s installation page before continuing.
Conceptual overview
Section titled “Conceptual overview”A buffered response factory, PdfResponse::download() or
PdfResponse::inline(), calls getPdfData(), stores the returned string on a
Response object, and sets Content-Length from strlen(). The framework
then keeps that string for the lifetime of the response. For a large document,
the document string and the response body string live in memory at the same
time.
The streamed factory uses a different shape. PdfResponse::streamDownload()
and PdfResponse::streamInline() return a StreamedResponse built with a
callback. The framework invokes that callback only when it is ready to send the
body. Inside the callback, the integration calls getPdfData() once, slices the
returned string into 64 KB chunks, and echoes each chunk followed by a
flush(). It does not retain a second persistent copy of the body, and it does
not emit a Content-Length header.
Two facts shape every decision on this page:
- Build is eager, transfer is chunked.
getPdfData()onNextPDF\Core\Documentcalls the writer and returns the entire PDF as one string. The 64 KB chunking controls only how the already-built bytes leave the process. Peak memory is bounded by the size of one finished document, not by a small streaming window. - No
Content-Length. The streamed variant cannot know the body length without building it inside the callback, so it omits the header. A client progress bar, aRangerequest, or a length-sensitive proxy will not see a size. Choose the buffereddownload()/inline()when a known length matters more than saving the response copy.
Get the document through the framework’s idiomatic resolution path:
- Laravel: resolve
NextPDF\Contracts\DocumentFactoryInterfacefrom the container and callcreate(). It returns a freshNextPDF\Core\Document, the concrete type the streamed factories accept. - Symfony: inject
NextPDF\Symfony\Service\PdfFactoryand callcreate(). It returns a freshNextPDF\Core\Documentwith the configured defaults applied.
API surface
Section titled “API surface”| Concern | Laravel | Symfony |
|---|---|---|
| Fresh document | app(DocumentFactoryInterface::class)->create() | PdfFactory::create() |
| Streamed inline | PdfResponse::streamInline($doc, $name) | PdfResponse::streamInline($doc, $name) |
| Streamed download | PdfResponse::streamDownload($doc, $name) | PdfResponse::streamDownload($doc, $name) |
| Returned type | Symfony\Component\HttpFoundation\StreamedResponse | Symfony\Component\HttpFoundation\StreamedResponse |
| Build call inside callback | NextPDF\Core\Document::getPdfData() | NextPDF\Core\Document::getPdfData() |
| Chunk size | 64 KB (deterministic str_split) | 64 KB (deterministic substr loop) |
The Laravel PdfResponse lives at NextPDF\Laravel\Http\PdfResponse; the
Symfony one lives at NextPDF\Symfony\Http\PdfResponse. Their streamed
factories both return the same Symfony\Component\HttpFoundation\StreamedResponse
type. Both apply the same fixed Open Web Application Security Project (OWASP)
response-hardening header set (X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Content-Security-Policy: default-src 'none',
X-Robots-Tag: noindex, nofollow, Referrer-Policy: no-referrer), and both
sanitize the download filename. You do not add those headers yourself.
Both factories call the same underlying core surface,
NextPDF\Core\Document::getPdfData(): string, which builds and returns the
whole PDF binary. Its sibling save(string $path): void writes the same bytes
to disk through an atomic writer. This recipe uses getPdfData() because the
target is an HTTP socket, not a file.
Code sample — Quick start
Section titled “Code sample — Quick start”Here is the minimal streamed download action in each framework. The document calls use the same core surface; only the controller scaffolding differs. The streamed factory hands the framework a callback, so your action returns immediately. The body is built and flushed when the framework sends the response.
<?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'); }}To preview in a browser tab instead of forcing a download, call
streamInline(...) instead of streamDownload(...). The
Content-Disposition becomes inline, and every other header stays the same.
Code sample — Production
Section titled “Code sample — Production”A production action injects its dependencies, validates the path input, catches
the most specific exception the build can raise, logs the failure class without
leaking a trace, and returns a defined Hypertext Transfer Protocol (HTTP) error.
The example below uses Laravel constructor injection. The Symfony equivalent
follows the same shape, with PdfFactory injected per action.
getPdfData() runs inside the streamed callback, so an exception it raises
surfaces after the framework has begun sending headers. To keep error
handling useful, build the document (the step that can fail) before you hand
the response back, and catch the build failure there. Then only the chunked
transfer of already-built bytes happens inside the 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; }}Catch NextPDF\Exception\NextPdfException, the abstract base every NextPDF
exception extends, when you want one handler for any build failure. To respond
to specific causes, catch the concrete subtypes getPdfData() can raise first:
NextPDF\Exception\PageLayoutException when content cannot fit the page
geometry, NextPDF\Exception\CompressionException when stream compression
fails, and NextPDF\Exception\InvalidConfigException for an invalid output
configuration. Never write an empty catch block. Each branch here logs the
failure class and returns a defined status.
Resolving a fresh document per action keeps the factory swappable in tests. Do not reuse one controller instance for two unrelated documents inside a single long-running worker process, because stale content state carries over.
Edge cases & gotchas
Section titled “Edge cases & gotchas”- The document is built twice in the validate-then-stream pattern. The
production sample calls
getPdfData()once to validate the build, then the factory calls it again inside the callback. This is the cost of moving the failure point ahead of the headers. When a double build is too expensive for a given document, skip the pre-build probe and accept that a build failure inside the callback truncates an already-started response. - No
Content-Length. The streamed variant omits the header. Download progress bars andRangerequests will not work. Use the buffereddownload()/inline()when a known length is required. - Buffering proxy negates the benefit. A reverse proxy or PHP output buffer
that captures the whole body before forwarding it holds the full PDF again,
which erases the saved copy. Configure the proxy to stream
application/pdfresponses, or use a buffered response on that path. - CodeIgniter 4 is not callback-streamed. The CodeIgniter integration ships
the same
streamInline()/streamDownload()method names, but they return aCodeIgniter\HTTP\DownloadResponsethat holds the full body, not a callback-drivenStreamedResponse. The StreamedResponse pattern on this page applies to Laravel and Symfony only. - Do not write to the body after returning. The streamed callback owns
output. Do not
echoor write to the response body yourself after you return theStreamedResponseback to the framework. - Signed documents fail fast. Calling
getPdfData()on a document set up for a high-level PAdES signature raisesNextPDF\Exception\NotImplementedExceptionrather than emitting an unsigned file. Stream signed output through the documented signing path, not through this recipe.
Performance
Section titled “Performance”Streaming bounds the response copy, not the document build. Peak memory is
roughly the size of one finished PDF, because getPdfData() realizes the whole
document before sending the first chunk. For a genuinely large or multi-page
document, the build itself, not the transfer, dominates the request budget. Move
generation off the request thread with a queued job. See
Generate a PDF in a queued job.
The 64 KB chunk size is fixed and deterministic in both integrations. It
controls transfer granularity only and does not change the total bytes sent or
the peak memory. Choose the streamed variant when the saved response copy is the
constraint and a progress bar is not required. Choose the buffered variant for
small, latency-sensitive responses that benefit from a known Content-Length.
Security notes
Section titled “Security notes”- Validate input before building. The production action rejects an
out-of-range identifier with a
422before any build work runs. Never interpolate unvalidated input into the build or the filename. - Filename sanitization is applied for you. Both streamed factories sanitize the filename and add the OWASP response-hardening header set. Pass a value you control and let the factory sanitize it as a second layer. Do not hand-encode the filename.
- Bound concurrent memory. Because the whole PDF is materialized in memory per request, high concurrent traffic multiplies peak memory. Enforce size and rate limits on the inputs that drive a build to mitigate memory-exhaustion denial of service.
- Log the failure class, not the message. The catch block logs
$exception::classand a correlation identifier, never the exception message or a stack trace. A raw trace in a log sink is an information leak. - No empty catch. Every catch branch on this page logs and returns a defined error response.
Conformance
Section titled “Conformance”This guide makes no normative standards claim. Every class, method, and header
shown is the verified public surface of the named integration:
NextPDF\Core\Document::getPdfData(), the
NextPDF\Laravel\Http\PdfResponse and NextPDF\Symfony\Http\PdfResponse
streamed factories, and the Symfony\Component\HttpFoundation\StreamedResponse
return type. The OWASP response-hardening header semantics that the factories
apply are documented, with their citations, on each integration’s
security-and-operations page linked under See also. This cookbook page restates
the usage and defers the normative citations to those pages.
See also
Section titled “See also”- Return a generated PDF from a controller: the buffered
inline()anddownload()counterparts. - Generate a PDF in a queued job: move the build off the request thread.
- Laravel production usage: DI-wired controller, the OWASP header set, and the container binding contract.
- Symfony production usage: the streamed callback, the 64 KB chunk emitter, and the builder locator.