Skip to content

Generate a PDF in a queued job

Heavy PDF generation should not run on the request thread. Each framework integration gives you a queued-generation API that builds and saves a PDF on a worker. The HTTP request can return as soon as you dispatch the work. This guide covers the queued path for Laravel (GeneratePdfJob), Symfony (GeneratePdfMessage over Messenger), and CodeIgniter 4 (GeneratePdfJob through codeigniter4/queue).

The prerequisites are:

  • NextPDF core and one framework integration are installed.
  • A worker transport is configured: a Laravel queue connection, a Symfony Messenger transport, or a CodeIgniter 4 queue with codeigniter4/queue installed.
  • A worker process is running for that transport.

This guide assumes your application already has a queue. For queue or Messenger setup, use your framework’s own documentation.

Install the integration, then install the queue dependency your framework needs.

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

CodeIgniter needs the queue package. The integration declares it as a development-only dependency, so require it in the application that runs workers.

Terminal window
composer require nextpdf/codeigniter codeigniter4/queue

For Laravel, configure the queue connection in config/nextpdf.php (queue.connection, queue.queue, queue.timeout), then run a worker for that connection.

Each integration uses the same pattern in its own framework style:

  • Laravel ships NextPDF\Laravel\Jobs\GeneratePdfJob, a ShouldQueue job. You dispatch it with an output path and a builder closure. The closure gets a container-resolved document and returns the configured document. On the worker, the job saves the returned document to the path. It also accepts optional success and failure callbacks.
  • Symfony ships NextPDF\Symfony\Message\GeneratePdfMessage, a readonly message dispatched on the Messenger bus, plus GeneratePdfHandler. The handler resolves a builder by class name from a PSR-11 service locator. You implement NextPDF\Symfony\Message\PdfBuilderInterface for each document type.
  • CodeIgniter 4 ships NextPDF\CodeIgniter\Jobs\GeneratePdfJob, registered under a name key in Config\Queue::$jobHandlers. You push the job by its registered name with a builder reference, an output path, and a context array. The builder is a static method limited to the App\PdfBuilders namespace.

All three integrations share one security stance: they validate the output path. Symfony and CodeIgniter re-validate it at consume time, because a payload can wait in a queue between dispatch and execution. The builder runs against a fresh document on the worker, so concurrent jobs never share document state.

ConcernLaravelSymfonyCodeIgniter 4
Queued unitGeneratePdfJob (ShouldQueue)GeneratePdfMessage (DTO) + GeneratePdfHandlerGeneratePdfJob (queue handler)
DispatchGeneratePdfJob::dispatch($path, $builder, $onSuccess, $onFailure)MessageBusInterface::dispatch(new GeneratePdfMessage(...))service('queue')->push($queue, $name, $data)
Builder shapecallable(PdfDocumentInterface): PdfDocumentInterfacePdfBuilderInterface::build(Document, array): Documentstatic fn(Document, array): Document under App\PdfBuilders
Path / input guardJob validates the output path on the workerDTO validates at construction, handler re-validates at consumeJob confines path to WRITEPATH/pdfs/, allowlists builder namespace
Failure surfacefailed() after tries; onFailure on terminal failureMessenger retry strategy; typed validation errorsInvalidArgumentException / QueueException

Use this minimal dispatch in each framework.

Laravel: dispatch GeneratePdfJob
<?php
declare(strict_types=1);
use NextPDF\Contracts\PdfDocumentInterface;
use NextPDF\Laravel\Jobs\GeneratePdfJob;
GeneratePdfJob::dispatch(
storage_path('app/reports/january-2026.pdf'),
static fn (PdfDocumentInterface $document): PdfDocumentInterface => $document
->addPage()
->cell(0, 10, 'January report', newLine: true),
);

The output path must end in .pdf; the job validates the path on the worker before it writes the file.

Symfony: dispatch GeneratePdfMessage from a controller
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Pdf\InvoicePdfBuilder;
use NextPDF\Symfony\Message\GeneratePdfMessage;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
final class ReportController
{
#[Route('/invoice/{id}/queue', name: 'invoice_queue')]
public function queue(MessageBusInterface $bus, int $id): Response
{
$bus->dispatch(new GeneratePdfMessage(
builderClass: InvoicePdfBuilder::class,
outputPath: '/var/storage/invoices/' . $id . '.pdf',
builderContext: ['invoice_id' => $id],
));
return new Response('PDF generation queued.', 202);
}
}
CodeIgniter 4: push GeneratePdfJob by its registered name
<?php
declare(strict_types=1);
namespace App\Controllers;
use CodeIgniter\HTTP\ResponseInterface;
final class InvoiceController extends BaseController
{
public function queueInvoice(int $id): ResponseInterface
{
service('queue')->push('pdf-queue', 'generate-pdf', [
'builder' => 'App\\PdfBuilders\\InvoiceBuilder::build',
'outputPath' => WRITEPATH . 'pdfs/invoice-' . $id . '.pdf',
'context' => ['invoice_id' => $id],
]);
return $this->response
->setStatusCode(ResponseInterface::HTTP_ACCEPTED)
->setJSON(['status' => 'queued', 'invoice_id' => $id]);
}
}

In CodeIgniter, push the jobHandlers key ('generate-pdf'), not the job class string. Register the handler first in app/Config/Queue.php.

CodeIgniter 4: app/Config/Queue.php
<?php
declare(strict_types=1);
namespace Config;
use CodeIgniter\Queue\Config\Queue as BaseQueue;
use NextPDF\CodeIgniter\Jobs\GeneratePdfJob;
final class Queue extends BaseQueue
{
/** @var array<string, class-string> */
public array $jobHandlers = [
'generate-pdf' => GeneratePdfJob::class,
];
}

A production dispatch wires success and failure callbacks (Laravel), or an explicitly registered builder and a typed handler (Symfony), into a PSR-3 logger. The Laravel example below dispatches with both callbacks.

Laravel: app/Jobs/DispatchMonthlyStatement.php
<?php
declare(strict_types=1);
namespace App\Jobs;
use NextPDF\Contracts\PdfDocumentInterface;
use NextPDF\Laravel\Jobs\GeneratePdfJob;
use Psr\Log\LoggerInterface;
use Throwable;
final class DispatchMonthlyStatement
{
public function __construct(private readonly LoggerInterface $logger) {}
public function __invoke(int $accountId): void
{
// dispatch() is public static: it constructs the job from the
// arguments it receives. Pass every argument — including the
// callbacks — to the static call, not to a separately built instance.
GeneratePdfJob::dispatch(
storage_path("app/statements/{$accountId}.pdf"),
static fn (PdfDocumentInterface $document): PdfDocumentInterface => $document
->addPage()
->cell(0, 10, "Statement for account {$accountId}", newLine: true),
function (string $path) use ($accountId): void {
$this->logger->info('Statement PDF written', [
'account_id' => $accountId,
'path' => $path,
]);
},
function (Throwable $exception) use ($accountId): void {
$this->logger->error('Statement PDF failed', [
'account_id' => $accountId,
'exception' => $exception::class,
]);
},
);
}
}

The success callback receives the output path. The failure callback receives the Throwable. The job exhausts tries (default 3) before the failure path runs. Tune timeout through nextpdf.queue.timeout. The tries and backoff values are public properties, so subclass GeneratePdfJob to change them.

For Symfony, implement the builder and register it in a service locator. That keeps the handler limited to registered builders.

Symfony: src/Pdf/InvoicePdfBuilder.php
<?php
declare(strict_types=1);
namespace App\Pdf;
use NextPDF\Core\Document;
use NextPDF\Symfony\Message\PdfBuilderInterface;
final class InvoicePdfBuilder implements PdfBuilderInterface
{
/** @param array<string, mixed> $context */
public function build(Document $document, array $context): Document
{
$document->addPage();
$document->setFont('dejavusans', '', 12);
$document->cell(0, 10, 'Invoice #' . $context['invoice_id']);
return $document;
}
}
Symfony: config/services.yaml (builder locator)
services:
App\Pdf\InvoicePdfBuilder: ~
nextpdf.pdf_builder_locator:
class: Symfony\Component\DependencyInjection\ServiceLocator
arguments:
- 'App\Pdf\InvoicePdfBuilder': '@App\Pdf\InvoicePdfBuilder'
tags: ['container.service_locator']
NextPDF\Symfony\Message\GeneratePdfHandler:
arguments:
$builderLocator: '@nextpdf.pdf_builder_locator'

For CodeIgniter, implement the builder as a static method under App\PdfBuilders. The job rejects any builder reference outside that namespace and any output path outside WRITEPATH/pdfs/.

CodeIgniter 4: app/PdfBuilders/InvoiceBuilder.php
<?php
declare(strict_types=1);
namespace App\PdfBuilders;
use NextPDF\Core\Document;
final class InvoiceBuilder
{
/** @param array<string, mixed> $context */
public static function build(Document $document, array $context): Document
{
$invoiceId = (int) ($context['invoice_id'] ?? 0);
$document->addPage();
$document->cell(0, 10, "Invoice #{$invoiceId}");
return $document;
}
}

Run the worker for each framework.

Terminal window
php bin/console messenger:consume async --limit=200 --memory-limit=256M --time-limit=3600
Terminal window
php spark queue:work pdf-queue

Recycle Laravel and Symfony workers with bounded lifetimes (--limit / --memory-limit / --time-limit) so a leaked allocation in a dependency cannot grow without bound.

  • The builder return value is what gets saved. In every integration, the worker saves the document the builder returns, not the instance it originally resolved. Always return the configured document from the builder.
  • Path validation runs on the worker. Symfony validates the output path at construction and again at consume time. CodeIgniter confines the path to WRITEPATH/pdfs/ and rejects traversal and sibling-prefix paths. A path that was safe at dispatch but unsafe at consume is still rejected.
  • CodeIgniter pushes the name, not the class. If you push GeneratePdfJob::class as the job name, the queue rejects it at push time. Push the jobHandlers key instead.
  • Laravel callbacks must be passed to the static dispatch. If you build a job instance and then call $job->dispatch(...), that call discards the instance and its callbacks. Pass the callbacks to GeneratePdfJob::dispatch(...).
  • Worker-safe registries. The font registry is a locked process-lifetime singleton, and the image registry is a bounded cache. Documents are fresh per job. Do not request a shared document on the worker.
  • Signing in workers. Signed or PDF/A output in a queue job requires a commercial NextPDF edition installed in the worker environment. Without it, the signing service resolves to null. Null-check before signing.

Moving generation to a queued job removes the full PDF build time from the HTTP request. The request returns once the work is dispatched. The font and image registries amortize their setup cost across the worker lifetime, so the per-job cost is limited to document construction and content emission. Size the number of in-flight jobs to your worker pool, and pre-populate preload_fonts (Laravel, Symfony) so font warmup happens once at worker boot rather than on the first job.

  • Queue payloads are attacker-influenced when the broker is reachable, so treat the output path and builder reference in a payload as untrusted. The integrations enforce this with path validation and, in CodeIgniter, a builder namespace allowlist.
  • Restrict the worker filesystem permissions to the intended output directory as defense in depth. If a tampered path somehow passes validation, it still cannot escape the directory.
  • Log the exception class and a correlation identifier in the failure callback, never the message or trace.
  • Never write an empty catch block. Every failure callback here logs and carries context.

Each integration’s security-and-operations page covers the full queue threat model: payload validation, callable allowlists, and path confinement.

This guide makes no normative standards claim. Every API call shown is the verified public surface of the named integration. The queued path relies on container-binding guarantees: a fresh document per resolution and the locked font registry. The upstream production-usage pages linked under See also document those guarantees with their PSR citations. This cookbook page restates the usage and defers the citations to those pages.