from the Pdf\Invoice class to batch processing
Anyone who wants to customize an invoice PDF inevitably ends up at Pdf\AbstractPdf, Pdf\Invoice, and Zend_Pdf, not at the sales business logic. This article shows how a targeted preference instead of a full class copy extends PDF generation, how custom renderers get registered via di.xml, and how invoice PDFs can be generated performantly via CLI batch or message queue instead of synchronously in checkout.
Table of Contents
- 1. Why PDF generation is its own architecture layer
- 2. Pdf\AbstractPdf, Pdf\Invoice, and Pdf\Shipment overview
- 3. Preference vs. targeted method extension
- 4. Custom line item renderers via di.xml virtualType
- 5. Zend_Pdf\Page and Zend_Pdf\Font for custom content
- 6. Customizing logo, header, and footer
- 7. Batch generation via CLI command
- 8. Performance: synchronous in checkout vs. queue
- 9. PDF generation in direct comparison
- 10. Summary
- 11. FAQ
1. Why PDF generation is its own architecture layer
As soon as a customer requests an invoice or a shipment document, Magento does not fall back on a template system like phtml or LESS, but on a dedicated rendering layer built around Zend_Pdf. This PDF generation is cleanly separated from the actual sales business logic: Order\Invoice, Order\Shipment, and Order\Creditmemo only produce data records, which are then translated into a binary PDF document by standalone Pdf classes. Anyone who wants to design an invoice PDF individually almost always ends up in the namespace Magento\Sales\Model\Order\Pdf, not in the invoice model itself.
This separation exists for a reason: layout changes to an invoice PDF, for example an additional tax line, a QR code, or a custom header layout, should not touch the business logic of invoice creation. Anyone who mixes both risks conflicts on every Magento update in classes that are actually only supposed to calculate numbers, not draw text on a PDF page. This article deliberately stays on the rendering level: it is not about return processes, not about emailing documents, and not about the business logic behind credit memos, but exclusively about how the PDF document itself is generated.
In practice this means: anyone customizing PDF generation works with Zend_Pdf_Page, Zend_Pdf_Font, and the renderer classes under Pdf\Items, not with observers on sales_order_invoice_save_after. This distinction decides which extension technique is the right one in a given case, and that is exactly what the following sections cover in detail.
2. Pdf\AbstractPdf, Pdf\Invoice, and Pdf\Shipment overview
Magento\Sales\Model\Order\Pdf\AbstractPdf forms the foundation of the entire PDF generation. It provides protected helper methods: insertLogo() draws the store logo as a Zend_Pdf_Image onto the current page, insertAddresses() renders billing and shipping address side by side, insertOrder() outputs order number, date, and payment method. All three concrete subclasses, Pdf\Invoice, Pdf\Shipment, and Pdf\Creditmemo, inherit these methods and additionally implement the public method getPdf(), which assembles a complete Zend_Pdf document from an array of invoice or shipment objects.
Important for any customization: AbstractPdf is an abstract class with exclusively concrete, partly protected methods, not an interface. There is no PdfInvoiceInterface that could be implemented via a service contract. So anyone wanting to change something in the layout of PDF generation inevitably has to work with the concrete classes, either through inheritance or through plugins on the publicly visible methods.
Inside getPdf(), Pdf\Invoice iterates over each invoice line item and delegates drawing each line to a renderer class from the namespace Pdf\Items\Invoice. Which renderer class is responsible for which product type is not decided in getPdf() itself, but looked up via the configuration in etc/sales.xml, a detail that matters in the section on custom renderers.
3. Preference vs. targeted method extension
Since AbstractPdf, Invoice, and Shipment are concrete classes with protected methods, a plugin only reaches so far here: the Magento interceptor mechanism can only intercept public methods. insertLogo(), insertAddresses(), or the internal management of the Y coordinate are protected and therefore unreachable for plugins. Anyone who wants to change insertLogo() so the logo appears right-aligned instead of left-aligned cannot avoid a preference.
The decisive difference lies in the size of the preference. A full preference that replaces the entire Pdf\Invoice class and has to be manually reapplied on every core update is unnecessarily risky in the vast majority of cases. The more pragmatic path: a thin subclass that overrides only the one affected protected method and calls parent:: for everything else. This targeted method extension minimizes the attack surface for merge conflicts, because new core methods that Magento adds in future versions are automatically preserved.
For peripheral tasks, for example logging every generated invoice PDF or triggering an event after successful generation, a plugin on the public method getPdf() is the better choice, because getPdf() is public. The rule of thumb: preference for interventions in protected rendering details, plugin for everything that happens before or after the actual rendering. Exactly this combination, a narrow preference plus peripheral plugins, is the accepted compromise within the Magento core architecture for PDF generation.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<!-- Narrow preference: only Pdf\Invoice is replaced, not the whole Pdf subsystem -->
<preference for="Magento\Sales\Model\Order\Pdf\Invoice" type="Mironsoft\PdfCustomizer\Model\Order\Pdf\Invoice" />
<!-- Virtual type parametrizes an existing renderer instead of duplicating it -->
<virtualType name="Mironsoft\PdfCustomizer\Model\Order\Pdf\Items\Invoice\BundleWithQr" type="Mironsoft\PdfCustomizer\Model\Order\Pdf\Items\Invoice\DefaultInvoiceWithTaxBreakdown">
<arguments>
<argument name="showQrReference" xsi:type="boolean">true</argument>
</arguments>
</virtualType>
</config>
4. Custom line item renderers via di.xml virtualType
Every line of an invoice PDF is drawn by its own renderer class that inherits from Pdf\Items\AbstractItems. For simple products this is Pdf\Items\Invoice\DefaultInvoice, for bundle products there is a dedicated renderer with nested rendering. Which class is used for which product type is defined per module in etc/sales.xml, via the type attribute on the order/pdf/items/item node.
The trick here: the block attribute in sales.xml does not have to contain a hardwired class name, it can point to a virtualType from di.xml. This lets an existing renderer class be parametrized with additional constructor arguments without having to write a completely new class, for example to show a tax line or an additional fee on the invoice PDF per product type.
Inside the custom renderer class itself, you access the surrounding Zend_Pdf document via $this->getPdf() and the current vertical drawing position via $this->getPdf()->y. After calling parent::draw(), you move the Y value further down by the height of the additional line so that subsequent items are not overwritten. Exactly this pattern turns a single renderer into a reusable building block layer for PDF generation across multiple product types.
<?php
declare(strict_types=1);
namespace Mironsoft\PdfCustomizer\Model\Order\Pdf\Items\Invoice;
use Magento\Sales\Model\Order\Pdf\Items\Invoice\DefaultInvoice as CoreDefaultInvoice;
use Magento\Framework\Model\Context;
use Magento\Framework\Registry;
use Magento\Framework\Stdlib\StringUtils;
use Magento\Tax\Helper\Data as TaxHelper;
use Magento\Framework\Filter\FilterManager;
use Magento\Framework\Filesystem;
use Zend_Pdf_Page;
use Zend_Pdf_Color_Html;
/**
* Custom line item renderer that appends a tax breakdown row per item.
* Registered via etc/sales.xml as an alternative renderer for a specific product type.
*/
class DefaultInvoiceWithTaxBreakdown extends CoreDefaultInvoice
{
/**
* @param Context $context
* @param Registry $registry
* @param StringUtils $string
* @param TaxHelper $taxHelper
* @param FilterManager $filterManager
* @param Filesystem $filesystem
* @param bool $showQrReference Whether the payment reference line is rendered
* @param array $data
*/
public function __construct(
Context $context,
Registry $registry,
StringUtils $string,
TaxHelper $taxHelper,
FilterManager $filterManager,
Filesystem $filesystem,
private readonly bool $showQrReference = false,
array $data = []
) {
parent::__construct($context, $registry, $string, $taxHelper, $filterManager, $filesystem, $data);
}
/**
* Draw the default item block and append a tax breakdown line beneath it.
*
* @return Zend_Pdf_Page
*/
public function draw()
{
$page = parent::draw();
$item = $this->getItem();
$this->getPdf()->y -= 10;
$page->setFillColor(new Zend_Pdf_Color_Html('#64748b'));
$page->drawText(
sprintf('Tax rate: %s%% on %s', $item->getOrderItem()->getTaxPercent(), $item->getOrderItem()->getName()),
35,
$this->getPdf()->y,
'UTF-8'
);
if ($this->showQrReference) {
$this->getPdf()->y -= 12;
}
return $page;
}
}
5. Zend_Pdf\Page and Zend_Pdf\Font for custom content
Anyone who needs an additional line on an invoice PDF, for example a payment reference or a QR code for SEPA transfers, works directly with the Zend_Pdf primitives. Zend_Pdf_Page::drawText() draws text at exact X/Y coordinates, measured from the bottom left page margin in points, where one point equals one seventy-second of an inch. Zend_Pdf_Font::fontWithName() loads one of the built-in PDF standard fonts such as Helvetica or Courier, without needing to embed an external font file.
A common requirement is a QR code with a payment reference below the totals line. Since Zend_Pdf itself does not include QR code generation, the image is generated beforehand as a PNG, loaded via Zend_Pdf_Image::imageWithPath(), and drawn with page->drawImage() at a fixed position. The positioning must respect the dynamic Y coordinate of the surrounding rendering, otherwise the QR code overlaps with the last item line of the PDF generation.
The thin preference class for Pdf\Invoice is the right place for this addition, because getPdf() has already fully built all pages and you can insert additional text or an image at the end of each page in a targeted way, without touching the rest of the core layout. Constructor property promotion in PHP 8.4 keeps the custom class compact, even though the inherited constructor itself still expects the classic core parameter list.
<?php
declare(strict_types=1);
namespace Mironsoft\PdfCustomizer\Model\Order\Pdf;
use Magento\Sales\Model\Order\Pdf\Invoice as CoreInvoice;
use Magento\Payment\Helper\Data as PaymentHelper;
use Magento\Payment\Model\Config as PaymentConfig;
use Magento\Framework\Stdlib\StringUtils;
use Magento\Framework\Filesystem;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Filter\FilterManager;
use Magento\Sales\Model\Order\Address\Renderer as AddressRenderer;
use Magento\Sales\Model\Order\Pdf\ItemsFactory;
use Magento\Sales\Model\Order\Pdf\Total\Factory as PdfTotalFactory;
use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
use Magento\Sales\Model\Order\Pdf\Config as PdfConfig;
use Magento\Framework\Translate\Inline\StateInterface;
use Psr\Log\LoggerInterface;
use Zend_Pdf_Page;
use Zend_Pdf_Font;
/**
* Thin preference over the core invoice PDF renderer.
* Only appends a payment reference line, the core layout stays untouched.
*/
class Invoice extends CoreInvoice
{
/**
* @param PaymentConfig $paymentConfig
* @param StringUtils $string
* @param Filesystem $filesystem
* @param ScopeConfigInterface $scopeConfig
* @param FilterManager $filterManager
* @param AddressRenderer $addressRenderer
* @param ItemsFactory $pdfItemsFactory
* @param PdfTotalFactory $pdfTotalFactory
* @param TimezoneInterface $localeDate
* @param PdfConfig $pdfConfig
* @param StateInterface $inlineTranslation
* @param LoggerInterface $logger
* @param PaymentHelper $paymentHelper Custom dependency added by this preference
* @param array $data
*/
public function __construct(
PaymentConfig $paymentConfig,
StringUtils $string,
Filesystem $filesystem,
ScopeConfigInterface $scopeConfig,
FilterManager $filterManager,
AddressRenderer $addressRenderer,
ItemsFactory $pdfItemsFactory,
PdfTotalFactory $pdfTotalFactory,
TimezoneInterface $localeDate,
PdfConfig $pdfConfig,
StateInterface $inlineTranslation,
LoggerInterface $logger,
private readonly PaymentHelper $paymentHelper,
array $data = []
) {
parent::__construct(
$paymentConfig,
$string,
$filesystem,
$scopeConfig,
$filterManager,
$addressRenderer,
$pdfItemsFactory,
$pdfTotalFactory,
$localeDate,
$pdfConfig,
$inlineTranslation,
$logger,
$data
);
}
/**
* Extend core PDF generation with a payment reference line per invoice page.
*
* @param array $invoices
* @return \Zend_Pdf
*/
public function getPdf($invoices = [])
{
$pdf = parent::getPdf($invoices);
foreach ($pdf->pages as $page) {
/** @var Zend_Pdf_Page $page */
$page->setFont(Zend_Pdf_Font::fontWithName(Zend_Pdf_Font::FONT_COURIER), 8);
$page->drawText('Payment reference: ' . $this->buildPaymentReference(), 25, 25, 'UTF-8');
}
return $pdf;
}
/**
* Build a deterministic payment reference string used for the QR line.
*
* @return string
*/
private function buildPaymentReference(): string
{
return sprintf('MSFT-%s', bin2hex(random_bytes(4)));
}
}
6. Customizing logo, header, and footer
The store logo on invoice PDFs is drawn via insertLogo(), a protected method that reads the logo path from the store configuration and places it as a Zend_Pdf_Image on the first page of every invoice. A common customization: the logo should differ per store view or appear right-aligned instead of left-aligned. Both require extending insertLogo(), since the method is protected and a plugin cannot reach it.
For the footer, AbstractPdf has no dedicated insertFooter() method, instead the footer is usually drawn directly at the end of getPdf(), often with a page number and legal notices. Anyone needing an additional line here, for example a note on the payment deadline, should consistently stick to the targeted method extension and not copy the entire getPdf() method, which spans several hundred lines in the core classes.
An often overlooked point: insertLogo() is called differently per page, depending on the isLastPage parameter. Anyone who wants the logo only on the first page but a leaner header on subsequent pages must evaluate this parameter in their own override method instead of ignoring it. Exactly such details decide whether a customized PDF generation looks correct on two- or three-page invoices.
7. Batch generation via CLI command
For nightly archiving of all invoice PDFs, neither an observer nor a cron job that synchronously generates hundreds of PDFs in the PHP process is suitable, but a dedicated bin/magento CLI command. A custom command that extends Symfony\Component\Console\Command\Command takes a time range as a parameter, loads the matching invoice objects via InvoiceRepositoryInterface, and calls getPdf() for each invoice.
The advantage over a classic cron script: the CLI command can be run via crontab in the always-consistent Docker or server environment, supports options like --from and --to, and can log in detail how many invoice PDFs were processed. The command is registered classically via CommandListInterface in di.xml, not via a module-specific events.xml.
For very large data volumes, the command should work in chunks, for example two hundred invoices per batch, and clear the object cache between batches, since Zend_Pdf objects and the associated invoice collections otherwise quickly exhaust available memory. This batch strategy is the foundation for any larger PDF generation outside the request lifecycle.
<?php
declare(strict_types=1);
namespace Mironsoft\PdfCustomizer\Console\Command;
use Magento\Sales\Api\InvoiceRepositoryInterface;
use Magento\Sales\Model\Order\Pdf\Invoice as InvoicePdf;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Filesystem\DirectoryList;
use Magento\Framework\Filesystem;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Batch-generates invoice PDFs for a given date range and stores them for nightly archiving.
*/
class GenerateInvoicePdfBatch extends Command
{
private const OPTION_FROM = 'from';
private const OPTION_TO = 'to';
/**
* @param InvoiceRepositoryInterface $invoiceRepository
* @param SearchCriteriaBuilder $searchCriteriaBuilder
* @param InvoicePdf $invoicePdf
* @param Filesystem $filesystem
* @param string $name
*/
public function __construct(
private readonly InvoiceRepositoryInterface $invoiceRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
private readonly InvoicePdf $invoicePdf,
private readonly Filesystem $filesystem,
string $name = 'mironsoft:pdf:invoice-batch'
) {
parent::__construct($name);
}
/**
* Configure command name, description and CLI options.
*
* @return void
*/
protected function configure(): void
{
$this->setDescription('Generates archived invoice PDFs for a date range')
->addOption(self::OPTION_FROM, null, InputOption::VALUE_REQUIRED, 'Start date (Y-m-d)')
->addOption(self::OPTION_TO, null, InputOption::VALUE_REQUIRED, 'End date (Y-m-d)');
parent::configure();
}
/**
* Execute batch PDF generation and write archives to var/export/invoices.
*
* @param InputInterface $input
* @param OutputInterface $output
* @return int
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$criteria = $this->searchCriteriaBuilder
->addFilter('created_at', $input->getOption(self::OPTION_FROM), 'from')
->addFilter('created_at', $input->getOption(self::OPTION_TO), 'to')
->create();
$invoices = $this->invoiceRepository->getList($criteria)->getItems();
$directory = $this->filesystem->getDirectoryWrite(DirectoryList::VAR_DIR);
foreach ($invoices as $invoice) {
$pdf = $this->invoicePdf->getPdf([$invoice]);
$path = sprintf('export/invoices/invoice_%s.pdf', $invoice->getIncrementId());
$directory->writeFile($path, $pdf->render());
$output->writeln(sprintf('<info>Generated %s</info>', $path));
}
$output->writeln(sprintf('<info>%d invoice PDFs archived</info>', count($invoices)));
return Command::SUCCESS;
}
}
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<!-- Register the batch command in the bin/magento command list -->
<type name="Magento\Framework\Console\CommandListInterface">
<arguments>
<argument name="commands" xsi:type="array">
<item name="pdf_invoice_batch" xsi:type="object">Mironsoft\PdfCustomizer\Console\Command\GenerateInvoicePdfBatch</item>
</argument>
</arguments>
</type>
</config>
8. Performance: synchronous in checkout vs. queue
PDF generation during checkout, directly when the order is saved, is one of the most common performance mistakes in Magento projects. Zend_Pdf is not a lightweight library: building a multi-page document with several renderer calls can take several hundred milliseconds depending on the number of line items, time the customer waits synchronously in the checkout flow, even though the PDF file is not needed at all at that moment.
The better approach: a plugin or observer on the invoice trigger only publishes a message on a message queue, for example with the invoice ID as payload. A separate consumer processes this message asynchronously, generates the invoice PDF, and stores it in the filesystem or an external storage. Checkout itself stays completely unburdened by the actual PDF generation.
This decoupling pays off especially during load spikes, for example during a sale: instead of hundreds of parallel checkout processes simultaneously triggering PDF generation and blocking the PHP-FPM pool, one or more queue consumers process the requests in a controlled, sequential manner, with clearly configurable parallelism via the number of consumer instances.
9. PDF generation in direct comparison
Not every customization to PDF generation needs the same extension technique. The following table summarizes the typical decision points that repeatedly lead to wrong, because overly invasive, solutions in projects.
| Situation | Not recommended | Recommended approach | Benefit |
|---|---|---|---|
| Changing a protected method (insertLogo) | Full preference on Pdf\Invoice | Targeted method extension | Fewer merge conflicts on core updates |
| Additional line per product type | Duplicate the entire renderer class | virtualType parametrization in di.xml | No duplicated code |
| PDF generation in checkout | Synchronous and blocking | Asynchronous via message queue | No latency for the customer |
| Nightly bulk export | Manual script without chunking | CLI command with batch processing | Controlled resource usage |
| Logging the generation | Preference just for logging | Plugin on public getPdf() | Update-safe, minimally invasive |
In modern Magento projects, the combination of a narrow preference, virtualType parametrization, and plugins for peripheral logic is the constellation that holds up best against future core updates. Anyone who copies entire classes instead has to manually reapply every security update, often without noticing that the signature of an inherited method has changed.
10. Summary
PDF generation in Magento 2 follows a clear architecture: Pdf\AbstractPdf provides protected building blocks, Pdf\Invoice and Pdf\Shipment implement concrete documents from them, and Pdf\Items renderers draw individual line items. Because these classes are concrete and partly protected, any deeper customization needs a targeted preference instead of a plugin, while peripheral logic such as logging or event triggers continues to run through plugins on public methods.
Anyone extending an invoice PDF with custom content draws directly onto the finished document with Zend_Pdf_Page and Zend_Pdf_Font, registers custom line item renderers via virtualType in di.xml, and avoids copying the entire getPdf() method. For scale and performance the rule is: batch generation belongs in a CLI command with chunking, not in a cron script, and synchronous PDF generation in checkout should consistently be offloaded to a message queue.
PDF generation in Magento 2, the essentials at a glance
Targeted preference
Override only the one affected protected method, delegate the rest to the core with parent::. Minimizes merge conflicts on updates.
Renderer via virtualType
Parametrize line item renderers via etc/sales.xml and di.xml virtualType instead of duplicating entire classes.
Use Zend_Pdf directly
Zend_Pdf_Page and Zend_Pdf_Font for QR codes, payment references, and custom text positions on invoice PDFs.
Batch instead of synchronous
CLI commands for nightly archiving, message queue instead of synchronous PDF generation in checkout.
11. FAQ: PDF Generation in Magento 2
1Why isn't a plugin enough for insertLogo?
2Pdf\AbstractPdf vs. Pdf\Invoice?
3Adding a QR code with a payment reference?
4Registering a custom renderer?
5Synchronous or via queue?
6Generating many invoice PDFs in bulk?
7Relevant Zend_Pdf classes?
8Performance for large-scale PDF generation?
9Multiple preferences for the same class?
10Testing PDF generation without a real checkout?
Mironsoft
Magento 2 development, sales customizations, and PDF rendering
Invoice PDFs that fit your process?
We analyze your existing PDF generation, design a targeted preference strategy, and implement custom renderers, CLI batch exports, or queue-based processing for invoice PDFs in the Magento sales module.
PDF audit
Analyze existing preferences and renderers for unnecessary attack surface
Custom rendering
QR codes, tax lines, and custom layouts for invoice PDFs
Batch & queue
CLI commands and queue consumers for scalable PDF generation