Skip to content

Stream a large generated PDF as an HTTP response

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/laravel or nextpdf/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() and download() 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 the integration for your framework. Run one of the following commands.

Terminal window
composer require nextpdf/laravel
Terminal window
composer require nextpdf/symfony

For Laravel, publish the configuration after installation.

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

Symfony registers the bundle through Flex. Confirm discovery on your framework’s installation page before continuing.

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() on NextPDF\Core\Document calls 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, a Range request, or a length-sensitive proxy will not see a size. Choose the buffered download() / 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\DocumentFactoryInterface from the container and call create(). It returns a fresh NextPDF\Core\Document, the concrete type the streamed factories accept.
  • Symfony: inject NextPDF\Symfony\Service\PdfFactory and call create(). It returns a fresh NextPDF\Core\Document with the configured defaults applied.
ConcernLaravelSymfony
Fresh documentapp(DocumentFactoryInterface::class)->create()PdfFactory::create()
Streamed inlinePdfResponse::streamInline($doc, $name)PdfResponse::streamInline($doc, $name)
Streamed downloadPdfResponse::streamDownload($doc, $name)PdfResponse::streamDownload($doc, $name)
Returned typeSymfony\Component\HttpFoundation\StreamedResponseSymfony\Component\HttpFoundation\StreamedResponse
Build call inside callbackNextPDF\Core\Document::getPdfData()NextPDF\Core\Document::getPdfData()
Chunk size64 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.

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.

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');
}
}

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.

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.

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;
}
}

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.

  • 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 and Range requests will not work. Use the buffered download() / 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/pdf responses, 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 a CodeIgniter\HTTP\DownloadResponse that holds the full body, not a callback-driven StreamedResponse. 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 echo or write to the response body yourself after you return the StreamedResponse back to the framework.
  • Signed documents fail fast. Calling getPdfData() on a document set up for a high-level PAdES signature raises NextPDF\Exception\NotImplementedException rather than emitting an unsigned file. Stream signed output through the documented signing path, not through this recipe.

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.

  • Validate input before building. The production action rejects an out-of-range identifier with a 422 before 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::class and 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.

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.