跳转到内容

在队列任务中生成 PDF

繁重的 PDF 生成作业不应留在请求线程上。各 framework 集成都提供队列生成接口,在 worker(工作进程)上构建并保存 PDF;因此 HTTP 请求只需派发工作即可立即响应。本指南涵盖 Laravel(GeneratePdfJob)、Symfony(通过 Messenger 的 GeneratePdfMessage)与 CodeIgniter 4(GeneratePdfJob,通过 codeigniter4/queue)的队列路径。

前置条件如下:

  • 已安装 NextPDF core 与其中一个 framework 集成包。
  • 已配置 worker transport:Laravel 的 queue 连接、Symfony 的 Messenger transport,或已安装 codeigniter4/queue 的 CodeIgniter 4 queue。
  • 该 transport 已有一个 worker 进程在运行。

本指南假设你的应用程序已经具备 queue。关于 queue 或 Messenger 本身的配置,请参阅你所用 framework 的官方文档。

先安装集成包,再安装所用 framework 需要的 queue 依赖包。

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

CodeIgniter 需要 queue 包。集成包仅将它声明为开发阶段依赖,因此你需要在实际运行 worker 的应用程序中直接 require 它。

Terminal window
composer require nextpdf/codeigniter codeigniter4/queue

在 Laravel 中,于 config/nextpdf.php 中配置 queue 连接(queue.connectionqueue.queuequeue.timeout),并为该连接启动一个 worker。

三个集成用各自的方式表达同一组概念:

  • Laravel 提供 NextPDF\Laravel\Jobs\GeneratePdfJob,这是一个 ShouldQueue job。派发它时需要传入一个输出路径和一个 builder 闭包。该闭包会收到一份由容器 resolve(解析)出的 document,并返回配置完成的 document。job 会把返回的这份 document 保存到 worker 上的指定路径。它还接受可选的成功与失败回调。
  • Symfony 提供 NextPDF\Symfony\Message\GeneratePdfMessage,这是一个派发到 Messenger bus 上的 readonly 消息,并配有 GeneratePdfHandler,它会从 PSR-11 service locator 按类名解析出对应的 builder。你需要为每一种 document 类型实现 NextPDF\Symfony\Message\PdfBuilderInterface
  • CodeIgniter 4 提供 NextPDF\CodeIgniter\Jobs\GeneratePdfJob,并通过一个名称键注册到 Config\Queue::$jobHandlers 之下。你通过注册名称推送这个 job,并附带一个 builder 引用、一个输出路径与一个 context 数组。builder 是一个限定于 App\PdfBuilders namespace(命名空间)之下的静态方法。

这三者共用同一套安全立场:输出路径都会经过验证。Symfony 与 CodeIgniter 会在消费时重新验证一次,因为 payload 可能在派发后、执行前停留在 queue 中。builder 会在 worker 上针对一份全新的 document 运行,所以并发的多个 job 永远不会共用 document 状态。

关注面向LaravelSymfonyCodeIgniter 4
队列单元GeneratePdfJob(ShouldQueue)GeneratePdfMessage(DTO)+ GeneratePdfHandlerGeneratePdfJob(queue handler,队列处理器)
派发GeneratePdfJob::dispatch($path, $builder, $onSuccess, $onFailure)MessageBusInterface::dispatch(new GeneratePdfMessage(...))service('queue')->push($queue, $name, $data)
builder 形状callable(PdfDocumentInterface): PdfDocumentInterfacePdfBuilderInterface::build(Document, array): Documentstatic fn(Document, array): Document,位于 App\PdfBuilders
路径/输入防护job 会在 worker 上验证输出路径DTO 在构造时验证,handler 在消费时重新验证job 会把路径限定在 WRITEPATH/pdfs/ 之内,并以允许列表限定 builder namespace
失败接口failed()(在 tries 用尽后调用);onFailure 在终端失败时触发Messenger 重试策略;带类型的验证错误InvalidArgumentException / QueueException

以下是各 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),
);

输出路径必须以 .pdf 结尾;job 会在 worker 上先验证路径,然后写入。

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

在 CodeIgniter 中,应推送 jobHandlers 键('generate-pdf'),而不是 job 类字符串。请先在 app/Config/Queue.php 中注册这个 handler。

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

生产环境的派发会接入成功与失败回调(Laravel),或使用一个显式注册的 builder 与一个带类型的 handler(Symfony),并通过 PSR-3 logger 记录日志。下面的 Laravel 示例会同时传入两个回调进行派发。

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,
]);
},
);
}
}

成功回调会收到输出路径;失败回调会收到 Throwable。job 会先耗尽 tries(默认 3),然后才进入失败路径。要调整 timeout,请通过 nextpdf.queue.timeouttriesbackoff 值都是 public 属性,因此你可以通过子类化 GeneratePdfJob 来变更它们。

在 Symfony 中,请实现 builder,并将其注册到 service locator,使 handler 只能访问已注册的 builder。

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'

在 CodeIgniter 中,请将 builder 实现为 App\PdfBuilders 之下的一个静态方法。job 会拒绝该 namespace 以外的任何 builder 引用,也会拒绝 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;
}
}

为各 framework 启动 worker。

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

请用有界生命周期回收 Laravel 与 Symfony 的 worker(--limit / --memory-limit / --time-limit),避免某个依赖包泄漏的内存分配无上限增长。

  • builder 返回的值才是被保存的内容。 在每个集成中,worker 保存的都是 builder 返回的那份 document,而不是最初解析出来的那个实例。请务必从 builder 返回配置完成的 document。
  • 路径验证在 worker 上运行。 Symfony 会在构造时与消费时各验证一次输出路径。CodeIgniter 会把路径限定在 WRITEPATH/pdfs/ 之内,并拒绝路径穿越与同名前缀的路径。派发时安全但消费时不安全的路径,仍然会被拒绝。
  • CodeIgniter 推送的是名称,不是类。GeneratePdfJob::class 当成 job 名称推送,会在推送时被 queue 拒绝。请改为推送 jobHandlers 键。
  • Laravel 的回调必须传给静态 dispatch。 先构造一个 job 实例、再调用 $job->dispatch(...),会导致那个实例及其回调被丢弃。请把回调传给 GeneratePdfJob::dispatch(...)
  • worker 安全的注册表。 font registry 是一个锁定的、process 生命周期内的单例,image registry 则是一个有界缓存。每个 job 中的 document 都是全新的。请不要在 worker 上获取共用的 document。
  • 在 worker 中签名。 在 queue job 中生成已签名或 PDF/A 输出,需要在 worker 环境中安装商业版 NextPDF;如果未安装,签名服务会解析为 null。请在签名前先做 null 检查。

将生成作业移到 queue job 后,HTTP 请求不再承担完整的 PDF 构建时间:工作一经派发,请求即可响应。font 与 image registry 会把配置成本摊销到整个 worker 生命周期,因此每个 job 的成本只剩 document 构建与内容输出。请根据 worker pool 规模调整并发 job 数量,并预先填充 preload_fonts(Laravel、Symfony),让 font 预热只在 worker 启动时执行一次,而不是等到第一个 job 才执行。

  • 当 broker 可被访问时,queue payload 就会受到攻击者影响,因此请将 payload 中的输出路径与 builder 引用都视为不可信任。各集成会通过路径验证(在 CodeIgniter 中还加上 builder namespace 允许列表)落实这一点。
  • 请将 worker 的文件系统权限限制在预定输出目录内,作为纵深防御;这样即使某个被篡改的路径不知为何通过了验证,仍然无法逃出该目录。
  • 请在失败回调中记录异常类与一个关联标识符,绝不要记录消息本身或堆栈跟踪。
  • 绝不要编写空的 catch 块。这里的每个失败回调都会记录日志并带上 context。

完整的 queue 威胁模型(payload 验证、callable 允许列表与路径限定)记载在各集成的安全与运维页面上。

本指南不提出任何规范性标准主张。文中展示的每个 API 调用,都是对应集成中经过验证的公开接口。队列路径依赖的容器绑定保证(每次解析都得到一份全新的 document、锁定的 font registry),以及相关 PSR 引用,都记载在「另请参阅」下方链接的上游生产环境用法页面中。本 cookbook 页面只重述用法,引用信息保留在那些页面中。