Get started

Generate PDFs from HTML in PHP: Dompdf, Playwright, and REST APIs compared

Generate PDFs from HTML in PHP using Dompdf, headless Chromium, or a REST API. Covers Laravel, Symfony, dynamic templates, fonts, and production tips.

benoitdedMarch 18, 202610 min read

Generating PDFs in PHP has more options than it should, and picking the wrong one causes real problems in production. This guide covers the three practical approaches, with working code for Laravel and plain PHP, and a clear decision framework for when each makes sense.

The approaches

ApproachCSS supportSystem depsConcurrencyBest for
Dompdf (pure PHP)Partial (no Grid)NoneSingle-threadedSimple layouts, easy setup
Headless ChromiumFullChrome binaryVia queueComplex CSS, maximum accuracy
PDF4.devFull (Chromium)NoneUnlimitedProduction scale, no infra

Option 1: Dompdf

Dompdf is a pure-PHP HTML-to-PDF converter. It parses HTML and CSS, then generates a PDF without any system dependencies. It's the fastest way to add PDF generation to a PHP project.

composer require dompdf/dompdf

Basic usage

<?php
 
use Dompdf\Dompdf;
use Dompdf\Options;
 
$options = new Options();
$options->set('defaultFont', 'DejaVu Sans');
 
$dompdf = new Dompdf($options);
 
$html = '
<html>
<head>
  <style>
    body { font-family: DejaVu Sans, sans-serif; font-size: 14px; }
    h1 { color: #333; }
    .total { font-weight: bold; font-size: 18px; }
  </style>
</head>
<body>
  <h1>Invoice #001</h1>
  <p>Client: Acme Corp</p>
  <p class="total">Total: $1,500.00</p>
</body>
</html>
';
 
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
 
// Output to browser
$dompdf->stream('invoice.pdf', ['Attachment' => true]);
 
// Or get the raw bytes
$output = $dompdf->output();
file_put_contents('invoice.pdf', $output);

Laravel integration

The barryvdh/laravel-dompdf package wraps Dompdf with a Laravel-friendly API.

composer require barryvdh/laravel-dompdf

Create a Blade template at resources/views/pdf/invoice.blade.php:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    body { font-family: DejaVu Sans, sans-serif; font-size: 13px; color: #222; }
    table { width: 100%; border-collapse: collapse; margin-top: 20px; }
    th { background: #f5f5f5; text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }
    td { padding: 8px; border-bottom: 1px solid #eee; }
    .total { font-weight: bold; font-size: 16px; }
  </style>
</head>
<body>
  <h1>Invoice #{{ $invoice['number'] }}</h1>
  <p>Client: {{ $invoice['client'] }}</p>
  <p>Date: {{ $invoice['date'] }}</p>
 
  <table>
    <thead>
      <tr><th>Item</th><th>Qty</th><th>Price</th></tr>
    </thead>
    <tbody>
      @foreach ($invoice['items'] as $item)
        <tr>
          <td>{{ $item['name'] }}</td>
          <td>{{ $item['qty'] }}</td>
          <td>${{ number_format($item['price'], 2) }}</td>
        </tr>
      @endforeach
    </tbody>
  </table>
 
  <p class="total">Total: ${{ number_format($invoice['total'], 2) }}</p>
</body>
</html>

Then generate the PDF in a controller:

<?php
 
namespace App\Http\Controllers;
 
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
 
class InvoiceController extends Controller
{
    public function download(Request $request, int $id)
    {
        $invoice = Invoice::findOrFail($id);
 
        $data = [
            'number' => $invoice->number,
            'client' => $invoice->client->name,
            'date'   => $invoice->created_at->format('Y-m-d'),
            'items'  => $invoice->items->toArray(),
            'total'  => $invoice->total,
        ];
 
        $pdf = Pdf::loadView('pdf.invoice', $data)
            ->setPaper('a4', 'portrait');
 
        return $pdf->download("invoice-{$invoice->number}.pdf");
    }
}

Symfony integration

In Symfony, render a Twig template and pass the HTML to Dompdf:

<?php
 
namespace App\Controller;
 
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
 
class InvoiceController extends AbstractController
{
    #[Route('/invoice/{id}/pdf', name: 'invoice_pdf')]
    public function pdf(int $id): Response
    {
        $invoice = $this->getInvoice($id); // fetch from DB
 
        $html = $this->renderView('pdf/invoice.html.twig', [
            'invoice' => $invoice,
        ]);
 
        $options = new Options();
        $options->set('defaultFont', 'DejaVu Sans');
        $dompdf = new Dompdf($options);
 
        $dompdf->loadHtml($html);
        $dompdf->setPaper('A4', 'portrait');
        $dompdf->render();
 
        $output = $dompdf->output();
 
        return new Response($output, 200, [
            'Content-Type'        => 'application/pdf',
            'Content-Disposition' => 'attachment; filename="invoice.pdf"',
        ]);
    }
}

Dompdf limitations

Dompdf works well for straightforward layouts. The problems appear at scale or with modern CSS:

  • No CSS Grid support. Complex multi-column layouts require table-based fallbacks.
  • Partial Flexbox. Simple flex rows work, but complex nesting fails unpredictably.
  • Webfonts via URL fail. Google Fonts CDN links don't load. Embed fonts as base64 or use bundled fonts (DejaVu, Helvetica, Courier are built in).
  • Slow on complex documents. A multi-page report with images and tables can take 3-5 seconds, blocking your PHP-FPM worker.
  • No async processing. Dompdf runs synchronously. High concurrent load means queued requests pile up.

Option 2: Headless Chromium

For layouts that depend on modern CSS (Grid, Flexbox, CSS custom properties, Google Fonts), headless Chromium gives accurate rendering because it is a real browser.

The practical approach in PHP is to shell out to a Node.js script or a standalone chromium binary. Playwright is the cleanest way to manage this.

Setup with Node.js subprocess

Install Playwright in a node/ subdirectory alongside your PHP app:

mkdir node && cd node
npm init -y
npm install playwright
npx playwright install chromium

Create node/html-to-pdf.mjs:

import { chromium } from 'playwright';
 
const html = process.argv[2];
 
const browser = await chromium.launch();
const page = await browser.newPage();
 
await page.setContent(html, { waitUntil: 'networkidle' });
 
const pdf = await page.pdf({
  format: 'A4',
  margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
  printBackground: true,
});
 
await browser.close();
 
process.stdout.write(pdf);

Call it from PHP:

<?php
 
function htmlToPdfChromium(string $html): string
{
    $escapedHtml = escapeshellarg($html);
    $script      = __DIR__ . '/node/html-to-pdf.mjs';
 
    $output = shell_exec("node $script $escapedHtml");
 
    if ($output === null) {
        throw new \RuntimeException('PDF generation failed');
    }
 
    return $output;
}
 
$html = '<html><body><h1>Hello from Chromium</h1></body></html>';
$pdf  = htmlToPdfChromium($html);
 
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="output.pdf"');
echo $pdf;

The production problems with running Chromium yourself

The subprocess approach works in development. In production, several issues emerge:

Docker image size. Chromium adds roughly 300 MB to your Docker image. This inflates pull times, registry storage costs, and cold starts.

Worker limits. Each PDF request spawns a Node.js process. Under load, you exhaust PHP-FPM workers waiting for subprocess output. A 2-second PDF at 50 concurrent requests means 100 blocked workers.

Deployment complexity. Chromium requires specific system libraries (libgbm, libnss3, libxss1, others) that vary between distributions. Keeping a consistent environment across local, staging, and production takes ongoing maintenance.

Serverless incompatibility. Laravel Vapor, Bref, and other PHP serverless environments have strict binary and memory limits. A 300 MB Chromium binary often exceeds Lambda layer limits.

This is the inflection point where running the browser yourself stops being worth it.

Option 3: PDF4.dev

PDF4.dev takes your HTML and returns a PDF. You send an HTTP request, you get bytes back. No browser to install, no subprocess to manage, no Docker layer to maintain.

PDF4.dev uses headless Chromium on the backend, so you get the same rendering quality as running Chromium yourself, without the infrastructure.

Plain PHP

<?php
 
function generatePdf(string $html, array $format = []): string
{
    $apiKey  = getenv('PDF4_API_KEY');
    $payload = json_encode([
        'html'   => $html,
        'format' => $format ?: ['preset' => 'a4'],
    ]);
 
    $ch = curl_init('https://pdf4.dev/api/v1/render');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $payload,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . $apiKey,
            'Content-Type: application/json',
        ],
    ]);
 
    $response   = curl_exec($ch);
    $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
 
    if ($statusCode !== 200) {
        $error = json_decode($response, true);
        throw new \RuntimeException(
            'PDF API error: ' . ($error['error']['message'] ?? 'Unknown error')
        );
    }
 
    return $response;
}
 
// Generate a PDF from raw HTML
$html = '<html><body><h1>Invoice #001</h1><p>Total: $1,500.00</p></body></html>';
$pdf  = generatePdf($html, ['preset' => 'a4']);
 
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="invoice.pdf"');
echo $pdf;

Laravel with a template

The cleanest pattern for Laravel: render a Blade view to HTML, send it to the API.

<?php
 
namespace App\Services;
 
use Illuminate\Support\Facades\Http;
 
class PdfService
{
    public function fromHtml(string $html, array $format = []): string
    {
        $response = Http::withToken(config('services.pdf4.key'))
            ->post('https://pdf4.dev/api/v1/render', [
                'html'   => $html,
                'format' => $format ?: ['preset' => 'a4'],
            ]);
 
        if ($response->failed()) {
            throw new \RuntimeException(
                'PDF generation failed: ' . $response->json('error.message')
            );
        }
 
        return $response->body();
    }
}

Add your API key to config/services.php:

'pdf4' => [
    'key' => env('PDF4_API_KEY'),
],

Use it in a controller:

<?php
 
namespace App\Http\Controllers;
 
use App\Services\PdfService;
use Illuminate\Http\Response;
 
class InvoiceController extends Controller
{
    public function __construct(private PdfService $pdf) {}
 
    public function download(int $id): Response
    {
        $invoice = Invoice::with('items', 'client')->findOrFail($id);
 
        $html = view('pdf.invoice', ['invoice' => $invoice])->render();
 
        $bytes = $this->pdf->fromHtml($html, [
            'preset'  => 'a4',
            'margins' => ['top' => '20mm', 'bottom' => '20mm', 'left' => '15mm', 'right' => '15mm'],
        ]);
 
        return response($bytes, 200, [
            'Content-Type'        => 'application/pdf',
            'Content-Disposition' => "attachment; filename=\"invoice-{$invoice->number}.pdf\"",
        ]);
    }
}

The Blade template can now use any CSS, including Grid, Flexbox, and Google Fonts via <link> tag, because PDF4.dev renders it in a full Chromium browser.

Using a saved template (optional)

If you generate the same document type repeatedly, save the template once in PDF4.dev and pass only the dynamic data at render time:

$response = Http::withToken(config('services.pdf4.key'))
    ->post('https://pdf4.dev/api/v1/render', [
        'template_id' => 'invoice',
        'data' => [
            'number' => $invoice->number,
            'client' => $invoice->client->name,
            'total'  => number_format($invoice->total, 2),
            'items'  => $invoice->items->toArray(),
        ],
    ]);

The template stores the HTML with Handlebars {{variable}} syntax. You send only the data, not the full HTML on every request.

Comparing the three approaches

DompdfHeadless ChromiumPDF API (PDF4.dev)
Setup time2 minutes30-60 minutes5 minutes
System depsNoneNode.js + Chrome binaryNone
CSS GridNoYesYes
CSS FlexboxPartialYesYes
Google FontsNoYesYes
Render speed500ms-2s200-500msUnder 300ms
ConcurrencyPHP worker-limitedProcess-limitedUnlimited
Docker size impactMinimal+300 MBNone
ServerlessYesDifficultYes
Ongoing maintenanceLowHigh (Chrome updates, libs)None
Scales to 10k PDFs/dayNoWith a queue, maybeYes
Cost at scaleServer CPUServer CPU + ops timePredictable per-render

Which approach to use

The default choice for most projects is PDF4.dev. You get full Chromium rendering, zero infrastructure, and it works in every environment (Laravel Vapor, serverless, Docker, plain VPS). The only reasons to self-host are strict data residency requirements or wanting to avoid any external HTTP dependency.

Use Dompdf only when:

  • Your layout is simple tables and basic styles with no Flexbox or Grid
  • You have a hard requirement against external HTTP calls
  • Volume is under ~50 PDFs per day and render time doesn't matter

Use headless Chromium only when:

  • You need to render JavaScript-heavy pages (charts rendered client-side, React components)
  • You already have a Node.js process in your stack to reuse
  • You have the ops capacity to manage Chrome binary updates, system library drift, and Docker image bloat

Use PDF4.dev (the default) when:

  • Your templates use any modern CSS
  • You're on any serverless or constrained environment
  • You want consistent sub-300ms renders without managing a browser
  • You'd rather spend engineering time on your product than on PDF infrastructure

For most Laravel and Symfony projects, switching from Dompdf to PDF4.dev is a one-hour refactor: replace the Dompdf::render() call with an HTTP request to the API. Your existing Blade templates work without modification, and you gain full CSS support and unlimited concurrency.

Get started

Create a free PDF4.dev account, generate an API key from the settings page, and replace your Dompdf render with the PdfService above. Your existing Blade templates work without modification.

Free tools mentioned:

Html To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.