Three ways to turn HTML into a real, text-selectable PDF from PHP, all run on the same invoice with PHP 8.5: dompdf (pure PHP), Browsershot (headless Chrome through Puppeteer) and a plain cURL call to an API. We measured where dompdf's output differs from Chrome's, so you can choose before you write the template.
| Option | Pick it when |
|---|---|
| dompdf Pure PHP, no browser, LGPL | Simple templates (blocks, tables, floats) and shared hosting where you can't install anything but Composer packages. |
| Browsershot By Spatie, drives Chrome via Node + Puppeteer | Your HTML uses flexbox, grid, web fonts or JavaScript and your server can run Node and Chrome. |
| An API Chrome on someone else's server | You want Chrome output but your host can't run a browser (shared hosting, slim containers), or you'd rather not keep one patched. Only ext-curl. |
Skip these: TCPDF and FPDF build PDFs from PHP drawing calls; TCPDF's writeHTML() only understands basic HTML. mPDF is in the same CSS class as dompdf (no flexbox or grid). knplabs/knp-snappy wraps wkhtmltopdf, whose project is archived (wkhtmltopdf alternative).
Save this as invoice.html next to your scripts. The header is a flexbox row, the kind of thing most templates use today:
<!doctype html>
<html><head><meta charset="utf-8"><style>
@page { size: A4; margin: 20mm 15mm; }
body { font-family: sans-serif; }
.top { display: flex; justify-content: space-between; align-items: center;
background: #eef2ff; padding: 12px; }
table { width: 100%; border-collapse: collapse; }
td, th { border-bottom: 1px solid #ddd; padding: 6px; text-align: left; }
tr { page-break-inside: avoid; }
</style></head><body>
<div class="top"><h1>Invoice #1042</h1><p>Due: 2026-11-01</p></div>
<table><tr><th>Item</th><th>Price</th></tr>
<tr><td>Consulting (3h)</td><td>€450.00</td></tr>
<tr><td>Hosting, Oct.</td><td>€19.00</td></tr></table>
</body></html>
composer require dompdf/dompdf (tested with 3.1.6), then dompdf.php:
<?php
require __DIR__ . '/vendor/autoload.php';
use Dompdf\Dompdf;
use Dompdf\Options;
$options = new Options();
$options->set('isRemoteEnabled', true); // allow http(s) images and CSS
$dompdf = new Dompdf($options);
$dompdf->loadHtml(file_get_contents(__DIR__ . '/invoice.html'));
$dompdf->setPaper('A4');
$dompdf->render();
// "Page 1 of 3" in the bottom right corner (dompdf ignores @page margin boxes)
$dompdf->getCanvas()->page_text(500, 810, 'Page {PAGE_NUM} of {PAGE_COUNT}', null, 9);
file_put_contents(__DIR__ . '/invoice-dompdf.pdf', $dompdf->output());
It runs anywhere PHP runs and takes well under a second. What we measured on this template:
<table> or with floats. Grid doesn't work either.@page margin boxes, so the page_text() call above draws "Page 1 of 1"; its coordinates are in points (A4 is 595 × 842).font-family: sans-serif dompdf uses Helvetica: € and ë are fine, but ✓ and 東京 come out as ?. font-family: "DejaVu Sans" (bundled) fixes ✓, but CJK still renders as empty boxes; for that, load a font that has the glyphs with @font-face.https:// logos, CDN stylesheets) are only fetched with isRemoteEnabled, as above. Leave it off if the HTML contains user input.Browsershot calls Puppeteer, so the server needs Node: composer require spatie/browsershot (tested with 5.4.1) and npm install puppeteer in the project (it downloads Chrome, ~400 MB unpacked, and Chrome needs its system libraries). browsershot.php:
<?php
require __DIR__ . '/vendor/autoload.php';
use Spatie\Browsershot\Browsershot;
Browsershot::html(file_get_contents(__DIR__ . '/invoice.html'))
->format('A4')
->showBackground() // keep background colors
->noSandbox() // needed in Docker and on Ubuntu 23.10+
->savePdf(__DIR__ . '/invoice-browsershot.pdf');
Result: the header row stays on one line, exactly as in a browser, because it is a browser. Without showBackground() the shaded header prints white, like any Chrome print. noSandbox() is needed in most Docker images and on Ubuntu 23.10+ (AppArmor blocks Chrome's sandbox); without it Chrome exits with "No usable sandbox!". If Node or Chrome live somewhere unusual, set setNodeBinary(), setNpmBinary() or setChromePath(). Each call starts a new Chrome: about a second and ~200 MB of RAM per PDF here (0.9 s, 209 MB peak).
Shotmint renders in the same Chrome, but your PHP only makes an HTTP request: no Node, no browser download, works on shared hosting. Run it with SHOTMINT_KEY=... php api.php:
<?php
$payload = json_encode([
'html' => file_get_contents(__DIR__ . '/invoice.html'),
'format' => 'A4',
'margin' => '20mm',
]);
$ch = curl_init('https://shotmint.creasework.com/v1/pdf');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-Key: ' . getenv('SHOTMINT_KEY'),
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
]);
$pdf = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($pdf === false || $status !== 200) {
throw new RuntimeException("Render failed ($status): " . ($pdf ?: curl_error($ch)));
}
file_put_contents(__DIR__ . '/invoice-api.pdf', $pdf);
Result: the same 1-page A4 PDF as Browsershot, header on one line, background kept (print_background is on by default). A bad key throws Render failed (401): {"error":"Missing or invalid API key..."}; errors are always JSON with a 4xx status. For a live page send 'url' instead of 'html'. Other options: landscape, format (A3, A4, A5, Letter, Legal, Tabloid), scale and screen_media. Images and fonts in the HTML must be reachable over public URLs (or inlined as data: URIs).
Whichever option you use, return the bytes with a PDF content type and a filename (tested with php -S):
<?php
// After any of the three options: $pdf holds the PDF bytes
$pdf = file_get_contents(__DIR__ . '/invoice-api.pdf');
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="invoice-1042.pdf"');
header('Content-Length: ' . strlen($pdf));
echo $pdf;
In Laravel the same thing is response($pdf, 200, ['Content-Type' => 'application/pdf', 'Content-Disposition' => 'attachment; filename="invoice-1042.pdf"']). Render a Blade view to HTML first with view('invoice', $data)->render() and pass that string to any of the three options.
| Feature | Support |
|---|---|
| CSS | Browsershot and API: everything Chrome supports dompdf: CSS 2.1, no flexbox, grid or JavaScript |
| Page numbers | dompdf: page_text() in PHPChrome: footer templates or CSS @page margin boxes (how) |
| Install | dompdf: Composer only; API: ext-curl only Browsershot: Node, Puppeteer and a ~400 MB Chrome on the server |
| Private pages | dompdf and Browsershot: can reach localhost and your VPN API: public URLs or HTML you send |
If your templates are simple and you can live with tables instead of flexbox, dompdf is free and has no moving parts. If they use modern CSS and you control the server, use Browsershot. If you're on shared hosting, or don't want Node and Chrome on every web server, use the API. Want to see the Chrome output first? Paste your HTML into the free HTML to PDF converter; no key needed. Other languages: Node.js, Python, Java, C# / .NET.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons