← Back to blog

Magento 2 Invoice PDF Customization: Totals, Items and Layout

Rodrigo Biazzi

The default Magento invoice PDF is functional and plain. Sooner or later someone asks for a different layout, a legal text in the footer, a custom total, the SKU in a different place, or a font that prints their language correctly. The PDF code is old, written on top of Zend_Pdf with absolute coordinates, and not built for easy extension. Still, most changes can be done cleanly if you know which extension point to use.

This article covers invoice PDF customization in Magento Open Source and Adobe Commerce 2.4.8, from the configuration based options to code changes, and when it makes more sense to replace the PDF generation completely.

Where the PDF comes from

The invoice PDF is built by Magento\Sales\Model\Order\Pdf\Invoice, which extends Magento\Sales\Model\Order\Pdf\AbstractPdf. Its getPdf() method receives a list of invoices and returns a Zend_Pdf object. The admin controllers for a single invoice and for the mass "Print Invoices" action call that method and send the result of render() to the browser.

Inside getPdf(), for each invoice it creates a page, inserts the logo and the store address, the order information and addresses, the items table header, each item through a renderer, and finally the totals. The methods doing this live in AbstractPdf: insertLogo(), insertAddress(), insertOrder(), insertTotals() and drawLineBlocks().

Two details are useful to know before you start:

  • Most of those methods are protected. Plugins cannot intercept protected methods, so layout changes need a class that extends the PDF model.
  • The customer account "Print Invoice" link on the storefront is an HTML print page, not this PDF. Customizing one does not change the other.

Start with configuration

Before writing code, check what the admin already offers under Stores, Configuration, Sales, Sales, Invoice and Packing Slip Design:

  • Logo for PDF Print-outs. A separate logo for PDFs. Use a high resolution image with the proportions you want. It is scaled to fit a fixed area.
  • Address. Free text printed at the top right. Many merchants put the company registration or tax number here, which avoids code for the most common request.

Also under Sales, PDF Print-outs, you can choose to display the order id in the header of invoices, shipments and credit memos. These settings work per website or store view, so multi store setups can have different logos and addresses.

Adding a total with pdf.xml

Totals on the PDF are declared in etc/pdf.xml files and merged across modules. Each total reads a field from the invoice and prints it with a label. If you added a fee as described in custom fee totals collectors, this is how it gets onto the PDF:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Sales:etc/pdf_file.xsd">
    <totals>
        <total name="handling_fee">
            <title translate="true">Handling Fee</title>
            <source_field>handling_fee_amount</source_field>
            <font_size>7</font_size>
            <display_zero>false</display_zero>
            <sort_order>350</sort_order>
        </total>
    </totals>
</config>

The default total model calls getDataUsingMethod() on the source document with the source_field. For the invoice PDF, the source is the invoice, not the order. A fee column that exists only on sales_order prints nothing. It must be on sales_invoice too, which is also needed for correct invoice totals anyway.

The sort order is compared with the core totals declared in Magento_Sales/etc/pdf.xml, Magento_Tax/etc/pdf.xml and others. Read those files in your version and choose a number that puts your line where you want it. For a total that needs custom logic, for example a value computed from several fields, add a model node pointing to a class that extends Magento\Sales\Model\Order\Pdf\Total\DefaultTotal and override getTotalsForDisplay().

Changing how items are drawn

Items are drawn by renderers, also declared in pdf.xml, per document type and product type:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Sales:etc/pdf_file.xsd">
    <renderers>
        <page type="invoice">
            <renderer product_type="default">Vendor\Pdf\Model\Order\Pdf\Items\Invoice\DefaultInvoice</renderer>
        </page>
    </renderers>
</config>

Your class extends Magento\Sales\Model\Order\Pdf\Items\Invoice\DefaultInvoice and overrides draw(). The core draw() builds an array of lines, where each column is an entry with the text, the x position (feed), the font and the alignment, and then passes it to drawLineBlocks(), which handles page breaks. The easiest way to customize it is to copy the core method into your class and change the columns, for example to add a product attribute under the name or to wrap long SKUs differently.

Make your module depend on Magento_Sales in module.xml so your pdf.xml is merged after the core one and your renderer wins. Bundle products have their own renderer in Magento_Bundle, so if you sell bundles, check that renderer too.

If you change column positions, also change the table header. It is drawn by _drawHeader() in the invoice model, which is protected, so that brings us to the next section.

Layout changes with a preference

For changes to the header, the table header or the footer, you need your own class extending the invoice PDF model, registered as a preference in etc/di.xml:

<preference for="Magento\Sales\Model\Order\Pdf\Invoice" type="Vendor\Pdf\Model\Order\Pdf\Invoice"/>

Here is a small example that adds a footer text from configuration to every page, after the core has drawn everything else:

<?php
declare(strict_types=1);

namespace Vendor\Pdf\Model\Order\Pdf;

use Magento\Sales\Model\Order\Pdf\Invoice as CoreInvoice;
use Magento\Store\Model\ScopeInterface;

class Invoice extends CoreInvoice
{
    public function getPdf($invoices = [])
    {
        $pdf = parent::getPdf($invoices);

        $footer = (string) $this->_scopeConfig->getValue(
            'vendor_pdf/invoice/footer',
            ScopeInterface::SCOPE_STORE
        );

        if ($footer === '') {
            return $pdf;
        }

        foreach ($pdf->pages as $page) {
            $this->_setFontRegular($page, 7);
            $page->drawText($footer, 25, 20, 'UTF-8');
        }

        return $pdf;
    }
}

This uses only the public result of getPdf() and two protected helpers from the parent, so it survives upgrades better than a copied getPdf(). When an invoice list mixes stores, read the config with the store id of each invoice instead of the current scope. The example keeps it simple.

A preference replaces the class for the whole system, and only one module can win. If an installed extension already has a preference for the same class, yours and theirs conflict. Run bin/magento dev:di:info "Magento\Sales\Model\Order\Pdf\Invoice" to see what is currently configured. The trade-offs are covered in plugins vs observers vs preferences.

Fonts and special characters

The PDF models embed TrueType fonts shipped in lib/internal. They cover Latin, Cyrillic and Greek well. For languages they do not cover, characters print as empty boxes or disappear. The fix is to override the font helpers _setFontRegular(), _setFontBold() and _setFontItalic() in your preference and load a font that covers the script with Zend_Pdf_Font::fontWithPath(). Fonts for Asian scripts are large and make each PDF much heavier, so test a mass print of many invoices.

Zend_Pdf also does not wrap text by itself. Long product names and addresses are split by Magento's string helper with fixed character counts. If a change makes text overlap, the split length is usually what needs adjusting.

When to replace the PDF engine

If the design calls for a completely different layout, coordinates in PHP become slow to work with. The alternative is to render an HTML template and convert it with a library like Dompdf or mPDF installed through Composer. It is easier to style and to maintain, but consider:

  • The admin controllers call render() on the object returned by getPdf(). You need to replace those controllers, or the routes, to return your output.
  • HTML to PDF libraries use more memory and CPU. Mass printing a few hundred invoices can hit PHP limits.
  • Other places may use the PDF model, like extensions that attach invoices to emails. Check them all.

For most stores, a renderer, a few totals and a preference for header and footer are enough, with less risk.

FAQ

Why does my custom total not show on the invoice PDF?

The source_field is read from the invoice. Make sure the value is stored in a sales_invoice column, the module that declares pdf.xml is enabled, and display_zero is not hiding a zero value.

Can I use a plugin instead of a preference for the PDF layout?

Only for public methods like getPdf. Most drawing methods are protected, so changes to header, table and addresses need a preference or a custom renderer.

Does changing the PDF change the invoice print in the customer account?

No. The storefront print page is HTML rendered by layout and templates. Customize it separately in your theme.

Why do some characters print as boxes?

The default fonts do not cover every script. Load a font that covers your language in the font helper methods of your PDF class.

I am available for freelance Magento and Adobe Commerce work, including sales documents and PDFs. If you need help with this, get in touch.

Related articles