If you've gone looking for how to put your own PDF renderer into
Shopware 6, you've probably landed on the format-renderer guide, copied
its skeleton, and noticed that it declares a dependency on
html and then fetches the markup out of
RenderState. Three or four write-ups later the conclusion
feels settled: a renderer is handed finished HTML, so whatever you plug
in has to be an HTML-to-PDF converter.
That's what the examples do. It isn't what the contract says, and the difference decides what you're allowed to build.
The abstract class defaults to no dependency
Here's
AbstractDocumentRenderer, trimmed to the part that
matters:
abstract readonly class AbstractDocumentRenderer
{
abstract public function getFormat(): string;
abstract public function getFileExtension(): string;
public function getDependencies(): array
{
return []; // <- the default is nothing
}
abstract public function renderToString(
RenderInput $input,
RenderState $state,
Context $context,
): RenderResult;
}
And here is what every renderer gets, whatever it declares —
RenderInput:
final readonly class RenderInput
{
public string $documentType;
public string $documentNumber;
public DocumentSourceEntity $order; // the order snapshot, public
private array $data; // every data provider's DTO
}
The order is right there, as a public property, on the input
to every renderer. The HTML is not in the signature at all.
It arrives in RenderState only when your renderer puts
'html' in getDependencies() and goes and
asks for it.
Shopware's own
PdfRenderer does exactly that. It overrides the
default to ['html'] and opens with
$state->require(DocumentFormat::HTML->value)->content,
because it wants to run Dompdf over the platform's Twig output. Sensible
default. Still a choice.
So the slot takes either shape
Which one you want depends on what you're actually trying to change, and the two cases pull in opposite directions.
Keep the template, change only the binary
Declare ['html'], take the markup, convert it. WeasyPrint,
headless Chrome, Gotenberg, a hosted HTML-to-PDF service — anything whose
input is HTML and whose output is bytes fits the interface as written.
If your problem is that the renderer underneath you is unmaintained, or slow, or wrong about one CSS property, and the invoice layout itself is fine, stop here. This is the cheaper answer and it is the right one more often than not. Nothing below is an argument against it.
Change the document itself
Declare nothing, take $input->order plus the providers'
data, and build the output from that. The ADR is explicit that you may
displace the built-in renderer rather than sit beside it:
"Extensions could still provide their own renderer and override the
existing ones if desired by giving the tagged service a higher
priority."
This is the path when the pain is the layout rather than the binary — the Twig template, the embedded fonts, the page breaks that move when a line item wraps, the per-country variants, the totals block that three people have edited and nobody wants to touch. It's more work than a converter swap. It's also the only one of the two that takes the template out of your maintenance surface, because in the converter case the template is still yours and still there.
A tell for which case you're in
There's a good one sitting in Shopware's own code.
PdfRenderer::injectPageCount() writes a literal
DOMPDF_PAGE_COUNT_PLACEHOLDER into the footer template,
renders the PDF, then walks the CPDF content stream doing byte-level
str_replace on it. Twice — once for Unicode TrueType fonts,
which encode that string as null-padded UTF-16BE, and once for the
built-in AFM fonts, which encode it as single bytes:
$search = [
$this->insertNullByteBeforeEachCharacter(self::PAGE_COUNT_PLACEHOLDER),
self::PAGE_COUNT_PLACEHOLDER,
];
// …
foreach ($pdf->objects as &$o) {
if ($o['t'] === 'contents') {
$o['c'] = str_replace($search, $replace, (string) $o['c']);
}
}
All of that to print "Page 1 of 3". The docblock notes it's a verbatim port of the v1 implementation, so it survived a ground-up rewrite of the whole document stack unchanged.
This isn't a criticism of Shopware — it's a well-run platform and the hack is contained and commented. It's a diagnosis. A renderer that receives finished markup cannot know how many pages the markup will become, so a question about pagination has to be answered after the fact by editing the output. If your own invoice code has something shaped like that in it, your problem is the layout and not the binary, and the converter swap will leave it exactly where it is.
One note on versions
Everything above is the DocumentV2 namespace, which is
annotated
@experimental stableVersion:v6.8.0 feature:DOCUMENT_GENERATION_REWORK.
The v1 classes carry
#[ExperimentalReplacement(version: 'v6.9.0', …)]. Live
stores are still on v1 today.
v1 draws the altitudes differently and lands in the same place. A v1
renderer implements supports(): string for a document
type — 'invoice', 'delivery_note' —
and
render(array $operations, Context $context, DocumentRendererConfig $config): RendererResult.
InvoiceRenderer loads the orders itself and only then hands
its finished document to a separate file renderer. HTML isn't in that
signature either.
| Version | What you implement | What it receives |
|---|---|---|
| v1 (live today) | a document type renderer | generate operations and a context; it loads the order itself |
| v2 (behind the flag) | a format renderer | RenderInput — order snapshot plus provider data; HTML only on request |
The general version of this, because it isn't about Shopware
A vendor's own subclass is one point in the space its contract permits, and it's reliably the point where the vendor already solved the problem their way. That's usually the problem you turned up wanting to solve differently. So:
The contract is the extension point. The vendor's class is just the first tenant. Open the abstract class or the interface and read what the signature guarantees you, rather than what the shipped example happens to use.
The same read on another platform gives a different answer, and it's
worth doing before you cost anything. Sylius publishes two sockets at
different heights: PdfGenerationAdapterInterface::generate(string
$html) is a converter's, and
InvoicePdfFileGeneratorInterface::generate(InvoiceInterface
$invoice): InvoicePdf one level up is handed the entity. Odoo
gives you neither. Find the renderer slot, then look up a level, then
read the base class — in that order.
Where we fit, and where we don't
We make PDFs and images from templates that live on our side: you call the API with a template ID and a JSON object, and get file bytes back. So on the two cases above:
- Keeping your Twig template and swapping the binary: we are not the answer. There's no raw-HTML endpoint here, and a template on our side can't be handed a finished page. Use a converter — Gotenberg and WeasyPrint are both good and both free.
- Taking the layout out of your repo: that's the case we're built for — stable layout, variable data, high repetition. The integration sits where the data still exists, which in v2 is a renderer that declares no dependency, and in v1 is your own code around the document.
Worth knowing before you evaluate us: rendered content isn't stored (privacy policy), and the same document sets Georgian governing law with a $100 liability cap and offers no DPA. If you're in the EU or anywhere regulated, that last part probably matters more to you than anything above.