Three ways to turn a Twig template into a PDF in Symfony, all tried on Symfony 8.1.9 and PHP 8.5.4 with the same invoice: dompdf called from a controller (pure PHP), KnpSnappyBundle (wkhtmltopdf) and a small service that sends the HTML to an API through Symfony's HttpClient. We opened each PDF and checked what came out, including a logo added with asset(), the step where most Symfony setups first break.
| Option | Pick it when |
|---|---|
| dompdf Pure PHP, no browser | Simple templates (blocks, tables, floats) and a host where you can only install Composer packages. Set a chroot, or asset() images vanish without an error. |
| KnpSnappyBundle Wraps the wkhtmltopdf binary | Legacy apps that already ship wkhtmltopdf. Not for new projects: the project is archived and Ubuntu 26.04 has no package for it. |
| HttpClient + API Chrome on someone else's server | Your template uses flexbox, grid or web fonts, and you don't want Node or Chrome on your servers. One service class. |
templates/invoice.html.twig. A flexbox header with a logo from public/logo.png, a € price list and a ✓:
<!doctype html>
<html><head><meta charset="utf-8"><style>
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 { break-inside: avoid; }
</style></head><body>
<div class="top">
<img src="{{ asset('logo.png') }}" width="160" height="48" alt="ACME">
<h1>Invoice #{{ number }}</h1><p>Due: {{ due }}</p>
</div>
<table><tr><th>Item</th><th>Price</th></tr>
{% for item in items %}
<tr><td>{{ item.name }}</td><td>€{{ item.price|number_format(2) }}</td></tr>
{% endfor %}
</table>
<p>✓ Paid by card</p>
</body></html>
composer require dompdf/dompdf (tested with 3.1.6). There is no official bundle and you don't need one: render the template with renderView() and hand the HTML to dompdf. Our first try used new Dompdf() with default options and came back in 0.24 s with three problems:
asset('logo.png') writes /logo.png, which dompdf reads as a path from the filesystem root. It raised no exception: the only trace was "Unable to parse image URL /logo.png." in $GLOBALS['_dompdf_warnings'].<table> or floats.?: the built-in fonts lack it (CJK text too). € was fine.The logo fix is one option: set dompdf's chroot to public/, and root-relative paths from asset() resolve there (tested: the 160×48 logo is in the PDF). This is the full controller, including the API action from section 3:
<?php
namespace App\Controller;
use App\Pdf\ShotmintPdf;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class InvoiceController extends AbstractController
{
private const INVOICE = [
'number' => 1042,
'due' => '2026-11-01',
'items' => [
['name' => 'Consulting (3h)', 'price' => 450],
['name' => 'Hosting, Oct.', 'price' => 19],
],
];
#[Route('/invoice/dompdf')]
public function dompdf(): Response
{
$html = $this->renderView('invoice.html.twig', self::INVOICE);
// asset() writes /logo.png: let dompdf read files from public/
$options = new Options();
$options->setChroot($this->getParameter('kernel.project_dir').'/public');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4');
$dompdf->render();
return new Response($dompdf->output(), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT, 'invoice-1042.pdf'),
]);
}
#[Route('/invoice/api')]
public function api(ShotmintPdf $pdf): Response
{
$html = $this->renderView('invoice.html.twig', self::INVOICE);
return new Response($pdf->fromHtml($html), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT, 'invoice-1042.pdf'),
]);
}
}
Leave isRemoteEnabled off unless you need it: with chroot set, local images work without letting templates fetch URLs.
composer require knplabs/knp-snappy-bundle still installs on Symfony 8 (1.10.6 with knp-snappy 1.7.3), but it's only a wrapper: it needs the wkhtmltopdf binary on the server. Ubuntu 26.04 has no package for it (apt-cache policy wkhtmltopdf: Candidate: (none)), and the bundle then fails on the first PDF with RuntimeException: The binary 'wkhtmltopdf' is not executable. The wkhtmltopdf project was archived in 2023 and renders with an old QtWebKit that doesn't do flexbox properly either; see wkhtmltopdf alternatives for what breaks and how to migrate. sensiolabs/gotenberg-bundle (1.4.0) is the maintained Chrome-based option, but it needs a Gotenberg container running next to your app; we didn't run that here (Gotenberg compared).
composer require symfony/http-client, then add this service. Autowiring picks it up; the key and your public URL come from env vars. src/Pdf/ShotmintPdf.php:
<?php
namespace App\Pdf;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ShotmintPdf
{
public function __construct(
private HttpClientInterface $client,
#[Autowire(env: 'SHOTMINT_KEY')] private string $key,
#[Autowire(env: 'APP_PUBLIC_URL')] private string $publicUrl,
) {}
/** Prints HTML in Chrome on the API side and returns the PDF bytes. */
public function fromHtml(string $html, array $options = []): string
{
$response = $this->client->request('POST', 'https://shotmint.creasework.com/v1/pdf', [
'headers' => ['X-API-Key' => $this->key],
'json' => ['html' => $html, 'base_url' => $this->publicUrl, 'format' => 'A4'] + $options,
'timeout' => 60,
]);
if ($response->getStatusCode() !== 200) {
// The JSON error body says what to fix (bad key, no credits, bad option).
throw new \RuntimeException('Shotmint: '.$response->getContent(false));
}
return $response->getContent();
}
}
And in .env.local (never commit the key):
SHOTMINT_KEY=sm_your_key
APP_PUBLIC_URL=https://example.com/
The api() action in the controller above returned the A4 PDF in 0.46 s, measured through Symfony. The header came out as one row, ✓ and € were intact, and the text stays selectable. Errors carry the API's JSON reason: with a wrong key the exception reads Shotmint: {"error":"Missing or invalid API key. …","get_key":"curl -X POST …"}.
Images from asset(): the API loads your HTML from about:blank, so /logo.png only resolves because of base_url. Set APP_PUBLIC_URL to the public origin that serves your public/ folder. Tested with a root-relative image on php.net: no image without base_url, the 200×106 image with it. A local dev URL (http://127.0.0.1:8000) isn't reachable from the API, so on a dev box the logo shows as a broken-image icon; inline such images as data: URIs if they must work there. Any other PDF option goes in $options, e.g. ['margin' => '15mm', 'page_numbers' => true].
| What we checked | Result |
|---|---|
| Flexbox header | API: one row dompdf: stacked; Snappy: no binary to test |
Logo from asset() | dompdf: needs setChroot(public/)API: needs base_url = your public site |
| ✓ and € | API: both kept dompdf: € kept, ✓ became ? |
| Time for this invoice | dompdf 0.24 s API 0.46 s, including the network |
| Server needs | dompdf and API: Composer only Snappy: the wkhtmltopdf binary; Gotenberg: a container |
| Private pages | dompdf: reads local files API: public URLs or the HTML you send |
Plain templates you're happy to write with tables: dompdf with a chroot. Modern CSS, and no wish to run Chrome yourself: the HttpClient service. Don't start new work on Snappy. Want to see the Chrome output before you write any code? Paste your rendered Twig HTML into the free HTML to PDF converter; no key needed. Same comparison for plain PHP: HTML to PDF in PHP; for Laravel: HTML to PDF in Laravel.
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