If you installed sylius/invoicing-plugin and then went
looking for why your container needs a wkhtmltopdf binary,
this is the chain you found:
InvoicePdfFileGenerator::generate(InvoiceInterface $invoice)
└─ TwigToPdfRendererInterface::render(...) # sylius/pdf-generation-bundle
└─ the knp_snappy adapter # the plugin's default
└─ knplabs/knp-snappy-bundle # a wrapper
└─ the wkhtmltopdf binary # QtWebKit, archived
That is still true on the current release.
sylius/invoicing-plugin
v2.3.0, released 2026-10-08, requires
knplabs/knp-snappy-bundle ^1.10 and
sylius/pdf-generation-bundle ^1.0 on PHP 8.2. Both
constraints were re-read from Packagist’s metadata API on
2026-11-05 and are unchanged. The snappy bundle is maintained
— v1.10.6 landed 2026-01-07. What it wraps is not: wkhtmltopdf’s
own upstream archived the project, and the renderer inside it is a
QtWebKit from a decade ago.
So the bundle is alive and the engine underneath it isn’t. That’s worth being precise about, because “unmaintained” gets thrown at the wrong layer. Nobody has abandoned your Symfony integration. The HTML engine it drives is the frozen part.
One version detail, since it tells you where to look in your own
composer.lock: the sylius/pdf-generation-bundle
layer in that chain is newer than the plugin. v2.1.1 and earlier required
only the snappy bundle; the pdf-generation-bundle requirement arrives in
v2.2.0, 2026-05-06, which preconfigures it with the
knp_snappy adapter so nothing changes on upgrade. On an
older store the plugin calls snappy through its own
TwigToPdfGenerator instead — deprecated since 2.2
— and the seam below is the same.
Whether this is urgent: probably not, and here’s the honest version
A QtWebKit renderer that has stopped changing is, for an invoice, mostly fine. Invoices are simple by nature: a header, a table, totals, some fine print. They don’t need grid layout or modern font loading. Plenty of Sylius stores have run this path for years without a single rendering bug, and if yours is one of them, close this tab.
Three things do eventually bite, in roughly this order:
- Nobody is fixing anything. The next CSS feature your designer wants in the invoice template either already works or never will.
- The binary is a deployment dependency. An OS package, pinned, in every image, with its own fonts. It’s the line in your Dockerfile that breaks when the base image moves.
- Security posture questions get awkward. An archived C++ renderer parsing anything derived from user input is a conversation you will have with somebody eventually, even if the risk is modest in practice.
None of that is an emergency. It’s a slow tax, and the useful thing is knowing where you’d pay to stop it.
The seam, and it’s unusually clean
This is the part worth the page. The plugin was built so that the PDF generator is replaceable without forking anything. Here is the interface, verbatim:
namespace Sylius\InvoicingPlugin\Generator;
use Sylius\InvoicingPlugin\Entity\InvoiceInterface;
use Sylius\InvoicingPlugin\Model\InvoicePdf;
interface InvoicePdfFileGeneratorInterface
{
public function generate(InvoiceInterface $invoice): InvoicePdf;
}
One method. It takes the Invoice entity and returns an
InvoicePdf. That’s the whole contract.
And the shipped implementation shows you what it does with it:
public function generate(InvoiceInterface $invoice): InvoicePdf
{
$filename = $this->invoiceFileNameGenerator->generateForPdf($invoice);
$logoPath = $this->fileLocator->locate($this->invoiceLogoPath);
$templateParams = [
'invoice' => $invoice,
'channel' => $invoice->channel(),
'invoiceLogoPath' => $logoPath,
'invoiceLogo' => $this->buildLogoDataUri($logoPath),
];
// the deprecated pre-2.2 renderer is handled in an else-branch, omitted
$pdf = $this->twigToPdfRenderer->render($this->template, $templateParams, 'sylius_invoicing');
return new InvoicePdf($filename, $pdf);
}
That is src/Generator/InvoicePdfFileGenerator.php on the
plugin’s 2.3 branch, read 2026-11-08.
Notice what the HTML is here: an implementation detail below the seam. The Twig template, the snappy bundle and the binary all live inside this one method. Above it, the plugin only ever asks for “a PDF of this invoice”.
That matters for how much work a replacement is. You decorate one service:
# config/services.yaml
App\Invoicing\MyInvoicePdfFileGenerator:
decorates: sylius_invoicing.generator.invoice_pdf_file
Decorate the service id, not the interface. The plugin
wires sylius_invoicing.generator.invoice_pdf_file by id into
its invoice creator and its file provider; the interface is only an alias
pointing at that id. Redefining the alias would reach your own autowired
code and leave every one of the plugin’s callers on snappy. Decorating
the id replaces it everywhere, alias included.
Your class gets the entity, produces the bytes however it likes, and
returns new InvoicePdf($filename, $bytes). Nothing else in
the plugin needs to know: not the invoice creator, not the download
controller, not the invoice_generated e-mail that attaches
the file. That is a
genuinely well-placed extension point and whoever drew it deserves the
credit.
What to replace it with, and the question that decides it
Here’s where most vendor pages would start selling. The decision actually turns on one question, and for a lot of readers the answer sends them away from us.
Do you want to keep your Twig invoice template?
If yes, use a converter, not us
If your invoice.html.twig is customised, translated, carries
a merchant’s branding, and your team edits it, then your template
is the design and you want to keep rendering it. Replace only the
engine: swap snappy for a modern HTML→PDF path. Since 2.2 the engine
sits behind an adapter in sylius/pdf-generation-bundle,
which already lists a Dompdf adapter beside the KnpSnappy one —
no binary, though Dompdf’s CSS support is narrower than a
browser’s. For Chromium (Browsershot, a Gotenberg container) or a
hosted HTML→PDF API, you write that adapter. Either way your Twig
keeps working, the layout mostly survives, and the change stays below
the seam.
That’s the right call and we are the wrong tool for it. iReceipt PRO takes a template plus JSON. It does not take your markup and convert it — there is no raw-HTML endpoint. If you want to keep your HTML, a converter is the right answer and we’ll say so rather than sell around it. We wrote a whole page on what actually breaks when you leave wkhtmltopdf for exactly this path.
If no, the data-in seam is the interesting one
The other case is more common than it sounds, especially at agencies. You didn’t want an invoice template. You inherited one, you’ve fought its page breaks, and what you actually need is: this invoice entity, out comes a correct PDF, and nobody thinks about fonts again.
Look at the interface once more.
generate(InvoiceInterface $invoice). You are handed
data. The HTML only exists because the default
implementation chose to make some. A replacement that takes data and
returns a document fits that signature exactly, with no browser pool, no
binary, no OS package and no Twig template to maintain:
final class ApiInvoicePdfFileGenerator implements InvoicePdfFileGeneratorInterface
{
public function __construct(
private readonly HttpClientInterface $client,
private readonly InvoiceFileNameGeneratorInterface $filenames,
private readonly string $templateId,
private readonly string $apiKey,
) {}
public function generate(InvoiceInterface $invoice): InvoicePdf
{
$variables = [
'number' => $invoice->number(),
'issuedAt' => $invoice->issuedAt()->format('Y-m-d'),
'total' => $invoice->total(),
'currency' => $invoice->currencyCode(),
'channel' => $invoice->channel()->getName(),
'lines' => array_map(fn ($item) => [
'name' => $item->name(),
'quantity' => $item->quantity(),
'total' => $item->total(),
], iterator_to_array($invoice->lineItems())),
];
$response = $this->client->request(
'POST',
'https://api.ireceipt.pro/v1/pdf/private/' . $this->templateId,
[
'auth_bearer' => $this->apiKey,
'json' => ['variables' => $variables],
],
);
return new InvoicePdf(
$this->filenames->generateForPdf($invoice),
$response->getContent(),
);
}
}
Four things in that snippet are worth saying out loud.
-
The route shape is
POST /v1/{format}/{scope}/{templateId}, where format is one ofpdf,jpg,png,webpand scope ispublicorprivate.publicdescribes the template’s visibility, not the auth: a public template still needs the bearer token. -
The body key on the wire is
variables. If you’re reading our JS SDK alongside this, note that it calls the same argumentargs. -
A successful render answers HTTP 201, not 200, with the
file as the response body. Symfony’s
getContent()is fine with that; a hand-rolled=== 200check is not. -
The shape of
$variablesis yours to decide — map the entity to whatever your template expects.
Where this lands for agencies
If you build Sylius stores for merchants, this is a per-project decision you make repeatedly, and the two paths have different ownership. A converter keeps the invoice template in the project repo, which means it stays yours to maintain, per client, forever. A data-in renderer moves the layout out of the repo, which is good if you don’t want to own it and bad if the merchant wants to edit it themselves.
One licensing note, since we’d rather say it up front than in a contract: our terms don’t permit reselling API access or handing your key to a client. The account belongs to whoever operates the store. Generated files are yours to use commercially without restriction. So the pattern that works is the merchant holding their own account, with you integrating it.
What we don’t publish, and you should weigh it
Being even-handed costs us something here, so: we don’t publish a price list on this site (it’s in the dashboard), we don’t publish a latency or uptime figure, and we don’t currently publish a DPA. If you’re specifying this for a merchant in the EU who will ask for Article 28 terms, that’s a real gap and you should know about it before you design it in. Gotenberg in a container of your own, by contrast, has no such question to answer. It’s your infrastructure.
Rendered content isn’t stored (privacy policy), and the same document sets Georgian governing law with a $100 liability cap.
The short version
-
sylius/invoicing-pluginv2.3.0 (2026-10-08) still reaches a wkhtmltopdf binary throughknp-snappy-bundle. The bundle is maintained; the engine under it is archived. - It is not urgent. Invoices are simple documents and QtWebKit renders them fine. The cost is a deployment dependency and a renderer nobody will improve.
-
Replacing it is one interface:
InvoicePdfFileGeneratorInterface, handed the entity, returningInvoicePdf. Decorate the service, don’t fork the plugin. - Keeping your Twig template? Use a converter. We’re the wrong shape and we’d rather tell you.
- Don’t want to own an invoice template at all? Then the seam is handed data, which is the shape we’re built for.