pontedilana/php-weasyprint
PhpWeasyPrint is a PHP wrapper for WeasyPrint that generates PDFs from URLs or HTML. Inspired by Snappy and built as a drop-in replacement, it offers a strictly typed GeneratorInterface and requires PHP 8.3+ plus WeasyPrint 60 or greater.
1. Install the library and the binary — they are two separate things.
composer require pontedilana/php-weasyprint
The package is only a wrapper; the actual renderer is the Python CLI. Your image/VM needs WeasyPrint 60+ (the current release supports up to WeasyPrint 68 options):
# Dockerfile — Debian/Ubuntu flavour
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv \
libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 \
shared-mime-info fonts-dejavu-core fonts-noto-core \
&& pip3 install --break-system-packages "weasyprint>=60" \
&& weasyprint --version
Verify from inside the app container, as the same user PHP runs as: weasyprint --version. If that fails, nothing below will work.
2. Wire it into the container once. Put the binary path in config, not in controllers:
// config/weasyprint.php
return [
'binary' => env('WEASYPRINT_BINARY', '/usr/local/bin/weasyprint'),
'timeout' => (int) env('WEASYPRINT_TIMEOUT', 60),
];
// app/Providers/AppServiceProvider.php
use Pontedilana\PhpWeasyPrint\Pdf;
public function register(): void
{
$this->app->bind(Pdf::class, fn () => tap(
new Pdf(config('weasyprint.binary')),
fn (Pdf $pdf) => $pdf->setTimeout(config('weasyprint.timeout')),
));
}
Bind as transient (bind, not singleton) — see the state-leak gotcha below.
3. First use case: invoice/report from a Blade view.
public function show(Invoice $invoice, Pdf $pdf)
{
$html = view('pdf.invoice', ['invoice' => $invoice])->render();
return response()->streamDownload(
fn () => print $pdf->getOutputFromHtml($html),
"invoice-{$invoice->number}.pdf",
['Content-Type' => 'application/pdf'],
);
}
Where to look first: GeneratorInterface (it mirrors KnpSnappy's contract: getOutput, getOutputFromHtml, getOutputFromUrl, generate, generateFromHtml, generateFromUrl), then Pdf::setOption() cross-referenced against the output of weasyprint --help on your installed version. The Pontedilana\PhpWeasyPrint\Enum\* classes only cover a handful of constrained options — everything else is a raw CLI flag name.
Treat HTML generation and PDF rendering as two steps. Build the HTML in a Blade view (or a dedicated DocumentBuilder class), then hand a plain string to the generator. This keeps the PDF layer testable — you can assert on the HTML without the binary installed.
Store, don't stream, for anything generated in the background. generateFromHtml() writes straight to a path and avoids holding the whole document in PHP memory:
final class StatementService
{
public function __construct(private Pdf $pdf) {}
public function build(Statement $statement): string
{
$path = "statements/{$statement->id}.pdf";
$html = view('pdf.statement', compact('statement'))->render();
$this->pdf->generateFromHtml($html, Storage::path($path));
return $path;
}
}
Queue heavy documents. Rendering is a subprocess with real latency (hundreds of ms to several seconds). A ShouldQueue job keeps the request cycle clean and lets you apply queue-level retries and timeouts instead of the library's:
final class GenerateInvoicePdf implements ShouldQueue
{
public $timeout = 120;
public $tries = 3;
public function handle(Pdf $pdf): void
{
$pdf->disableTimeout(); // let the queue worker own the timeout
$pdf->generateFromHtml($html, $outputPath);
}
}
Attach to mail directly from the string. This is one of the highest-value day-to-day uses:
Mail::to($invoice->customer)->send(new InvoiceMail(
$invoice,
$pdf->getOutputFromHtml(view('pdf.invoice', compact('invoice'))->render()),
));
// InvoiceMail::build()
->attachData($this->pdfBytes, "invoice-{$this->invoice->number}.pdf", ['mime' => 'application/pdf'])
Use base-url (or <base href>) for any HTML passed as a string. Relative src/href paths in a string have no document URL to resolve against. Either inline assets as data: URIs, pass absolute paths, or:
$pdf->setOption('base-url', config('app.url'));
Keep a dedicated print stylesheet. Don't reuse your Bootstrap web layout. WeasyPrint has excellent paged-media support, so lean on it:
@page {
size: A4;
margin: 20mm 15mm;
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
font-size: 9pt;
}
}
thead { display: table-header-group; } /* repeat headers across pages */
tr { break-inside: avoid; }
Preset options per document type rather than sprinkling setOption calls. Use the enums where they exist:
use Pontedilana\PhpWeasyPrint\Enum\PdfVariant;
// Archival invoices (PDF/A)
$pdf->setOption('pdf-variant', PdfVariant::PdfA3b);
$pdf->setOption('pdf-version', '1.7');
$pdf->setOption('attachment-relationship', 'Data');
// Fast internal reports
$pdf->setOption('optimize-images', true);
$pdf->setOption('quiet', true);
Testing. Because the renderer is an external process, split your tests: unit-test the Blade/HTML output normally, and bind a fake Pdf for feature tests that only need to assert a download was offered. Keep a small number of integration tests that actually invoke the binary, tagged with a group and skipped when WEASYPRINT_BINARY is absent in CI.
The 10-second timeout bites first. Both the Symfony Process timeout and a --timeout CLI flag default to 10s. Large tables, remote images, or a cold Python start will blow through it. Raise it per environment — and note that setTimeout() controls both, while setOption('timeout', null) disables only the WeasyPrint-side flag.
The Pdf instance is mutable state. setOption() mutates the object. If you register it as a singleton, options set in one request leak into the next — an invoice quietly rendered as PDF/A because a previous job asked for it. Bind transient, or wrap in try/finally { $pdf->resetOptions(); }.
Only one input source per render. Unlike Snappy, you cannot pass an array of HTML strings/URLs to merge. If you're migrating from Snappy and rely on merging, restructure into a single HTML document with concatenated sections. Also: image generation (Pontedilana\PhpWeasyPrint\Image) is gone — WeasyPrint 53+ doesn't emit images.
Missing fonts produce silent tofu. WeasyPrint uses fontconfig. fonts-dejavu-core and fonts-noto-core in the image handle 95% of cases; for brand fonts, ship the .ttf/.woff2 into the image and reference it with @font-face using an absolute file:// or storage path plus a correct base-url.
getOutput() returns the entire PDF as a PHP string. For a 300-page statement that's tens of megabytes in memory, plus the subprocess buffer. Prefer generateFromHtml() to a file and serve with response()->file(), or streamDownload() with a callback.
Security defaults are good — keep them. Commands are executed from an argument array, never through a shell, so option values and paths can't inject. Option URLs (attachment, stylesheet) are fetched server-side only for http/https unless you opt in via the fourth constructor argument. If end users supply the HTML or URLs, also constrain WeasyPrint itself:
$pdf->setOption('allowed-protocols', 'http,https,data');
$pdf->setOption('no-http-redirects', true);
$pdf->setOption('fail-on-http-errors', true); // surfaces broken asset URLs as failures
fail-on-http-errors is a double-edged sword — one flaky CDN image turns into a failed invoice. Enable it in staging, not necessarily in production.
Version drift between the library and the binary is the #1 confusing failure. Passing an option the installed WeasyPrint doesn't know yields an "unrecognized arguments" process failure. Pin both: the library version and the WeasyPrint version in your image. Bump them together and re-run the integration suite.
Debugging failing renders. Log $pdf->getCommand() (human-readable, shell-escaped — for logs only, not for execution) and the process stderr, which the library surfaces through the exception message. Then reproduce outside PHP:
weasyprint --base-url=https://app.test --verbose input.html /tmp/out.pdf
Adding setOption('verbose', true) or setOption('debug', true) to the library gets you the same trace in the exception.
Customising beyond options. The extension point is subclassing Pdf/AbstractGenerator. Be aware of the 2.x signature change: executeCommand() now takes array $command, not string, and the binary executability check moved to checkBinary(). If you override these, mirror the array-based contract — never re-introduce a shell string.
Temp files. String input is written to a temp file before rendering. The 2.6.0 release tightened cleanup so only files inside the temp directory are deleted at shutdown — if you set a custom temp dir, make sure it's writable and on a volume with space; long-running queue workers can otherwise accumulate stale PDFs.
How can I help you explore Laravel packages today?