pontedilana/weasyprint-bundle
Symfony bundle that integrates WeasyPrint to turn HTML and CSS into high-quality PDFs. Render Twig templates through a simple service, with configurable binary path and clean Symfony integration for invoices, reports, and printable documents.
Read this first: this is a Symfony bundle, not a Laravel package. There is no service provider, no config/weasyprint.php, no facade, and no auto-discovery. composer require pontedilana/weasyprint-bundle in a Laravel app will install the code but register nothing. Your realistic options, in order of effort:
Symfony\Process (which you can require standalone, or just use proc_open/exec). This is what most Laravel teams actually do, and it's ~40 lines of code.spatie/laravel-pdf with the WeasyPrint driver, barryvdh/laravel-dompdf, knplabs/knp-snappy) if you want first-class framework integration.# Debian/Ubuntu — Pango, cairo, gdk-pixbuf are mandatory
sudo apt-get install -y python3-pip libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libgdk-pixbuf-2.0-0
pip install weasyprint==62.* # pin it; CLI flags drift between majors
weasyprint --version
# macOS
brew install weasyprint pango libffi
Verify rendering, not just the version — a missing Pango shows up as a OSError: cannot load library 'libgobject-2.0-0' at render time:
echo '<h1>Hello</h1>' | weasyprint - /tmp/smoke.pdf && open /tmp/smoke.pdf
composer require pontedilana/weasyprint-bundle:^3.1
// config/bundles.php
Pontedilana\WeasyPrintBundle\WeasyPrintBundle::class => ['all' => true],
# config/packages/weasy_print.yaml
weasy_print:
pdf:
binary: '%env(WEASYPRINT_BINARY)%' # absolute path, or a bare name resolved via PATH
process_timeout: 120 # set to false to disable the timeout entirely
allowed_schemes: ['file', 'http', 'https'] # 3.1+ — restricts URL-bearing CLI options
Three things changed here as of 3.1.0 and are worth knowing before you copy an older snippet off a blog:
pdf block (pdf.binary, pdf.process_timeout, pdf.allowed_schemes) — not at the top level. Check DependencyInjection/Configuration.php for your exact installed version, since this has moved between majors.binary name is now resolved against PATH via ExecutableFinder (php-weasyprint 2.5.1+). So binary: weasyprint works where it previously needed an absolute path. Absolute paths are still used as-is and are still the safer default in containers.process_timeout: false disables the process timeout (maps to disableTimeout()), for the 200-page-statement case where any fixed number is a lie.Then inject the generator service and render a Twig template — this is the first use case worth proving out, an invoice/report:
public function invoice(int $id, WeasyPrint $weasyPrint): Response
{
$pdf = $weasyPrint->getOutputFromTemplate('pdf/invoice.html.twig', [
'invoice' => $repository->find($id),
]);
return new Response($pdf, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="invoice.pdf"',
]);
}
Check vendor/pontedilana/weasyprint-bundle/config/services.yaml before writing code — the exact service id and method names (generateFromTemplate, getOutputFromTemplate, generateFromHtml, getOutputFromHtml) are the first thing to confirm, since the bundle deliberately mirrors knplabs/knp-snappy's API.
config/services.yaml and DependencyInjection/Configuration.php — the real config keys, including the pdf.* nesting.weasyprint --help) — the options array you pass through is essentially a passthrough to those flags.Since the bundle is inert in Laravel, wrap the binary in a single injectable class so the rest of the app never touches exec:
namespace App\Services;
use Symfony\Component\Process\Process;
class WeasyPrint
{
public function __construct(
private string $binary = '/usr/local/bin/weasyprint',
private int $timeout = 120,
) {}
public function fromHtml(string $html, array $options = []): string
{
$tmp = tempnam(sys_get_temp_dir(), 'wp_');
file_put_contents($tmp, $html);
$process = new Process(array_merge(
[$this->binary, $tmp, '-'],
$this->normalizeOptions($options),
));
$process->setTimeout($this->timeout);
$process->run();
@unlink($tmp);
if (!$process->isSuccessful()) {
throw new WeasyPrintException($process->getErrorOutput());
}
return $process->getOutput(); // binary PDF on stdout
}
public function fromView(string $view, array $data = [], array $options = []): string
{
return $this->fromHtml(view($view, $data)->render(), $options);
}
}
Passing the HTML via a temp file (rather than - on stdin with a pipe) makes error messages readable and avoids stdin buffer limits on large documents.
If you're mirroring the bundle's behaviour, note two 3.1-era refinements worth copying into your own wrapper: resolve a bare binary name through Symfony\Component\Process\ExecutableFinder instead of assuming PATH is set, and support a null timeout that skips setTimeout() rather than passing 0 (which means "no timeout" in Symfony Process but reads as "instantly kill me" to the next maintainer).
Inject the bundle's generator into a dedicated InvoicePdfBuilder rather than into controllers. The generator should know about layout, not about domain lookup:
final class InvoicePdf
{
public function __construct(
private WeasyPrint $weasyPrint,
private Environment $twig,
) {}
public function render(Invoice $invoice): string
{
return $this->weasyPrint->getOutputFromHtml(
$this->twig->render('pdf/invoice.html.twig', ['invoice' => $invoice]),
['base-url' => 'https://app.example.com/', 'stylesheet' => ['/css/pdf.css']],
);
}
}
allowed_schemes (3.1+)pdf.allowed_schemes restricts the schemes permitted in options that accept URLs — base-url, stylesheet, attachments, and friends — passed as the 4th argument of the Pdf constructor. It is the right tool the moment any part of that options array is influenced by a tenant, a user, or a database column:
weasy_print:
pdf:
allowed_schemes: ['file'] # templates must not reach out to the network at all
Typical postures: ['file'] for fully self-contained/offline rendering, ['file', 'https'] for CDN-hosted fonts and logos, and never ['file', 'http', 'https', 'phar', 'data'] "just to make it work."
Important scoping note: this guards the options array, not the HTML body. WeasyPrint still fetches <img src="..."> and @import URLs it finds in the document. allowed_schemes is not an HTML sanitizer — if user content reaches the template, sanitize it separately.
Two distinct methods, chosen by size and by who's waiting:
| Scenario | Approach |
|---|---|
| User clicks "Download invoice" | Render to temp file, response()->download()->deleteFileAfterSend(true) |
| Bulk monthly statements | ->store() to S3/disc, then email a link |
| Preview in browser | Stream with Content-Disposition: inline |
Never hold a 200-page statement in a PHPDoc response string — write it to disk and stream.
PDF rendering is CPU-bound and blocks a worker for 1–30 seconds. Always dispatch:
class GenerateStatement implements ShouldQueue
{
public int $timeout = 300;
public int $tries = 3;
public function __construct(public int $statementId) {}
public function handle(WeasyPrint $pdf): void
{
$statement = Statement::with('lines')->findOrFail($this->statementId);
Storage::put(
"statements/{$statement->id}.pdf",
$pdf->fromView('pdf.statement', compact('statement'))
);
}
}
Give the job a timeout comfortably larger than the render timeout, otherwise the worker is killed while WeasyPrint keeps running. If you disable the generator timeout with process_timeout: false, the queue worker's --timeout becomes your only backstop — raise it deliberately and keep $tries low, or one pathological document occupies a worker forever.
This is where 90% of broken renders come from. WeasyPrint resolves relative URLs against --base-url. In Laravel, the cleanest trick is to call URL::forceRootUrl(config('app.url')) and pass base-url explicitly, or inline critical CSS into the template with @vite/<style>. For images, asset() produces absolute URLs only if APP_URL is correct — a classic staging-only bug where logos silently vanish.
For file-based assets (fonts, local images) pass base-url as a file:// path:
$pdf->fromView('pdf.report', $data, ['base-url' => public_path().'/']);
WeasyPrint's stylesheet support is CSS 2.1 plus a good chunk of CSS 3, but flexbox and grid are only partially/conditionally supported depending on the version. If layout fidelity matters, use floats, display: table, or inline-block for structure. Test flexbox against your pinned version — layouts that look fine in Chrome and wrong in the PDF is the #1 support ticket for this class of tool.
Page counters, headers, and footers are CSS, not PHP:
@page {
size: A4;
margin: 20mm 15mm;
@bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 9pt; }
@top-left { content: string(doc-title); }
}
h1 { string-set: doc-title content(); }
.invoice-line { page-break-inside: avoid; }
thead { display: table-header-group; } /* repeat table headers across pages */
thead { display: table-header-group } is the one line that makes multi-page tables actually usable.
Don't assert on PDF bytes in unit tests — they contain timestamps and are non-deterministic. Assert on the HTML your builder produces, and have one integration test that shells out and checks %PDF- magic bytes plus a page count:
$this->assertStringStartsWith('%PDF-', $pdf);
If you adopt the bundle's service, bind a fake that returns a canned PDF string so CI doesn't need the Python dependency at all. Add one config test that asserts allowed_schemes doesn't contain http/https if your templates are supposed to be offline — that's the kind of regression that gets reverted silently.
The bundle does nothing in Laravel. composer require will succeed and you'll get a working library class, but nothing wires it up. Don't spend an hour hunting for a missing service provider — there isn't one. Decide early: manual bootstrap, or CLI + wrapper.
Binary path is the most common failure — but less so from 3.1. A bare binary: weasyprint is now resolved against PATH via ExecutableFinder, which fixes the classic "works in my shell, 127 in PHP-FPM" case provided the web user's PATH contains it. In Docker it usually doesn't. Configure the absolute path in production; use the bare name in local dev if you like. Symptom of getting it wrong: The command "weasyprint" failed. Exit Code: 127.
allowed_schemes is silently inert on old php-weasyprint. The feature is effective with php-weasyprint 2.6+. On 2.5.x the config key is accepted, validated, and then does nothing — no warning, no error, just an unprotected renderer. Pin php-weasyprint explicitly in composer.json if you're relying on this for security, and write a smoke test that proves a http:// base-url is actually rejected.
process_timeout: false is not a general fix. It removes a guard rail. Combine it with a queue worker timeout and a memory limit, or a single malformed template will OOM-kill the box instead of failing the request. Disable it per-render via the constructor argument if only a handful of documents are huge, not globally.
Version pinning matters more than usual. WeasyPrint's CLI flags have changed across majors (--pdf-variant, --pdf-forms, --pdf-tags, --optimize-images and --uncompressed-pdf semantics have shifted). If your options array is silently ignored, check weasyprint --help on the server's version, not the docs site's.
Error output is on stderr, and PHP usually swallows it. Always include $process->getErrorOutput() in your exception message. WeasyPrint's Python tracebacks are verbose but they tell you exactly which font, asset, or CSS feature failed.
Fonts are the silent killer. The container has DejaVu and little else. Your brand font renders as Times and nobody notices until a client forwards the PDF. Copy .ttf/.woff files into the image and declare them with @font-face { src: url('file:///app/resources/fonts/Inter.ttf'); } — WeasyPrint supports local @font-face but cannot fetch Google Fonts over the network from within @font-face reliably. Verify with fc-list | grep -i inter. If you've tightened allowed_schemes, remember the font is fetched through that same option path — a file-only allowlist and a https://fonts... @font-face are mutually exclusive.
Remote assets require network egress and correct DNS from the worker. Logo fetches from file:// or http:// both go through WeasyPrint's URL fetcher. In Docker, localhost inside the container is not your nginx service — use the service name or inline the image as a base64 data URI. Base64 is the bulletproof option for logos and watermarks, and the only one that survives an allowed_schemes: ['file'] lockdown.
Timeouts compound. Your render timeout, your queue worker's --timeout, PHP's max_execution_time, and nginx's fastcgi_read_timeout are four separate limits, and the smallest one wins. A 40-page report with 200 images will hit all of them. Auditing these four numbers is the single highest-value 10 minutes you can spend after your first slow render.
Memory scales with document size, not complexity. WeasyPrint loads the full layout tree. 100+ pages of dense tables can exceed a 256MB memory_limit. Raise it in the queue worker config, not globally.
Debug by dumping the HTML. When a render fails or looks wrong, write the exact HTML string to /tmp/debug.html and run weasyprint /tmp/debug.html /tmp/debug.pdf by hand. This isolates "my HTML/CSS is wrong" from "my PHP wrapper is wrong" in seconds. Add a --debug-html style flag to your wrapper for exactly this.
base-url for file:// needs a trailing slash. file:///app/public and file:///app/public/ resolve relative paths differently. Same for asset() URLs with APP_URL missing its trailing slash.
Don't render from inside a DB transaction. Rendering can take seconds; holding a transaction open that long invites lock contention on busy tables. Load the data, commit, then render.
Symfony extension points worth knowing: the bundle follows the Snappy template — if you need to subclass or decorate, the generator is a normal service, so decorates: or a compiler pass replacing the service id works without forking the package. It's small (single-digit classes), so copying the ~150-line generator into app/Services/ and dropping the dependency is a legitimate choice if you're not on Symfony. If you do that, port allowed_schemes and the ExecutableFinder lookup yourself; the 3.1.0 security work is in the library/bundle wiring, not in WeasyPrint's binary.
Upgrading to 3.1.0 is mostly free, with two checks. The Symfony minimum-version bumps (http-foundation, http-kernel, process) can conflict with a Laravel app pinned to older Symfony components — run composer update --dry-run before committing, and expect this to be the reason a legacy app can't take 3.1. And if your YAML still has binary / timeout at the top level rather than under pdf:, reconcile the key names against Configuration.php before assuming process_timeout: false took effect.
WeasyPrint vs. headless Chrome, in one line: WeasyPrint has better pagination, page counters, and running headers; Chrome has infinitely better CSS support. If your designers hand you a Tailwind/flexbox-heavy template, expect pain and budget time for a parallel "print stylesheet" that uses tables and floats.
PDF/A and accessibility: if you need PDF/A-3 or tagged PDFs for archiving or compliance, WeasyPrint supports it (--pdf-variant pdf/a-3b) — but validate the output with verapdf, don't trust the flag alone. Font embedding is required and non-embedded fonts will fail validation.
How can I help you explore Laravel packages today?