WooCommerce PDF Invoices

Source

Private (Premium) — baked from the licensed zip

Purpose

Generates the PDF invoice attached to WooCommerce order emails

Special infra

None beyond the shared uploads PVC; writes PDFs to a temp dir

The invoice generator itself, by Andrew Benbow (Chrome Orange), bought from the woocommerce.com marketplace. It renders an HTML template to PDF and attaches the result to WooCommerce order emails. Branding and per-customer content are not configured here — they come from EMS PDF Invoice, which hooks this plugin’s filters.

The licence is per site

This is a paid extension. A woocommerce.com subscription covers a fixed number of sites, so a new site needs its own licence entitlement before it can be baked into that site’s image. Confirm the entitlement before adding it to a new build — it is the one part of the invoice stack that cannot be provisioned from git.

Install — hand-staged licensed artefact

Like Divi and UpdraftPlus, the zip is downloaded from the vendor account and staged by hand in the build-only packages/ directory, then extracted in the Dockerfile:

unzip -q packages/woocommerce-pdf-invoice-*.zip -d web/app/plugins   # -> web/app/plugins/woocommerce-pdf-invoice

Pin the version. The extension is ~31 MB and bundles its own dompdf and mPDF builds, so an upgrade changes the PDF renderer as well as the plugin. It also carries an undocumented markup contract that our template depends on (see below). Record the tested version in packages/README.md and re-run a render check after any bump.

Activated in bootstrap after WooCommerce, and before ems-pdf-invoice, which depends on it.

Configuration

All settings live in a single serialized option, woocommerce_pdf_invoice_settings, edited under WooCommerce → PDF Invoice. The per-customer subset worth seeding declaratively:

Option key Meaning

pdf_company_name, pdf_company_details

Header company block.

pdf_registered_name, pdf_registered_address, pdf_company_number, pdf_tax_number

Footer registration block.

logo_file

Logo, stored as an uploads URL and resolved to a filesystem path at render time — so it presupposes a media-library upload. To declare branding in git instead, use the plugin’s logo_path setting in EMS PDF Invoice.

pdf_font, pdf_date_format, pdf_filename

Typography and output naming.

pdf_termsid

Page whose content is appended as a terms-and-conditions sheet.

Two ways to corrupt live data through this option

wp option update destroys the array. The setting is one serialized array of ~44 keys. wp option update woocommerce_pdf_invoice_settings "Acme" replaces the whole array with a string and wipes every vendor setting. Use wp option patch update, which merges one key and survives a vendor upgrade that introduces new defaults:

wp option patch update woocommerce_pdf_invoice_settings pdf_company_name "Acme Events"

It errors when the option does not yet exist, so seeding must run after the plugin is activated.

Never seed the invoice-number sequence. pdf_next_number, start_number, sequential, annual_restart, padding, pdf_prefix and pdf_sufix are live state. Declaring them means every pod start resets the customer’s invoice numbering.

Template override contract

The extension resolves its template as {plugin}/templates/{filename} and filters the filename through woocommerce_pdf_invoice_filename. It also checks {active-theme}/pdf_templates/{filename} first — which is unusable here, because the theme directory is read-only in the image.

A plugin-supplied template therefore has to be reached by a relative path that climbs out of the extension’s own templates directory. That is what EMS PDF Invoice does, and why it ships a doctor command: if the traversal ever fails, the invoice still renders — just with the vendor’s stock design, which is easy to miss.

The extension’s own CSS class names (pdf_table_row_heading, pdf_orderdetails_header, pdf_table_cell, pdf_table_row_even/_odd) are part of that contract and are restyled by our template. They are internal to the vendor and undocumented, which is the main reason the version is pinned.

Storage

PDFs are written to a temp directory at render time and attached to the outgoing email; they are not retained as site content. Under a read-only root filesystem the writable /tmp emptyDir covers this — see Immutable WordPress architecture.

The dompdf font cache

The image bakes dompdf’s user font registry at build time, with docker/bake-dompdf-font-cache.php. Without it every invoice render fails a write; with the wrong version of it, invoices do not render at all. Both failure modes are worth knowing, because the second one looks like the fix for the first.

dompdf keeps two registries side by side in its font directory:

File Role

installed-fonts.dist.json

The bundled fonts, shipped by dompdf.

installed-fonts.json

The user fonts, written at runtime. Not shipped by the plugin.

There is also a third file, and it is the one that matters: dompdf_font_family_cache.php, the older format. This is where the plugin’s DejaVu families actually come from — the same families its template asks for.

FontMetrics::loadFontFamilies() reads the bundled registry, then checks whether installed-fonts.json is_readable(). When it is not — the state the plugin ships in — it does not conclude "no user fonts". It takes the legacy-migration branch, reads dompdf_font_family_cache.php, populates the font list from it, and then calls saveFontFamilies() to persist the migration. That last step does an unconditional file_put_contents() into the plugin’s own read-only directory, and fails:

PHP Warning: file_put_contents(.../woocommerce-pdf-invoice/lib/fonts//installed-fonts.json):
Failed to open stream: Read-only file system in .../dompdf/src/FontMetrics.php on line 97

The invoice still renders. The fonts were loaded; only the attempt to cache that work failed.

Do not silence this by shipping an empty {}. It is the obvious fix and it breaks PDF generation completely.

An empty file makes is_readable() true, so the loader takes the other branch: user fonts are set to an empty list and dompdf_font_family_cache.php is never consulted. The DejaVu families then do not exist and rendering dies outright:

Uncaught Dompdf\Exception: Unable to find a suitable font replacement for:
'"dejavu sans", "dejavu sans mono", "dejavu", sans-serif, monospace'

This shipped in image 1.0.9 and was caught on the dev site before production saw it. The lesson generalises: the failing write was the migration trying to persist its work, not the work itself, so suppressing the write by faking its output removes the work as well.

The build step performs the migration properly, while the filesystem is still writable — mirroring the loader’s own rule of storing a bare basename for fonts inside the font directory, so the result stays portable. It refuses to write, failing the build, if fewer than five families resolve or if dejavu sans is absent. A plugin upgrade that reshapes the legacy cache therefore stops the build rather than quietly producing an image whose invoices cannot render.

Do not mount a writable volume over the font directory either. It holds the 67 bundled font files dompdf must read, and a mount hides whatever the image put at the path — the same trap the plugins PVC has in the mutable posture.

Redirecting the path is not available: dompdf takes it from PDFFONTSPATH, which the plugin defines with a bare unguarded define(), and fontCache simply defaults to fontDir. The plugin builds its dompdf Options inline with no filter, so there is no supported hook to point the cache elsewhere.