跳到內容

在 queue job 中產生 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,或 CodeIgniter 4 已安裝 codeigniter4/queue 的 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
佇列單元GeneratePdfJobShouldQueueGeneratePdfMessage(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 則是一個有界的快取。document 在每個 job 都是全新的。請不要在 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 頁面只重述用法,引用則保留在那些頁面中。