By the end of this you’ll have a small PHP project that turns an HTML template into an A4 invoice PDF with your own font, a logo, a header and footer on every page, and “Page 1 of 2” in the corner. It’s a Dompdf HTML to PDF setup in plain PHP, so there’s no Chrome, no binary to install and nothing listening on a port unless you want an endpoint.
Dompdf is the pure-PHP option in our wkhtmltopdf alternatives benchmark, and it’s the easiest one to drop into an existing PHP app. This guide covers the Composer install on Ubuntu, Debian and Rocky Linux, custom fonts with @font-face, page numbers, fixed headers and footers, SVG logos, a streaming PDF endpoint, Laravel, and the one table habit that decides whether a long invoice takes 6 seconds or runs out of memory.
Tested in October 2026 with Dompdf 3.1.6 on Ubuntu 26.04 and 24.04, Debian 13 and Rocky Linux 10, and every step below still holds.
Know what Dompdf can and can’t render
This is the part that trips people up: Dompdf is a CSS 2.1 engine with a few CSS3 extras. It is not a browser. Layouts built with flexbox or grid collapse into stacked blocks, and nothing in a <script> tag runs. If you design the template for it (tables for layout, fixed elements for headers), it’s fast and predictable. We ran the same three test documents through every engine in the benchmark, and this is what Dompdf did with them:
| Feature | Dompdf result | What to use instead |
|---|---|---|
| Tables, repeated header rows | Works, thead repeats on every page | Nothing needed |
CSS custom properties (var()) | Works | Nothing needed |
@font-face web fonts | Works once the weight matches (see below) | Declare every weight you use |
| Flexbox, CSS Grid, multi-column | Ignored, blocks stack | Tables, floats, fixed widths |
Page margin boxes (@top-left, counter(pages)) | Ignored | position: fixed header/footer plus page_text() |
Inline <svg> | Dropped silently | <img src="logo.svg"> or an SVG data URI |
| JavaScript | Not executed | A Chromium-based tool |
| CJK text with the bundled fonts | Empty boxes | Register a font that has the glyphs |
For simple documents it’s quick. In the benchmark it rendered our 140-row invoice in 0.65 seconds from a cold CLI start with a 50 MiB memory peak. With the page cache dropped before each run, a render took 0.79 seconds and peaked at 124 MiB. If your template really needs flexbox, grid or JavaScript, you’ll save yourself a rewrite by running Gotenberg as an HTML to PDF API next to your PHP app instead.
Weighing Dompdf against mPDF? Both are PHP-only, but mPDF has a trap you should know about: a plain @page { size: A4; } rule made the current mPDF release produce 34 blank pages from a one-line document in our tests. It wants sheet-size: A4 or size: 210mm 297mm instead. Dompdf handles the standard rule correctly.
Install PHP and Composer
Dompdf needs the DOM and mbstring extensions, plus GD for raster images. On Ubuntu 26.04, Ubuntu 24.04 and Debian 13, every piece is in the default repositories:
sudo apt update
sudo apt install -y php-cli php-mbstring php-xml php-gd unzip composer
On Rocky Linux 10 and AlmaLinux 10, PHP comes from AppStream and Composer from EPEL, which needs the CRB repository switched on:
sudo dnf install -y epel-release
sudo dnf config-manager --set-enabled crb
sudo dnf install -y php-cli php-mbstring php-xml php-gd unzip composer
Check both tools before you go further:
php -v | head -1
composer --version
Ubuntu 26.04 printed PHP 8.5 and a current Composer 2 release:
PHP 8.5.4 (cli) (built: Sep 2 2026 14:42:18) (NTS)
Composer version 2.9.5 2026-01-29 11:40:53
PHP version 8.5.4 (/usr/bin/php8.5)
Run the "diagnose" command to get more detailed diagnostics output.
Each distro ships a different PHP, and one difference matters a lot for PDFs, which you’ll see in the long-table section:
| Item | Ubuntu 26.04 | Ubuntu 24.04 | Debian 13 | Rocky Linux 10 |
|---|---|---|---|---|
| PHP from the default repos | 8.5.4 | 8.3.6 | 8.4.26 | 8.3.33 (AppStream) |
| Composer | 2.9.5 | 2.7.1 | 2.8.8 | 2.10.3 (EPEL) |
CLI memory_limit | -1 (unlimited) | -1 | -1 | 128M |
| Dompdf from Composer | Same release | Same release | Same release | Same release |
If you would rather run the newest PHP than the distro build, the PHP 8.5 guide for Ubuntu 26.04, the Debian walkthrough and the Rocky and AlmaLinux steps cover it. Dompdf works the same on all of them.
Add Dompdf to a project
Create a project directory and pull Dompdf in with Composer:
mkdir ~/invoice-pdf && cd ~/invoice-pdf
composer require dompdf/dompdf
Composer brings in four small libraries with it (font, SVG, HTML5 and CSS parsers), so there is nothing else to compile:
./composer.json has been created
Running composer update dompdf/dompdf
Loading composer repositories with package information
Updating dependencies
Lock file operations: 5 installs, 0 updates, 0 removals
- Locking dompdf/dompdf (v3.1.6)
- Locking dompdf/php-font-lib (1.0.2)
- Locking dompdf/php-svg-lib (1.0.2)
- Locking masterminds/html5 (2.11.0)
- Locking sabberworm/php-css-parser (v9.5.0)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 5 installs, 0 updates, 0 removals
- Installing masterminds/html5 (2.11.0): Extracting archive
- Installing sabberworm/php-css-parser (v9.5.0): Extracting archive
- Installing dompdf/php-svg-lib (1.0.2): Extracting archive
- Installing dompdf/php-font-lib (1.0.2): Extracting archive
- Installing dompdf/dompdf (v3.1.6): Extracting archive
2 package suggestions were added by new dependencies, use `composer suggest` to see details.
Generating autoload files
No security vulnerability advisories found.
Using version ^3.1 for dompdf/dompdf
Now make the folders the project uses. Templates and their assets live in templates/, the web entry point in public/, and Dompdf’s font and temp files in storage/, away from both the web root and vendor/:
mkdir -p templates/fonts public storage/fonts storage/tmp
Grab the Lobster font (SIL Open Font License) for the invoice heading. Any TTF works the same way:
curl -sLo templates/fonts/Lobster-Regular.ttf https://github.com/google/fonts/raw/main/ofl/lobster/Lobster-Regular.ttf
Then create the logo file:
vim templates/logo.svg
Paste in this one-line SVG, or drop your own logo file in its place:
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><rect width="48" height="48" rx="10" fill="#0b5cad"/><path d="M12 34 L24 12 L36 34 Z" fill="#fff"/><circle cx="24" cy="28" r="4" fill="#0b5cad"/></svg>
Write the invoice template
The template is a regular PHP file that prints HTML. It uses only things Dompdf renders well: tables for layout, a float for the invoice number, and position: fixed for the header and footer so they repeat on every page. Open it:
vim templates/invoice.php
Add the full template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
@font-face { font-family: "Lobster"; font-weight: normal; src: url("fonts/Lobster-Regular.ttf"); }
@font-face { font-family: "Lobster"; font-weight: bold; src: url("fonts/Lobster-Regular.ttf"); }
@page { margin: 90px 40px 70px 40px; }
body { font-family: "DejaVu Sans", sans-serif; font-size: 10pt; color: #222; }
header { position: fixed; top: -70px; left: 0; right: 0; height: 50px; border-bottom: 2px solid #0b5cad; }
footer { position: fixed; bottom: -50px; left: 0; right: 0; height: 30px; font-size: 8pt; color: #666; }
.logo { width: 40px; vertical-align: middle; }
.brand { font-family: "Lobster"; font-size: 24pt; color: #0b5cad; vertical-align: middle; }
.meta { float: right; text-align: right; font-size: 9pt; }
table.parties { width: 100%; margin-bottom: 14px; }
table.parties td { width: 50%; vertical-align: top; background: #f3f6fa; padding: 8px; }
table.items { width: 100%; border-collapse: collapse; }
table.items thead { display: table-header-group; }
table.items th { background: #0b5cad; color: #fff; text-align: left; padding: 5px; }
table.items td { padding: 4px 5px; border-bottom: 1px solid #ddd; }
table.items tr { page-break-inside: avoid; }
table.items .num { text-align: right; }
.total { margin-top: 12px; text-align: right; font-size: 12pt; font-weight: bold; }
</style>
</head>
<body>
<header>
<img class="logo" src="logo.svg" alt="">
<span class="brand">Invoice</span>
<div class="meta"><?= htmlspecialchars($invoice['number']) ?><br>Due <?= htmlspecialchars($invoice['due']) ?></div>
</header>
<footer>Payment by bank transfer within 30 days. Questions: [email protected]</footer>
<table class="parties">
<tr>
<td><strong>From</strong><br><?= nl2br(htmlspecialchars($invoice['from'])) ?></td>
<td><strong>Bill to</strong><br><?= nl2br(htmlspecialchars($invoice['to'])) ?></td>
</tr>
</table>
<table class="items">
<thead><tr><th>#</th><th>Description</th><th class="num">Qty</th><th class="num">Unit</th><th class="num">Amount</th></tr></thead>
<tbody>
<?php foreach ($invoice['lines'] as $i => $line): ?>
<tr>
<td><?= $i + 1 ?></td>
<td><?= htmlspecialchars($line['item']) ?></td>
<td class="num"><?= $line['qty'] ?></td>
<td class="num"><?= number_format($line['unit'], 2) ?></td>
<td class="num"><?= number_format($line['qty'] * $line['unit'], 2) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="total">Total due: <?= number_format($invoice['total'], 2) ?></p>
</body>
</html>
A few lines in there are doing more than they look. The two @font-face rules point at the same file on purpose: the brand name sits in a plain span, but if you ever set Lobster on a heading (bold by default), Dompdf needs the bold face declared too. The font section below shows what happens without it. The header and footer sit inside the @page margins because of their negative top and bottom offsets. And every value that comes from data goes through htmlspecialchars(), because the HTML you hand Dompdf is code it will happily fetch and render.
Build the PDF in one function
Keep the Dompdf setup in a single file so the CLI script and the web endpoint share it. Create it in the project root:
vim pdf.php
Paste the builder:
<?php
require __DIR__ . '/vendor/autoload.php';
use Dompdf\Dompdf;
use Dompdf\Options;
function sample_invoice(int $rows = 40): array
{
$items = ['Managed PostgreSQL', 'Object storage 500 GB', 'Load balancer', 'Backup snapshots', 'Support hours'];
$lines = [];
for ($i = 0; $i < $rows; $i++) {
$lines[] = ['item' => $items[$i % 5] . ' (line ' . ($i + 1) . ')', 'qty' => ($i % 9) + 1, 'unit' => 10 + ($i * 7) % 90];
}
return [
'number' => 'INV-2026-1042',
'due' => '2026-11-07',
'from' => "ACME Hosting Ltd\n12 Example Road\nNairobi, Kenya",
'to' => "Example Corp\n99 Sample Street\nMombasa, Kenya",
'lines' => $lines,
'total' => array_sum(array_map(fn ($l) => $l['qty'] * $l['unit'], $lines)),
];
}
function make_pdf(array $invoice): Dompdf
{
$templates = __DIR__ . '/templates';
$options = new Options();
$options->setChroot([$templates]);
$options->setIsRemoteEnabled(false);
$options->setIsPhpEnabled(false);
$options->setDefaultPaperSize('A4');
$options->setFontDir(__DIR__ . '/storage/fonts');
$options->setFontCache(__DIR__ . '/storage/fonts');
$options->setTempDir(__DIR__ . '/storage/tmp');
ob_start();
include $templates . '/invoice.php';
$html = ob_get_clean();
$dompdf = new Dompdf($options);
$dompdf->setBasePath($templates);
$dompdf->loadHtml($html);
$dompdf->render();
$font = $dompdf->getFontMetrics()->getFont('DejaVu Sans');
$dompdf->getCanvas()->page_text(500, 810, 'Page {PAGE_NUM} of {PAGE_COUNT}', $font, 8, [0.4, 0.4, 0.4]);
return $dompdf;
}
Each option is there for a reason you’ll otherwise find the hard way:
setChroot([$templates])lets Dompdf read the font and logo. The default chroot is Dompdf’s own folder insidevendor/, so local assets fail without it.setDefaultPaperSize('A4')matters because Dompdf’s default is US Letter, not A4.setFontDir()andsetFontCache()move registered fonts out ofvendor/dompdf/dompdf/lib/fonts, the default. Otherwise acomposer updatewipes them, and the font folder ends up inside your code tree.page_text()runs afterrender()and stamps “Page X of Y” on every page. Dompdf replaces{PAGE_NUM}and{PAGE_COUNT}itself. The font argument comes fromgetFontMetrics()->getFont(), and the coordinates are PDF points from the top left of an A4 page (595 by 842).
Remote fetching and embedded PHP are already off by default. They’re set explicitly anyway, so a future edit has to switch them on deliberately.
Render HTML to PDF from the command line
A tiny runner calls the builder, writes the file and prints how long it took. Create it:
vim render.php
Add these lines:
<?php
require __DIR__ . '/pdf.php';
$rows = (int) ($argv[1] ?? 40);
$start = microtime(true);
$pdf = make_pdf(sample_invoice($rows));
file_put_contents(__DIR__ . '/invoice.pdf', $pdf->output());
printf("rows=%d time=%.2fs peak_memory=%.1fMB\n", $rows, microtime(true) - $start, memory_get_peak_usage(true) / 1048576);
Run it with the default 40 invoice lines:
php render.php
It finishes in well under a second:
rows=40 time=0.32s peak_memory=30.0MB
Poppler’s tools tell you whether the PDF came out the way you meant. Check the page size and the embedded fonts (install poppler-utils if they’re missing):
pdfinfo invoice.pdf | grep -E "Producer|Pages|Page size"
pdffonts invoice.pdf
You want A4, two pages for 40 lines, and Lobster listed as an embedded font next to DejaVu Sans:
Producer: dompdf 3.1.6 + CPDF
Pages: 2
Page size: 595.28 x 841.89 pts (A4)
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
SUBAAB+DejaVuSans CID TrueType Identity-H yes yes yes 8 0
SUBAAC+Lobster-Regular CID TrueType Identity-H yes yes yes 13 0
SUBAAD+DejaVuSans-Bold CID TrueType Identity-H yes yes yes 18 0
Here’s page one. The header with the SVG logo and the footer repeat on page two, the table header row repeats too, and the page counter sits bottom right:

Your @font-face font is ignored and the heading prints in Times
This is the most common Dompdf font complaint, and the cause is the weight, not the path. Headings are bold by default. If your @font-face only declares the normal weight, browsers fake a bold version, but Dompdf looks for a bold face, does not find one and falls back to its default serif. Our first render of a heading set in Lobster showed exactly that:
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
Times-Bold Type 1 WinAnsi no no no 10 0
SUBAAC+DejaVuSans-Bold CID TrueType Identity-H yes yes yes 11 0
SUBAAD+DejaVuSans CID TrueType Identity-H yes yes yes 16 0
This only bites when the custom font lands on a bold element such as a heading or <strong>. You can fix it either way. Set font-weight: normal on the element, or declare a second @font-face with font-weight: bold that points at the same file, which is what the template above does. Both put Lobster in the PDF on the next render.
Error: “Permission denied. The file could not be found under the paths specified by Options::chroot.”
Dompdf does not throw this one. It records it as a warning and carries on, so all you see is a crossed-out box where the logo should be. Rendering the same template with new Dompdf() and no options, then printing $_dompdf_warnings, shows what happened:
Array
(
[0] => Error loading file:///home/lorna/invoice-pdf/templates/fonts/Lobster-Regular.ttf: Permission denied. The file could not be found under the paths specified by Options::chroot.
[1] => Error loading logo.svg: Permission denied. The file could not be found under the paths specified by Options::chroot.
logo.svg
)
Point setChroot() at the folder that holds your assets, as pdf.php does. Do not widen it to /: that’s the setting that lets crafted HTML read files from anywhere on the server.
Your inline SVG logo doesn’t show up
Dompdf drops raw <svg> markup without a warning. Reference the SVG as an image instead: <img src="logo.svg"> inside the chroot, or a data URI built with base64_encode() if the SVG comes from code. We tested both, and both draw the logo as vector paths.
Serve the PDF from a web endpoint
The same builder streams the PDF straight to a browser. Create the entry point:
vim public/index.php
It reads a rows parameter, clamps it to a sane range and streams the result inline:
<?php
require __DIR__ . '/../pdf.php';
$rows = min(max((int) ($_GET['rows'] ?? 40), 1), 500);
$pdf = make_pdf(sample_invoice($rows));
$pdf->stream('invoice.pdf', ['Attachment' => false]);
That clamp is not decoration. You’ll see below how fast memory grows with table size, and an unbounded rows value is an easy way for someone to tie up your PHP workers. At the 500-row cap, a single table peaked at 106 MB on Ubuntu 26.04 and 108 MB on Rocky Linux, and the chunked template from the long-invoice section at 74 MB and 76 MB, so both fit a 128M worker. Start PHP’s built-in server on port 8004 to try it:
php -S 127.0.0.1:8004 -t public
From a second terminal, request a 60-line invoice and confirm you got a PDF back:
curl -s -o web.pdf -w "%{http_code} %{content_type} %{size_download}\n" "http://127.0.0.1:8004/?rows=60"
pdfinfo web.pdf | grep Pages
A 200 with application/pdf and three pages means the endpoint works:
200 application/pdf 36813
Pages: 3
The built-in server is for testing only. In production, put the project behind Nginx with PHP-FPM and point the web root at public/, so storage/ and templates/ are never served.
Run it under Nginx and PHP-FPM with SELinux enforcing
We tested this part on Rocky Linux 10 with SELinux enforcing, where two denials show up that Ubuntu and Debian never hit. Install the web stack and the SELinux tools:
sudo dnf install -y nginx php-fpm policycoreutils-python-utils
Copy the project out of your home directory to /var/www, clear the font cache the CLI runs left behind, keep the code owned by root, and give the PHP-FPM user (apache on Rocky) only the storage/ folder:
sudo cp -r ~/invoice-pdf /var/www/invoice-pdf
sudo rm -rf /var/www/invoice-pdf/storage/fonts/* /var/www/invoice-pdf/storage/tmp/*
sudo chown -R root:root /var/www/invoice-pdf
sudo chown -R apache:apache /var/www/invoice-pdf/storage
Create the Nginx server block:
sudo vim /etc/nginx/conf.d/invoice-pdf.conf
Point the root at public/ and hand PHP files to the PHP-FPM socket:
server {
listen 8004;
root /var/www/invoice-pdf/public;
index index.php;
location ~ \.php$ {
fastcgi_pass unix:/run/php-fpm/www.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
Port 8004 isn’t in SELinux’s list of web ports, so Nginx refused to start with nginx: [emerg] bind() to 0.0.0.0:8004 failed (13: Permission denied). Label the port, then start both services:
sudo semanage port -a -t http_port_t -p tcp 8004
sudo nginx -t
sudo systemctl enable --now php-fpm nginx
The first request then returned a 500. The PHP-FPM log at /var/log/php-fpm/www-error.log showed Dompdf failing to write its font cache, even though apache owns the folder:
PHP Warning: fopen(/var/www/invoice-pdf/storage/fonts/lobster_normal_b6a6c9d85cc9f25db523c7dd3e2b2fd2.ufm): Failed to open stream: Permission denied in /var/www/invoice-pdf/vendor/dompdf/php-font-lib/src/FontLib/AdobeFontMetrics.php on line 44
Files under /var/www get the read-only httpd_sys_content_t label. Give storage/ and everything under it (fonts and tmp) the writable label instead:
sudo semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/invoice-pdf/storage(/.*)?"
sudo restorecon -Rv /var/www/invoice-pdf/storage
Request the PDF through Nginx again from the server itself:
curl -s -o web.pdf -w "%{http_code} %{content_type}\n" "http://127.0.0.1:8004/?rows=60"
This time it’s a PDF, with SELinux still enforcing:
200 application/pdf
We kept the test on 127.0.0.1. To reach the endpoint from other machines, open the port in firewalld as well:
sudo firewall-cmd --permanent --add-port=8004/tcp && sudo firewall-cmd --reload
That’s the whole production path: the PHP user can write to storage/ and nothing else in the project.
Keep long invoices fast with smaller tables
Dompdf lays out each table as one unit, and the cost grows faster than the row count. We pushed the same template from 200 to 3,000 lines on Ubuntu 26.04, then changed one thing: we split the rows into separate 100-row tables.
| Invoice lines | One table: time | One table: peak memory | 100-row tables: time | 100-row tables: peak memory |
|---|---|---|---|---|
| 200 | 1.25 s | 48 MB | 0.76 s | 44 MB |
| 1,000 | 9.0 to 13.6 s | 242 MB | 6.4 to 7.5 s | 122 MB |
| 3,000 | 79 to 106 s | 1,346 MB | 13 to 30 s | 312 MB |
Memory is the number to watch. The times swing between runs because the lab VM was shared with other benchmarks, but peak memory came out identical every time. A 3,000-line invoice in one table needed 1.3 GB, which no web PHP pool will give it.
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted
That’s what a 1,000-line single table hits under a 128M limit, which is the usual PHP-FPM setting:
php -d memory_limit=128M render.php 1000
The stack trace points straight at Dompdf’s table cell map:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes) in /home/lorna/invoice-pdf/vendor/dompdf/dompdf/src/Cellmap.php on line 634
Stack trace:
#0 /home/lorna/invoice-pdf/vendor/dompdf/dompdf/src/Cellmap.php(553): Dompdf\Cellmap->add_frame()
Rocky Linux catches people even on the command line, because its /etc/php.ini sets memory_limit = 128M for the CLI too. Ubuntu and Debian leave the CLI unlimited. The same 1,000-line render died there with no flag at all (in Css/Style.php that time).
The fix is a three-line change to the template: wrap the table in an array_chunk() loop so each block of 100 rows becomes its own table, and every table still gets the repeating header row. Open the template again:
vim templates/invoice.php
Replace the whole <table class="items"> block with this version:
<?php foreach (array_chunk($invoice['lines'], 100, true) as $chunk): ?>
<table class="items">
<thead><tr><th>#</th><th>Description</th><th class="num">Qty</th><th class="num">Unit</th><th class="num">Amount</th></tr></thead>
<tbody>
<?php foreach ($chunk as $i => $line): ?>
<tr>
<td><?= $i + 1 ?></td>
<td><?= htmlspecialchars($line['item']) ?></td>
<td class="num"><?= $line['qty'] ?></td>
<td class="num"><?= number_format($line['unit'], 2) ?></td>
<td class="num"><?= number_format($line['qty'] * $line['unit'], 2) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endforeach; ?>
With that in place, the 1,000-line render finished inside the 128M limit on both Ubuntu and Rocky Linux, peaking at 122 MB and 128 MB respectively. The Rocky run had no headroom left at all, and halving the chunk to 50 rows only brought it down to 122 MB, so smaller tables alone won’t buy much more. If invoices that long are normal for you, raise memory_limit to 256M for the PDF pool. Past a few thousand lines, generate the PDF in a queue worker rather than in the web request.
Use Dompdf in Laravel
In a Laravel app you do not need any of the builder code above. The barryvdh/laravel-dompdf package wraps Dompdf in a facade and renders Blade views. We tested it in a fresh Laravel 13 project (the Laravel install guide covers the app itself). From the project root:
composer require barryvdh/laravel-dompdf
It pulls the same Dompdf release you installed above:
- Locking barryvdh/laravel-dompdf (v3.1.2)
- Locking dompdf/dompdf (v3.1.6)
- Locking dompdf/php-font-lib (1.0.2)
- Locking dompdf/php-svg-lib (1.0.2)
- Locking masterminds/html5 (2.11.0)
- Locking sabberworm/php-css-parser (v9.5.0)
Create a Blade view for the PDF:
mkdir -p resources/views/pdf
vim resources/views/pdf/invoice.blade.php
A short invoice view is enough to prove the wiring:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
body { font-family: "DejaVu Sans", sans-serif; font-size: 10pt; }
h1 { color: #0b5cad; }
table { width: 100%; border-collapse: collapse; }
th { background: #0b5cad; color: #fff; text-align: left; padding: 5px; }
td { padding: 4px 5px; border-bottom: 1px solid #ddd; }
</style>
</head>
<body>
<h1>Invoice {{ $number }}</h1>
<table>
<thead><tr><th>Item</th><th>Qty</th><th>Amount</th></tr></thead>
<tbody>
@foreach ($lines as $line)
<tr><td>{{ $line['item'] }}</td><td>{{ $line['qty'] }}</td><td>{{ number_format($line['amount'], 2) }}</td></tr>
@endforeach
</tbody>
</table>
</body>
</html>
Next, add a route. Open the routes file:
vim routes/web.php
Add the Pdf import at the top and the route at the bottom. This is the complete file we tested:
<?php
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/invoice.pdf', function () {
$data = [
'number' => 'INV-2026-1042',
'lines' => [
['item' => 'Managed PostgreSQL', 'qty' => 2, 'amount' => 36.00],
['item' => 'Object storage 500 GB', 'qty' => 1, 'amount' => 17.00],
],
];
return Pdf::loadView('pdf.invoice', $data)->setPaper('a4')->stream('invoice.pdf');
});
Start the development server on port 8004 and request the route from a second terminal:
php artisan serve --host=127.0.0.1 --port=8004
Fetch the PDF:
curl -s -o invoice.pdf -w "%{http_code} %{content_type}\n" http://127.0.0.1:8004/invoice.pdf
Laravel answers with the PDF:
200 application/pdf
Two package defaults differ from plain Dompdf, and you’ll want to know about both. Its chroot is the entire Laravel app (base_path()), and font subsetting is off, so PDFs carry whole font files. Run php artisan vendor:publish --provider="Barryvdh\DomPDF\ServiceProvider" to get config/dompdf.php. Then narrow chroot to the folder that holds your PDF assets and set enable_font_subsetting to true. Fonts already go to storage/fonts, and remote fetching and embedded PHP are off.
Security audit checklist
Dompdf has had serious bugs, including CVE-2022-28368. That one was remote code execution through a .php file referenced in @font-face, which landed in the font cache inside vendor/, and it needed isRemoteEnabled switched on to work (on any release since 0.8.6). At default settings, the current release passed our probe: we fed it HTML containing file:///etc/passwd references and links to a listener on 127.0.0.1. It read no file and made no request on any of the four systems. Turning on remote access with a / chroot made the same document hit the listener twice. Before a Dompdf endpoint goes live, walk through this list:
setChroot()points at your template and asset folder only, never/and never the app root.setIsRemoteEnabled(false)stays off. If you must load remote images, turn it on together withsetAllowedRemoteHosts(['cdn.example.com']), so the PDF engine can’t be pointed at internal addresses.setIsPhpEnabled(false)stays off. With it on,<script type="text/php">in the HTML runs as your PHP user.setAllowedProtocols()lists only what you use (often justfile://anddata://). The default also allowshttp://andhttps://.- Call
setIsJavascriptEnabled(false)for untrusted input. It defaults to true, and it embeds PDF-viewer JavaScript from<script>tags into the file, which is not something your users’ PDF readers need. setFontDir(),setFontCache()andsetTempDir()point outside both the web root andvendor/, writable only by the PHP user.- Every user-supplied value is escaped with
htmlspecialchars()(or Blade’s{{ }}), and request inputs that size the document are capped. - Dompdf stays current: run
composer auditin CI and update when it reports an advisory. It printed “No security vulnerability advisories found.” for this project.
Once those are ticked, you’ve got a PDF service with no browser to patch and nothing listening except the PHP app you already run. If you later need what Dompdf can’t do (flexbox, grid, JavaScript charts), the full comparison shows which engine fits that document.