Dev

Convert HTML to PDF in PHP with Dompdf

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.

Original content from computingforgeeks.com - post 173453

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:

FeatureDompdf resultWhat to use instead
Tables, repeated header rowsWorks, thead repeats on every pageNothing needed
CSS custom properties (var())WorksNothing needed
@font-face web fontsWorks once the weight matches (see below)Declare every weight you use
Flexbox, CSS Grid, multi-columnIgnored, blocks stackTables, floats, fixed widths
Page margin boxes (@top-left, counter(pages))Ignoredposition: fixed header/footer plus page_text()
Inline <svg>Dropped silently<img src="logo.svg"> or an SVG data URI
JavaScriptNot executedA Chromium-based tool
CJK text with the bundled fontsEmpty boxesRegister 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:

ItemUbuntu 26.04Ubuntu 24.04Debian 13Rocky Linux 10
PHP from the default repos8.5.48.3.68.4.268.3.33 (AppStream)
Composer2.9.52.7.12.8.82.10.3 (EPEL)
CLI memory_limit-1 (unlimited)-1-1128M
Dompdf from ComposerSame releaseSame releaseSame releaseSame 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 inside vendor/, so local assets fail without it.
  • setDefaultPaperSize('A4') matters because Dompdf’s default is US Letter, not A4.
  • setFontDir() and setFontCache() move registered fonts out of vendor/dompdf/dompdf/lib/fonts, the default. Otherwise a composer update wipes them, and the font folder ends up inside your code tree.
  • page_text() runs after render() and stamps “Page X of Y” on every page. Dompdf replaces {PAGE_NUM} and {PAGE_COUNT} itself. The font argument comes from getFontMetrics()->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:

Dompdf HTML to PDF invoice page with Lobster font, SVG logo, repeated header and Page 1 of 2 footer

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 linesOne table: timeOne table: peak memory100-row tables: time100-row tables: peak memory
2001.25 s48 MB0.76 s44 MB
1,0009.0 to 13.6 s242 MB6.4 to 7.5 s122 MB
3,00079 to 106 s1,346 MB13 to 30 s312 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 with setAllowedRemoteHosts(['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 just file:// and data://). The default also allows http:// and https://.
  • 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() and setTempDir() point outside both the web root and vendor/, 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 audit in 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.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP Php PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP Rocky Linux 10 Post-Install Tips: Repos, SIGs and rocky-release RHEl Rocky Linux 10 Post-Install Tips: Repos, SIGs and rocky-release Gatus Monitoring in Docker: Health Checks, Alerts, Status Page Containers Gatus Monitoring in Docker: Health Checks, Alerts, Status Page Install Rocky Linux 10: Full Guide & Top Features Rocky Linux Install Rocky Linux 10: Full Guide & Top Features

Leave a Comment

Press ESC to close