Перейти к содержимому

Генерация PDF в задаче очереди

Ресурсоёмкая генерация PDF не должна выполняться в потоке запроса. Каждая интеграция с фреймворком предоставляет API для генерации через очередь: PDF строится и сохраняется на воркере. HTTP-запрос может завершиться сразу после того, как вы поставите задачу в очередь. В этом руководстве рассматривается вариант с очередью для Laravel (GeneratePdfJob), Symfony (GeneratePdfMessage через Messenger) и CodeIgniter 4 (GeneratePdfJob через codeigniter4/queue).

Предварительные требования:

  • Установлены ядро NextPDF и одна из интеграций с фреймворком.
  • Настроен транспорт воркера: подключение очереди Laravel, транспорт Symfony Messenger или очередь CodeIgniter 4 с установленным codeigniter4/queue.
  • Для этого транспорта запущен процесс воркера.

В этом руководстве предполагается, что в вашем приложении уже настроена очередь. Для настройки очереди или Messenger используйте документацию вашего фреймворка.

Установите интеграцию, затем зависимость очереди, необходимую вашему фреймворку.

Окно терминала
composer require nextpdf/laravel
Окно терминала
composer require nextpdf/symfony symfony/messenger

Для CodeIgniter нужен пакет очереди. Интеграция объявляет его как зависимость только для разработки, поэтому подключите его в приложении, где запускаются воркеры.

Окно терминала
composer require nextpdf/codeigniter codeigniter4/queue

В Laravel настройте подключение очереди в config/nextpdf.php (queue.connection, queue.queue, queue.timeout), затем запустите воркер для этого подключения.

Каждая интеграция использует общий подход в стиле своего фреймворка:

  • Laravel предоставляет NextPDF\Laravel\Jobs\GeneratePdfJob — задачу ShouldQueue. Вы ставите её в очередь, передавая выходной путь и замыкание-построитель. Замыкание получает документ, разрешённый контейнером, и возвращает настроенный документ. На воркере задача сохраняет возвращённый документ по указанному пути. Она также принимает необязательные колбэки успеха и сбоя.
  • Symfony предоставляет NextPDF\Symfony\Message\GeneratePdfMessagereadonly-сообщение для отправки в шину Messenger, а также GeneratePdfHandler. Обработчик разрешает построитель по имени класса из локатора сервисов PSR-11. Вы реализуете NextPDF\Symfony\Message\PdfBuilderInterface для каждого типа документа.
  • CodeIgniter 4 предоставляет NextPDF\CodeIgniter\Jobs\GeneratePdfJob, зарегистрированный под именем-ключом в Config\Queue::$jobHandlers. Вы помещаете задачу по её зарегистрированному имени, передавая ссылку на построитель, выходной путь и массив контекста. Построитель — это статический метод, ограниченный пространством имён App\PdfBuilders.

У всех трёх интеграций единый подход к безопасности: они проверяют выходной путь. Symfony и CodeIgniter повторно проверяют его при потреблении, поскольку полезная нагрузка может ожидать в очереди между постановкой в очередь и выполнением. На воркере построитель работает с новым документом, поэтому параллельные задачи никогда не используют общее состояние документа.

АспектLaravelSymfonyCodeIgniter 4
Единица очередиGeneratePdfJob (ShouldQueue)GeneratePdfMessage (DTO) + GeneratePdfHandlerGeneratePdfJob (обработчик очереди)
Постановка в очередьGeneratePdfJob::dispatch($path, $builder, $onSuccess, $onFailure)MessageBusInterface::dispatch(new GeneratePdfMessage(...))service('queue')->push($queue, $name, $data)
Форма построителяcallable(PdfDocumentInterface): PdfDocumentInterfacePdfBuilderInterface::build(Document, array): Documentstatic fn(Document, array): Document в App\PdfBuilders
Защита пути / входных данныхЗадача проверяет выходной путь на воркереDTO проверяет путь при создании, обработчик повторно проверяет его при потребленииЗадача ограничивает путь каталогом WRITEPATH/pdfs/ и применяет список разрешённых пространств имён построителей
Обработка сбоевfailed() после исчерпания tries; onFailure при окончательном сбоеСтратегия повторов Messenger; типизированные ошибки проверкиInvalidArgumentException / QueueException

В каждом фреймворке можно использовать такую минимальную постановку в очередь.

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; задача проверяет путь на воркере перед записью файла.

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'), а не строку класса задачи. Сначала зарегистрируйте обработчик в 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,
];
}

В продакшене постановка в очередь связывает с логгером PSR-3 колбэки успеха и сбоя (Laravel) либо явно зарегистрированный построитель и типизированный обработчик (Symfony). Ниже пример для 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. Сначала задача исчерпывает tries (по умолчанию 3), и только потом запускается обработка сбоя. Настройте timeout через nextpdf.queue.timeout. Значения tries и backoff — публичные свойства, поэтому создайте подкласс GeneratePdfJob, чтобы изменить их.

Для Symfony реализуйте построитель и зарегистрируйте его в локаторе сервисов. Так обработчику будут доступны только зарегистрированные построители.

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 реализуйте построитель как статический метод в App\PdfBuilders. Задача отклоняет любую ссылку на построитель вне этого пространства имён и любой выходной путь вне 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;
}
}

Запустите воркер для каждого фреймворка.

Окно терминала
php bin/console messenger:consume async --limit=200 --memory-limit=256M --time-limit=3600
Окно терминала
php spark queue:work pdf-queue

Перезапускайте воркеры Laravel и Symfony с ограниченным временем жизни (--limit / --memory-limit / --time-limit), чтобы возможная утечка памяти в зависимости не могла расти бесконечно.

  • Сохраняется именно возвращаемое значение построителя. В каждой интеграции воркер сохраняет документ, который возвращает построитель, а не экземпляр, изначально разрешённый для него. Всегда возвращайте настроенный документ из построителя.
  • Проверка пути выполняется на воркере. Symfony проверяет выходной путь при создании и ещё раз при потреблении. CodeIgniter ограничивает путь каталогом WRITEPATH/pdfs/ и отклоняет пути с обходом каталогов и пути с совпадающим префиксом соседнего каталога. Путь, который был безопасным при постановке, но небезопасным при потреблении, всё равно отклоняется.
  • CodeIgniter помещает имя, а не класс. Если вы передадите GeneratePdfJob::class в качестве имени задачи, очередь отклонит его при постановке в очередь. Вместо этого передавайте ключ из jobHandlers.
  • Колбэки Laravel необходимо передавать в статический вызов dispatch. Если вы создадите экземпляр задачи, а затем вызовете $job->dispatch(...), этот вызов отбросит экземпляр и его колбэки. Передавайте колбэки в GeneratePdfJob::dispatch(...).
  • Безопасные для воркеров реестры. Реестр шрифтов — это заблокированный синглтон на время жизни процесса, а реестр изображений — кэш ограниченного размера. Документы создаются заново для каждой задачи. Не запрашивайте общий документ на воркере.
  • Подписание в воркерах. Для подписанного вывода или вывода PDF/A в задаче очереди требуется коммерческая редакция NextPDF, установленная в среде воркера. Без неё сервис подписания разрешается в null. Перед подписанием проверяйте его на null.

Перенос генерации в задачу очереди убирает всё время сборки PDF из HTTP-запроса. Запрос завершается, как только задача поставлена в очередь. Реестры шрифтов и изображений распределяют затраты на инициализацию по всему времени жизни воркера, поэтому затраты на одну задачу ограничиваются построением документа и выводом содержимого. Подбирайте число одновременно выполняемых задач под ваш пул воркеров и предварительно заполняйте preload_fonts (Laravel, Symfony), чтобы прогрев шрифтов происходил один раз при запуске воркера, а не при первой задаче.

  • Если брокер доступен злоумышленнику, он может влиять на полезные нагрузки очереди, поэтому считайте выходной путь и ссылку на построитель в полезной нагрузке недоверенными. Интеграции обеспечивают это проверкой пути и, в CodeIgniter, списком разрешённых пространств имён построителей.
  • Ограничьте права воркера в файловой системе ожидаемым каталогом вывода по принципу эшелонированной защиты. Если подделанный путь каким-то образом пройдёт проверку, он всё равно не сможет выйти за пределы каталога.
  • В колбэке сбоя записывайте в журнал класс исключения и идентификатор корреляции, но никогда — сообщение или трассировку.
  • Никогда не пишите пустой блок catch. Каждый колбэк сбоя здесь ведёт журнал и сохраняет контекст.

Страница по безопасности и эксплуатации для каждой интеграции охватывает полную модель угроз очереди: проверку полезной нагрузки, списки разрешённых вызываемых объектов и ограничение пути.

Это руководство не делает заявлений о соответствии нормативным стандартам. Каждый показанный вызов API — это проверенная публичная поверхность названной интеграции. Вариант с очередью полагается на гарантии привязки контейнера: новый документ при каждом разрешении и заблокированный реестр шрифтов. Страницы по использованию в продакшене, на которые есть ссылки в разделе “См. также”, документируют эти гарантии со своими ссылками на PSR. Эта страница сборника рецептов кратко повторяет сценарий использования и отсылает за ссылками к тем страницам.