Home / Blog / Sylius invoice PDFs

Sylius invoices still render through wkhtmltopdf. Here’s the seam to replace

The plugin has exactly one clean extension point, and it is handed the invoice entity rather than finished HTML. That changes what you can put behind it.

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:

  1. Nobody is fixing anything. The next CSS feature your designer wants in the invoice template either already works or never will.
  2. 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.
  3. 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.

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

If you want to try it

The five-minute quickstart gets a file onto your disk before you write any integration code, and the API reference has the full surface. If you’d rather just ask whether this fits what you’re building, the address below reaches someone who will tell you when it doesn’t.

Nixie · [email protected]
Updated Nov 8, 2026. AI tools were used in preparing this article.