Provision fonts in production
At a glance
Section titled “At a glance”Your PDF renders correctly on your laptop, then ships to a container and comes out as a row of empty boxes — the “tofu” glyph — or with missing accents and non-Latin characters. The cause is almost always the same: the font you selected is not present in the deployed image.
The native, in-process NextPDF engine resolves fonts from font files that
the font registry can read. It does not discover OS or fontconfig fonts
automatically — OS-installed font files only help if you explicitly register
those files or add their containing directory to the FontRegistry search path.
A container built from a slim base image has no apt/apk-installed fonts, and
even when it does, the native engine ignores them unless you point the registry
at their files. The fix is to bundle the actual font files inside your
application or image and register them with the engine. The registry reads
TrueType (.ttf), OpenType (.otf), and TrueType Collection (.ttc) files;
legacy Type1 (.pfb) is also accepted but rarely needed for new work.
Before you start, confirm these pieces are in place:
- NextPDF core is installed.
- You have the actual font files you intend to use, and you are licensed to embed them. Embedding rights are your responsibility — see Embed and subset a TrueType font.
- Your build can copy those files into the deployed artifact.
This is an operations how-to. The code is minimal; the work is in the build and the filesystem layout. For the API-level mechanics of registering and subsetting a single face, read the embed-and-subset recipe linked above. This page covers getting the files onto the box and pointing the engine at them.
Why the native engine does not find OS fonts automatically
Section titled “Why the native engine does not find OS fonts automatically”There are two distinct rendering paths, and the font story differs between them.
- Native in-process engine (the default,
Document/writeHtml): the engine does not call into the operating system’s font system orfontconfigfor discovery. It resolves a face through the font registry, which reads a specific font file you registered or finds one inside a directory you configured as a search path. Installing a font withapt-get install fonts-notoor runningfc-cachedoes nothing on its own — the native engine sees those files only if you register them or add their directory to the registry’s search path. - Chrome bridge (the HTML-to-PDF renderer that drives a headless browser):
this path does use the host’s installed fonts through the browser’s normal
font discovery, so
apt/apkfont packages andfontconfigmatter there.
If you read general “install these system font packages in your Dockerfile” guidance, it applies to the Chrome bridge, not to the native engine covered on this page. For native generation, bundle the files and register them.
Step 1 — Bundle the actual font files
Section titled “Step 1 — Bundle the actual font files”Put the font files inside your application tree so they are versioned and ship
with every build. A conventional location is a resources/fonts/ directory.
your-app/├── resources/│ └── fonts/│ ├── DejaVuSans.ttf│ ├── DejaVuSans-B.ttf│ └── NotoSansCJK-Regular.ttc└── src/Name the files so the engine’s directory search can find them by family and
style. When you register a directory (rather than a specific file) and later
call setFont('DejaVuSans', 'B', 12), the engine looks for files such as
DejaVuSans-B.ttf, DejaVuSansB.ttf, or DejaVuSans.ttf in each configured
directory. The directory search builds those candidate names from the same
single-letter style code you pass to setFont (B for bold, I for italic,
BI for bold-italic), not a spelled-out word — so the reliable form is
Family-<StyleCode>.ttf (for example DejaVuSans-B.ttf or DejaVuSans-BI.ttf),
not Family-Bold.ttf. A file named DejaVuSans-Bold.ttf is never found by
directory search; to use such a file, register it explicitly with register() —
which parses the font and indexes it under the family and style read from the
file’s own name tables, so the spelled-out filename no longer matters (see
Step 2).
Step 2 — Register the fonts with the engine
Section titled “Step 2 — Register the fonts with the engine”You have two equivalent ways to make the files visible. Both go through
NextPDF\Typography\FontRegistry, which implements
NextPDF\Contracts\FontRegistryInterface.
Register a specific file under an alias when you control the exact face:
use NextPDF\Typography\FontRegistry;
$registry = new FontRegistry();$registry->register(__DIR__ . '/../resources/fonts/DejaVuSans.ttf', alias: 'DejaVuSans');register(string $fontFile, string $alias = '', int $fontIndex = 0) accepts
.ttf, .otf, and .ttc files, plus legacy Type1 .pfb (which loads its
companion .afm metrics from the same path); $fontIndex selects a sub-font
inside a TrueType Collection (.ttc). register() parses the file and indexes
the face by the family and style read from its own name tables, so the physical
filename is irrelevant once registered. The optional $alias is just an extra
lookup name for the face — it is not a style code and does not change which style
the file provides; pass it when you want to call setFont() with a name other
than the font’s embedded family name. It returns the parsed FontInfo.
Register a directory when you want the engine to resolve faces by name from a folder you control:
$registry = new FontRegistry('/var/www/app/resources/fonts');// or, equivalently, after construction:$registry->addFontDirectory('/var/www/app/resources/fonts');The FontRegistry constructor takes that directory as its first argument, and
addFontDirectory() adds more search paths. A bare Document also exposes
addFontDirectory() for the standalone case.
To use a registry you populated yourself, build documents through
DocumentFactory, which wires that exact registry into every document it
creates:
use NextPDF\Core\DocumentFactory;use NextPDF\Graphics\ImageRegistry;
$factory = new DocumentFactory($registry, new ImageRegistry(maxCacheBytes: 0));
$doc = $factory->create();$doc->addPage();$doc->setFont('DejaVuSans', '', 12);$doc->cell(0, 10, 'Réndéred wîth a bundled face — no tofu.', newLine: true);$doc->save('/tmp/out.pdf');Document::createStandalone() builds its own internal registry, so a face you
registered on a separate FontRegistry is invisible to it. In production, go
through DocumentFactory (or your framework’s factory) so the populated
registry is the one in use.
Framework configuration
Section titled “Framework configuration”Each framework integration exposes the same two concepts as configuration, so
you rarely touch the registry directly. In the Laravel package’s nextpdf.php,
fonts_path (default NEXTPDF_FONTS_PATH, falling back to
resource_path('fonts')) is the search directory, and preload_fonts is a list
of absolute font-file paths parsed at worker boot. Point fonts_path at the
directory you bundled and your registered faces resolve automatically.
Step 3 — Provision fonts in a Docker image
Section titled “Step 3 — Provision fonts in a Docker image”In a container, the font files must be part of the image layer, copied in at
build time. Because the application code and the fonts ship together when you
bundle them under resources/fonts/, a normal COPY . . already carries them.
If you keep fonts outside the build context, copy them explicitly and make sure
the path you register matches the path inside the image.
# Native engine: NO system font packages are required.# The native engine does not discover OS-installed fonts automatically; install OS# font packages (`apt-get install fonts-*`) only if you also register them or point# the font registry's search directory at their files.FROM php:8.4-cli
WORKDIR /var/www/app
# Bundle the application, including resources/fonts/, into the image.COPY . /var/www/app
# Make the bundled directory the engine's font search path.ENV NEXTPDF_FONTS_PATH=/var/www/app/resources/fonts
CMD ["php", "bin/generate.php"]On an immutable or read-only filesystem (a readOnlyRootFilesystem
container, a serverless image, or a hardened host), the font files are read at
generation time and never written, so a read-only mount is fine. The only write
the engine may want is its parsed-font cache: either give that directory a small
writable volume, or warm and lock the registry at boot (next section) so no
runtime write or registration is attempted.
Step 4 — Warm up and verify
Section titled “Step 4 — Warm up and verify”In a long-running worker, parse every face once at boot, then lock the registry so no per-request registration happens and a misconfiguration fails loudly instead of silently falling back:
$registry = new FontRegistry('/var/www/app/resources/fonts');$registry->warmup([ '/var/www/app/resources/fonts/DejaVuSans.ttf', '/var/www/app/resources/fonts/DejaVuSans-B.ttf',]);$registry->lock();After lock(), register(), addFontDirectory(), and warmup() throw, which
turns a “wrong path in the image” mistake into a hard boot failure rather than a
tofu page in production.
Add a deployment smoke check that renders one page with each required face. The header check below only verifies the document produced output — it does not prove the font parsed, embedded, or even resolved. A face the engine cannot find may fall back to a standard base font (and, under the current non-strict behaviour, a conformance profile may instead supply a bundled substitute) while still emitting a valid, non-empty PDF — so even where that fallback happens this check alone will not catch the silent degradation. Do not rely on the fallback being guaranteed or silent on every path; verify the embedded program directly, as shown below:
$doc = $factory->create();$doc->addPage();$doc->setFont('DejaVuSans', '', 12);$doc->cell(0, 10, 'warmup check', newLine: true);
$pdf = $doc->getPdfData();
// `getPdfData()` would normally throw on a real failure; this header check only// confirms serialization returned PDF bytes, not that any specific font resolved.if (!str_starts_with($pdf, '%PDF')) { throw new RuntimeException('Font warmup smoke check produced no PDF output.');}To actually fail the deploy when a face is missing, check the emitted PDF for the
embedded font program. A registered face that resolves carries its own font
dictionary with an embedded program, so asserting its presence catches the case
where the requested face never resolved (whatever the engine fell back to) that
the header check misses. Which key holds the program depends on the outline
format: TrueType outlines (.ttf, .ttc) use /FontFile2, CFF/OpenType
outlines (.otf with PostScript outlines) use /FontFile3, and legacy Type1
(.pfb) uses /FontFile.
If all you need is a format-agnostic “some font program embedded” signal, test for
/FontFile alone — because /FontFile is a substring of both /FontFile2 and
/FontFile3, a bare substring check already matches every outline type, and adding
/FontFile2//FontFile3 as extra || branches is redundant:
if (!str_contains($pdf, '/FontFile')) { throw new RuntimeException('No embedded font program found — face fell back.');}A bare /FontFile substring cannot tell the outline types apart, though. To
distinguish them, match on the exact token with a word boundary so /FontFile
does not also fire on /FontFile2 or /FontFile3:
$isTrueType = preg_match('~/FontFile2\b~', $pdf) === 1; // TrueType (.ttf/.ttc)$isCffOtf = preg_match('~/FontFile3\b~', $pdf) === 1; // CFF/OpenType (.otf)$isType1 = preg_match('~/FontFile(?![23])\b~', $pdf) === 1; // Type1 (.pfb)
if (!$isTrueType && !$isCffOtf && !$isType1) { throw new RuntimeException('No embedded font program found — face fell back.');}Either way, treat this as a coarse heuristic only, not a reliable deploy
gate. A raw byte search over the serialized PDF is inaccurate for several
reasons: font programs can live inside compressed object streams (where
/FontFile* never appears as plain bytes), incremental updates can append
or supersede objects, non-embedded or standard-14 fonts legitimately carry
no font program at all, and serialization differences (object ordering,
whitespace, name encoding) can move or hide the token. At best it confirms
some face embedded a program — never that the specific face you wanted
resolved.
For a real deploy gate, do not rely on the byte search. Parse the emitted PDF
with a proper PDF parser or object inspector and assert that the font object for
your target face carries an embedded /FontFile//FontFile2//FontFile3
program, or use a product-provided font-resolution assertion if one is available
to your integration. The token-aware regexes above are useful for a quick local
quick check, but a structural inspection is what should fail the deploy. The
embedding and font-dictionary structure are described in
Embed and subset a TrueType font.
Edge cases & gotchas
Section titled “Edge cases & gotchas”createStandalone()has its own registry. A face registered on a separateFontRegistryis not visible to a standalone document. UseDocumentFactory(or the framework factory) so your registry is the active one.- Style files must exist as files. The engine does not synthesize bold or
italic from a regular face. If you call
setFont('DejaVuSans', 'B'), directory search looks forDejaVuSans-B.ttf,DejaVuSansB.ttf, orDejaVuSans.ttf(lowercase and.otfvariants too) — it forms the candidate from the literalBstyle code, so it never looks forDejaVuSans-Bold.ttf. A file with a spelled-out name likeDejaVuSans-Bold.ttfonly resolves when you register it explicitly withregister(), which indexes it by the family and style read from the file’s own name tables regardless of the filename; relying on directory search to find it produces a miss, after which the engine may fall back to a base font (not a guaranteed or always-silent path) — the degradation this page warns about. - Stream-wrapper and remote paths are rejected. The registry refuses paths
containing a URI scheme or a null byte. Register local files only; for fonts
fetched at runtime use
registerFromBinary()with the raw bytes. - Locked registry is immutable. Once you call
lock(), any laterregister(),addFontDirectory(), orwarmup()throws. Lookup methods stay available. Register and warm up everything before locking. - CJK collections are large. Register the right sub-font of a
.ttcwith$fontIndex, and budget for a larger embedded subset. See the CJK notes in the embed-and-subset recipe.
Security notes
Section titled “Security notes”- A font file is untrusted binary input. Only bundle fonts from sources you trust, and validate the provenance of any face accepted from end users.
- Locking the registry after warmup removes a runtime mutation surface and makes a path mistake fail at boot rather than silently degrade output.
- Do not interpolate user input into a registered file path. Register a fixed set of bundled faces; do not let a request choose an arbitrary filesystem path.
Conformance
Section titled “Conformance”This guide makes no normative standards claim. Every symbol shown is verified
public surface: NextPDF\Typography\FontRegistry (register(),
addFontDirectory(), warmup(), lock(), the directory constructor argument),
its NextPDF\Contracts\FontRegistryInterface contract,
NextPDF\Core\DocumentFactory::create(), and NextPDF\Core\Document::setFont()
/ addFontDirectory(). The Laravel fonts_path and preload_fonts keys are the
documented configuration of the nextpdf/laravel package. The embedding and
subset-tag behavior, with its ISO 32000-2 citations, is documented on the
embed-and-subset recipe linked under See also.
See also
Section titled “See also”- Embed and subset a TrueType font: the API-level recipe for registering one face and the automatic subset at save.
- Render HTML to a PDF page: the native HTML path, which resolves fonts through the same registry.
- Return a generated PDF from a controller: wire a factory-built document into a framework response.
- Laravel production usage: the framework font configuration and worker-boot warmup.