
Create a Custom Builder – A GotenbergBundle Story
Previous article >> find it here
You quickly end up with duplicated configuration, cluttered services, and conditional logic that’s hard to maintain. This article picks up exactly where we left off. We’ll build a Custom Builder — a dedicated class that encapsulates all the configuration and logic for a specific PDF type. By the end, your controller will be reduced to a single expressive call, and each PDF type will live in its own clean, testable class.
What is the purpose of a Custom Builder?
To make life easier, when you generate multiple PDF. If you generate multiple PDFs across your application, the configuration sometimes can’t be shared between all of them—mostly because of differences in styling.
This is where the Builder pattern comes into play. The Builder pattern is a design pattern that helps you construct complex objects step by step, separating how the object is built from its final representation. In this context, it allows you to encapsulate all the configuration and logic for a specific PDF type into a dedicated builder class.
By creating a custom Builder, you avoid cluttering your service with conditional logic or duplicating code. Instead, each custom builder handles its own configuration cleanly and efficiently, making your codebase easier to maintain and extend.
Step 1: Update the dependencies
TL;DR see thiscommit
First, you need to update your dependencies to get the latest version of GotenbergBundle. To finally get the v1.2 which is not experimental anymore.
Yeah!🍾
composer require sensiolabs/gotenberg-bundle:1.2.*Step 2: Create a custom builder Class
TL;DR see thiscommit
Create a custom builder class that extendsAbstractBuilder and implementsBuilderAssetInterface.
<?php
namespace App\Pdf;
use Sensiolabs\GotenbergBundle\Builder\AbstractBuilder;
use Sensiolabs\GotenbergBundle\Builder\BuilderAssetInterface;
final class InvoicePdfBuilder extends AbstractBuilder implements BuilderAssetInterface
{
protected function getEndpoint(): string
{
// TODO: Implement getEndpoint() method.
}
public function addAsset(string $path): static
{
// TODO: Implement addAsset() method.
}
}All native builders extend AbstractBuilder which defines thegenerate method. It also stores all the configurations you need such asmargin,width… and prepares the payload before sending it to Gotenberg API.
Because you add assets into the Twig template you need to implement the BuilderAssetInterface.
Add the attribute#[WithBuilderConfiguration(‘pdf’, ‘invoice’)] on the top of the class.
<?php
namespace App\Pdf;
use Sensiolabs\GotenbergBundle\Builder\AbstractBuilder;
use Sensiolabs\GotenbergBundle\Builder\Attributes\WithBuilderConfiguration;
use Sensiolabs\GotenbergBundle\Builder\BuilderAssetInterface;
#[WithBuilderConfiguration('pdf', 'invoice')]
final class InvoicePdfBuilder extends AbstractBuilder implements BuilderAssetInterface
{
// rest of the code
}This attribute will help you to get a semantic configuration for this custom builder.The first argument is to inject this new type of builder into the ‘pdf’ or ‘screenshot’ section. And the second is the naming you want.Let’s implement the methods now.
About the getEndpoint method, let’s use the existing constantHtmlPdfBuilder::ENDPOINT since the Gotenberg API endpoint is the same as the one used inHtmlPdfBuilder.
If you want to generate PDF, the available traits are:
-
AssetTraitIncludes methods to add assets. -
ContentTraitIncludes methods to add different content parts to your PDF. -
CookieTraitIncludes methods to set, add and forward cookies to Gotenberg API. -
CustomHttpHeadersTraitIncludes methods to add header to Gotenberg API. -
EmulatedMediaTypeTraitIncludes a method to emulate screen or print. -
FailOnTraitIncludes methods to customize behavior on invalid status code. -
PdfPagePropertiesTraitIncludes methods to customize PDF rendering. -
PerformanceModeTraitMethod to not wait for Chromium network to be idle. -
WaitBeforeRenderingTraitIncludes methods to add delay before converting it to PDF. -
DownloadFromTraitIncludes a method to add external resources. -
MetadataTraitIncludes a method to add metadata. -
PdfFormatTraitIncludes methods about PDF formats. -
SplitTraitIncludes methods to split PDF. -
WebhookTraitIncludes methods to use webhooks.
And all of them are combined intoChromiumPdfTrait.If you want to make a custom builder about office, screenshot… you can find out all available traitsin theGotenbergBundle source on GitHub.So let’s addAssetTrait that will implement theaddAsset method for us. Under the hood, this method stores for us the assets that come from Twig templates, or the one added on the fly.
ContentTrait is to get the possibility to use the method asheader andcontent we use into the controller or footer configured into the configuration file sensiolabs_gotenberg.yaml.
AndPdfPagePropertiesTrait is for all methods about margins, landscape, paper width … and the PDF render customization.
<?php
namespace App\Pdf;
use Sensiolabs\GotenbergBundle\Builder\AbstractBuilder;
use Sensiolabs\GotenbergBundle\Builder\Attributes\WithBuilderConfiguration;
use Sensiolabs\GotenbergBundle\Builder\Behaviors\Chromium\AssetTrait;
use Sensiolabs\GotenbergBundle\Builder\Behaviors\Chromium\ContentTrait;
use Sensiolabs\GotenbergBundle\Builder\Behaviors\Chromium\PdfPagePropertiesTrait;
use Sensiolabs\GotenbergBundle\Builder\BuilderAssetInterface;
use Sensiolabs\GotenbergBundle\Builder\Pdf\HtmlPdfBuilder;
#[WithBuilderConfiguration('pdf', 'invoice')]
final class InvoicePdfBuilder extends AbstractBuilder implements BuilderAssetInterface
{
use AssetTrait;
use ContentTrait;
use PdfPagePropertiesTrait;
protected function getEndpoint(): string
{
return HtmlPdfBuilder::ENDPOINT;
}
}Step 3: Update the configuration file
TL:DR see thiscommit
You just need to update the name of the builder. The name is the one you configured in theWithBuilderConfiguration attribute.
sensiolabs_gotenberg:
http_client: 'gotenberg.client'
default_options:
pdf:
- html:
+ invoice:
footer:
template: 'footer.html.twig'
paper_width: '21cm'
paper_height: '29.7cm'
margin_top: '6cm'
margin_bottom: '2cm'
landscape: trueStep 4: Update the controller
TL:DR see thiscommit
As we did for the configuration, 2 lines have to be updated to use it.
#[Route('/pdf', 'pdf')]
public function pdf(GotenbergPdfInterface $gotenbergPdf): Response
{
$invoiceData = $this->invoiceData();
- return $gotenbergPdf
- ->html()
+ return $gotenbergPdf->get(InvoicePdfBuilder::class)
->header('header.html.twig', [
'invoice' => $invoiceData['invoice'],
'client' => $invoiceData['client'],
])
->content('content.html.twig', [
'purchases' => $invoiceData['purchases'],
'invoice' => $invoiceData['invoice'],
])
->generate()
->stream()
;
}Step 5: Let’s make it work💪
TL:DR see thiscommit
If you have GotenbergBundle lower than v1.2 you need to add the configurator.
# services.yaml
services:
App\Pdf\InvoicePdfBuilder:
configurator: '@sensiolabs_gotenberg.builder_configurator'Or since the version v1.0.1 you don’t need to do it on your own.Just register it in thebuild method of yourKernel class (see Symfony’s kernel documentation if needed).
First, we retrieve the'sensiolabs_gotenberg' extension, then we only need to callregisterBuilder method with the custom builder FQCN as argument. Et voilà!
class Kernel extends BaseKernel
{
use MicroKernelTrait;
protected function build(ContainerBuilder $container): void
{
/** @var SensiolabsGotenbergExtension $extension */
$extension = $container->getExtension('sensiolabs_gotenberg');
$extension->registerBuilder(InvoicePdfBuilder::class);
}
}And all works like a charm.
Step 6: Wait… we forgot the whole point!🤦
TL:DR see thiscommit
Whooo, we’ve been so focused on creating a new builder we forgot the actual purpose of a custom builder: encapsulating the logic.
Right now, the controller still knows too much — it fetches the invoice data, passes it to the header, passes it to the content… That’s exactly the kind of responsibility that should live inside InvoicePdfBuilder.
Let’s move all of that.
#[WithBuilderConfiguration('pdf', 'invoice')]
final class InvoicePdfBuilder extends AbstractBuilder implements BuilderAssetInterface
{
use AssetTrait;
use ContentTrait;
use PdfPagePropertiesTrait;
public function invoice(): self
{
$invoiceData = $this->invoiceData();
$this->header('header.html.twig', [
'invoice' => $invoiceData['invoice'],
'client' => $invoiceData['client'],
]);
$this->content('content.html.twig', [
'purchases' => $invoiceData['purchases'],
'invoice' => $invoiceData['invoice'],
]);
return $this;
}
protected function getEndpoint(): string
{
return HtmlPdfBuilder::ENDPOINT;
}
private function invoiceData(): array
{
$factory = Factory::create();
$allPurchases = [];
for ($i = 0; $i < 20; $i++) {
$allPurchases[] = [
'orderId' => $factory->unixTime(),
'period' => $factory->dateTimeBetween('- 1 week')->format('Y-m-d') . ' - ' . $factory->dateTime('now')->format('Y-m-d'),
'description' => $factory->sentence(),
'price' => $factory->randomFloat(2, 1),
'quantity' => $factory->randomDigitNotZero(),
'total' => $factory->randomFloat(2, 1),
];
}
return [
'invoice' => [
'id' => $factory->unixTime(),
'date' => $factory->dateTime()->format('Y-m-d'),
'due_date' => $factory->dateTime('+1 week')->format('Y-m-d'),
'sub_total' => $factory->randomFloat(2, 1),
'total' => $factory->randomFloat(2, 1),
],
'client' => [
'phone_number' => $factory->e164PhoneNumber(),
'name' => $factory->company(),
'address' => $factory->address(),
'city' => $factory->city(),
],
'purchases' => $allPurchases
];
}
}Now the controller becomes really simple as wanted in the best practice of Symfony.
#[Route('/pdf', 'pdf')]
public function pdf(GotenbergPdfInterface $gotenbergPdf): Response
{
- $invoiceData = $this->invoiceData();
-
- return $gotenbergPdf->get(InvoicePdfBuilder::class)
- ->header('header.html.twig', [
- 'invoice' => $invoiceData['invoice'],
- 'client' => $invoiceData['client'],
- ])
- ->content('content.html.twig', [
- 'purchases' => $invoiceData['purchases'],
- 'invoice' => $invoiceData['invoice'],
- ])
- ->generate()
- ->stream()
- ;
+ return $gotenbergPdf->get(InvoicePdfBuilder::class)
+ ->invoice()
+ ->generate()
+ ->stream()
+ ;
}The controller no longer knows anything about templates or data structure. It just says “give me the invoice PDF” and the builder handles the rest. That’s the whole point.🎯
Conclusion
Now when you runphp bin/console debug:config sensiolabs_gotenberg, you’ll see your custom builder’s configuration neatly integrated alongside the native ones — a good sign that everything is properly wired.
More importantly, look at what we’ve achieved:
-
The controller is clean. It no longer knows anything about templates, data structure, or rendering logic. It simply asks for an invoice PDF and gets one.
-
The logic is encapsulated.
InvoicePdfBuilderowns everything related to invoice PDFs: the endpoint, the traits it needs, the templates, the data. If the invoice layout changes tomorrow, you know exactly where to go. -
The configuration is semantic. Thanks to
#[WithBuilderConfiguration('pdf', 'invoice')], yoursensiolabs_gotenberg.yamlreads naturally, and you can configure margins, footer, or paper size per builder without any workaround. -
It scales. Need a
ReportPdfBuilder? Follow the same steps. Each PDF type gets its own builder, its own configuration block, and zero interference with the others.
This is the Builder pattern at its best in a Symfony context: predictable, maintainable, and easy to extend. Give it a try in your next project and let us know how it goes🚀
PakarPBN
A Private Blog Network (PBN) is a collection of websites that are controlled by a single individual or organization and used primarily to build backlinks to a “money site” in order to influence its ranking in search engines such as Google. The core idea behind a PBN is based on the importance of backlinks in Google’s ranking algorithm. Since Google views backlinks as signals of authority and trust, some website owners attempt to artificially create these signals through a controlled network of sites.
In a typical PBN setup, the owner acquires expired or aged domains that already have existing authority, backlinks, and history. These domains are rebuilt with new content and hosted separately, often using different IP addresses, hosting providers, themes, and ownership details to make them appear unrelated. Within the content published on these sites, links are strategically placed that point to the main website the owner wants to rank higher. By doing this, the owner attempts to pass link equity (also known as “link juice”) from the PBN sites to the target website.
The purpose of a PBN is to give the impression that the target website is naturally earning links from multiple independent sources. If done effectively, this can temporarily improve keyword rankings, increase organic visibility, and drive more traffic from search results.
