Skip to content

Return a generated PDF from a controller

Generate a Portable Document Format (PDF) file in a controller action and return it as a Hypertext Transfer Protocol (HTTP) response. Each framework integration includes a PdfResponse helper that builds the response object, sets Content-Type: application/pdf, attaches the security headers, and sanitizes the filename. This guide covers the three delivery modes: inline preview, file download, and streamed delivery for Laravel, Symfony, and CodeIgniter 4.

Check these prerequisites first, so the controller path is ready before you start:

  • NextPDF core is installed.
  • One framework integration is installed, and its service provider, bundle, or service has been discovered. Verify discovery on the install page for your framework before you start.
  • Streamed mode needs no extra packages. Every integration includes the streamed variant alongside the buffered one.

This is a how-to. It assumes you already know how to route a request to a controller in your framework. For a first runnable example in each framework, read the framework quickstart linked under See also.

Install the integration for your framework. Run one of the following commands.

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

For Laravel, publish the configuration after installation.

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

Symfony registers the bundle through Flex, and CodeIgniter discovers the service automatically. Confirm discovery on your framework’s install page before you continue.

Every framework integration follows the same three-part shape: you get a fresh document, write content to that document, and pass it to a PdfResponse factory that returns an HTTP response. The document API (addPage(), cell(), setFont()) is the core engine surface, and it is identical across frameworks. The response factory differs only in the response class it returns, because each framework has its own HTTP-response type.

PdfResponse offers three delivery modes. Inline sets a Content-Disposition: inline header, so the browser renders the PDF in a viewer tab. Download sets Content-Disposition: attachment, so the browser saves the file. Streamed emits the PDF body in fixed chunks instead of buffering the whole document in memory. Choose it for large documents when peak memory matters more than a known Content-Length.

Get the document through your framework’s usual resolution path:

  • Laravel — resolve NextPDF\Contracts\DocumentFactoryInterface from the container with app(...) and call create(), which returns a fresh NextPDF\Core\Document — the concrete type the PdfResponse factories accept.
  • Symfony — inject NextPDF\Symfony\Service\PdfFactory and call create(), which returns a fresh NextPDF\Core\Document with the configured document defaults already applied.
  • CodeIgniter 4 — resolve the Pdf library through Services::pdf() (or the pdf() helper), or obtain a bare document through pdf_document().
ConcernLaravelSymfonyCodeIgniter 4
Fresh documentapp(DocumentFactoryInterface::class)->create()PdfFactory::create()pdf_document() / Services::pdf()->document()
Inline responsePdfResponse::inline($doc, $name)PdfResponse::inline($doc, $name)$pdf->inline($name) / PdfResponse::inline($doc, $name)
Download responsePdfResponse::download($doc, $name)PdfResponse::download($doc, $name)$pdf->download($name) / PdfResponse::download($doc, $name)
Streamed inlinePdfResponse::streamInline($doc, $name)PdfResponse::streamInline($doc, $name)PdfResponse::streamInline($doc, $name)
Streamed downloadPdfResponse::streamDownload($doc, $name)PdfResponse::streamDownload($doc, $name)PdfResponse::streamDownload($doc, $name)
Returned typeIlluminate\Http\Response (streamed: StreamedResponse)Symfony\Component\HttpFoundation\Response (streamed: StreamedResponse)CodeIgniter\HTTP\DownloadResponse

The Laravel PdfResponse lives at NextPDF\Laravel\Http\PdfResponse, the Symfony one at NextPDF\Symfony\Http\PdfResponse, and the CodeIgniter one at NextPDF\CodeIgniter\Http\PdfResponse. Each integration’s security-and-operations page documents the full response behavior for that package: header set, disposition rules, and filename sanitization. Those pages are linked under See also.

Here is the minimal download action in each framework. The document calls use the same core surface. Only the controller scaffolding changes.

Laravel: app/Http/Controllers/ReportController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Response;
use NextPDF\Contracts\DocumentFactoryInterface;
use NextPDF\Laravel\Http\PdfResponse;
final class ReportController extends Controller
{
public function download(): Response
{
$document = app(DocumentFactoryInterface::class)->create();
$document->addPage();
$document->cell(0, 10, 'Monthly report', newLine: true);
return PdfResponse::download($document, '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\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ReportController
{
#[Route('/report', name: 'report_pdf')]
public function download(PdfFactory $pdf): Response
{
$document = $pdf->create();
$document->addPage();
$document->cell(0, 10, 'Monthly report', newLine: true);
return PdfResponse::download($document, 'report.pdf');
}
}
CodeIgniter 4: app/Controllers/ReportController.php
<?php
declare(strict_types=1);
namespace App\Controllers;
use CodeIgniter\HTTP\DownloadResponse;
use NextPDF\CodeIgniter\Config\Services;
final class ReportController extends BaseController
{
public function download(): DownloadResponse
{
$pdf = Services::pdf();
$pdf->document()->addPage();
$pdf->document()->cell(0, 10, 'Monthly report');
return $pdf->download('report.pdf');
}
}

To preview in the browser instead of downloading, swap the download(...) call for inline(...) in Laravel and Symfony, or $pdf->inline('report.pdf') in CodeIgniter. The disposition changes to inline, and every other header stays the same.

A production action injects its dependencies, catches the most specific exception the integration documents, logs the failure class without leaking a trace, and returns a defined HTTP error. The example below uses Laravel constructor injection. The Symfony and CodeIgniter equivalents follow the same shape and appear on each integration’s production-usage page.

Laravel: app/Http/Controllers/InvoiceController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Response;
use NextPDF\Contracts\DocumentFactoryInterface;
use NextPDF\Laravel\Http\PdfResponse;
use Psr\Log\LoggerInterface;
use Throwable;
final class InvoiceController extends Controller
{
public function __construct(
private readonly DocumentFactoryInterface $documents,
private readonly LoggerInterface $logger,
) {}
public function show(int $invoiceId): Response
{
try {
$document = $this->documents->create();
$document->addPage();
$document->cell(0, 10, "Invoice #{$invoiceId}", newLine: true);
return PdfResponse::download(
$document,
"invoice-{$invoiceId}.pdf",
);
} catch (Throwable $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('Invoice PDF generation failed', [
'invoice_id' => $invoiceId,
'exception' => $exception::class,
]);
return new Response('Could not generate the invoice PDF.', 500);
}
}
}

Inject DocumentFactoryInterface and call create() in each action. This returns a fresh NextPDF\Core\Document — the concrete type the Laravel PdfResponse factories accept. Resolving a fresh document per request keeps the factory swappable in tests. Do not reuse one controller instance for two unrelated documents inside a single long-running worker process.

For very large documents, replace the buffered factory with a streamed one to bound peak memory. The streamed variant returns a StreamedResponse (Laravel and Symfony) and emits the body in fixed chunks. It deliberately omits Content-Length, so download-progress bars and length-sensitive proxies do not see a known size. Prefer the buffered download() / inline() for small, latency-sensitive responses.

Laravel: streamed download for a large report
$document = $this->documents->create();
// ... emit content onto $document ...
return PdfResponse::streamDownload($document, 'annual-report.pdf');
  • Fresh document per call. In all three integrations the document is a factory product, fresh per resolution. Do not cache a resolved document across logical documents, or across requests in a long-running worker. Stale content state carries over.
  • Empty filename. An empty filename passed to a PdfResponse factory falls back to a default name (document.pdf) rather than producing a blank disposition. Pass an explicit, meaningful filename.
  • Non-ASCII filenames. The Laravel response adds an RFC 5987 filename*= parameter automatically for non-ASCII names, and ASCII names use the plain parameter. Do not hand-encode the filename yourself.
  • Streamed responses behind a buffering proxy. A proxy that buffers the full body cancels the memory benefit of streaming. Configure the proxy to stream PDF responses, or use a buffered response on that path.
  • Symfony streamed callback. The streamed Symfony variant returns a StreamedResponse whose callback flushes output. Do not write to the response body yourself after handing it back.

Synchronous generation inside a controller blocks the request for the full PDF build. A single-page document usually stays well within a typical request budget. For multi-page or batch output, move generation off the request thread with a queued job — see Generate a PDF in a queued job. The streamed variants reduce peak memory for large documents at the cost of an unknown Content-Length. Choose them when memory is the constraint and a progress bar is not required.

  • The PdfResponse factories apply a fixed set of response-hardening headers and sanitize the download filename in every integration. Do not add those headers yourself.
  • Never interpolate unvalidated user input directly into a filename you pass to the factory. Pass a value you control, and let the factory sanitize it as a second layer.
  • In the catch block, log the exception class and a correlation identifier, not the exception message or trace. A raw trace in a log sink is an information leak.
  • Never write an empty catch block. Each example here logs and returns a defined error response.

Each integration’s security-and-operations page documents that integration’s threat model: header set, filename sanitization rules, and the document-binding lifetime.

This guide makes no normative standards claim. Every API call shown is the verified public surface of the named integration, cross-checked against each package’s quickstart and production-usage pages. The upstream production-usage pages linked under See also document the header semantics and container-binding behavior that the integrations rely on, along with their PSR citations. This cookbook page restates the usage and defers the normative citations to those pages.