HTML to PDF in PHP

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.

Quick answer

OptionPick 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).

The test template

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>

1. dompdf (pure PHP)

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:

2. Browsershot (headless Chrome)

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).

3. An API (no browser, only ext-curl)

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).

Send the PDF to the browser

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.

Side by side

FeatureSupport
CSSBrowsershot and API: everything Chrome supports
dompdf: CSS 2.1, no flexbox, grid or JavaScript
Page numbersdompdf: page_text() in PHP
Chrome: footer templates or CSS @page margin boxes (how)
Installdompdf: Composer only; API: ext-curl only
Browsershot: Node, Puppeteer and a ~400 MB Chrome on the server
Private pagesdompdf and Browsershot: can reach localhost and your VPN
API: public URLs or HTML you send

Which one?

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.

Try it with 25 free renders a month

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).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons