Create a Custom Builder - A GotenbergBundle Story

· Steven Renaux · Expertise · 7 minutes to read
A man sculpting a rock with PDF written on it

In a previous article, we explored how to generate your first PDF in a few lines of code using Gotenberg and GotenbergBundle, a Symfony bundle that wraps Gotenberg's HTTP API to convert HTML or Office files into PDFs. That was a great start. But what happens when your application needs to generate multiple different PDFs, each with its own layout, styles, data?

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 this commit

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 this commit

Create a custom builder class that extends AbstractBuilder and implements BuilderAssetInterface.

<?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 the generate method. It also stores all the configurations you need such as margin, 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 constant HtmlPdfBuilder::ENDPOINT since the Gotenberg API endpoint is the same as the one used in HtmlPdfBuilder.

If you want to generate PDF, the available traits are:

  • AssetTrait Includes methods to add assets.

  • ContentTrait Includes methods to add different content parts to your PDF.

  • CookieTrait Includes methods to set, add and forward cookies to Gotenberg API.

  • CustomHttpHeadersTrait Includes methods to add header  to Gotenberg API.

  • EmulatedMediaTypeTrait Includes a method to emulate screen or print.

  • FailOnTrait Includes methods to customize behavior on invalid status code.

  • PdfPagePropertiesTrait Includes methods to customize PDF rendering.

  • PerformanceModeTrait Method to not wait for Chromium network to be idle.

  • WaitBeforeRenderingTrait Includes methods to add delay before converting it to PDF.

  • DownloadFromTrait Includes a method to add external resources.

  • MetadataTrait Includes a method to add metadata.

  • PdfFormatTrait Includes methods about PDF formats.

  • SplitTrait Includes methods to split PDF.

  • WebhookTrait Includes methods to use webhooks.

And all of them are combined into ChromiumPdfTrait.If you want to make a custom builder about office, screenshot… you can find out all available traitsin the GotenbergBundle source on GitHub.So let’s add AssetTrait that will implement the addAsset 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 as header and content we use into the controller or footer configured into the configuration file sensiolabs_gotenberg.yaml.

And PdfPagePropertiesTrait 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 this commit

You just need to update the name of the builder. The name is the one you configured in the WithBuilderConfiguration 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: true

Step 4: Update the controller

TL:DR see this commit

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 this commit

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 the build method of your Kernel class (see Symfony's kernel documentation if needed).

First, we retrieve the 'sensiolabs_gotenberg' extension, then we only need to call registerBuilder 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 this commit

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 run php 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. InvoicePdfBuilder owns 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')], your sensiolabs_gotenberg.yaml reads 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 🚀

Ready to create your own Custom Builder?

Our team of Symfony and PHP experts strongly involved in GotenbergBundle is here to help you create and implement your Custom Builder the best way.

This might also interest you

Nicolas Grekas standing on stage at SymfonyLive Paris 2026
Jules Daunay

SymfonyLive Paris 2026: AI Revolution and a Peak Reunion for Team SensioLabs

The final curtain has fallen on SymfonyLive Paris 2026, and we're still buzzing ✨ (and seeing lines of code). As Symfony's creator and a long-time core sponsor, SensioLabs couldn't have picked a better moment to celebrate open source, innovation, and, most importantly, the amazing community that supports us.

Read more
Why PHP?
Silas Joisten

Why PHP Powers the Enterprise Web: The Strategic Advantage Companies Cannot Ignore

PHP remains one of the most reliable and cost effective backend technologies for enterprise systems.

Read more
Symfony UX training
Elise Hamimi

Boost Your Interfaces: Learn Symfony UX with the New Official Training by SensioLabs

In just a few years, Symfony UX has become a favorite among Symfony users. Perfectly aligned with modern  developers’ priorities, it allows you to easily build interactive, high-performance interfaces without leaving the comfort of the framework. It was time to bring this to our training catalog. That’s why we are proud to officially launch our new Symfony UX training program.

Read more
Fabien Potencier
Elise Hamimi

SymfonyCon Amsterdam 2025: Our Recap and the Highlights

After an iconic first edition in 2019, SymfonyCon made its big comeback to Amsterdam. From the start, you could feel the energy of a highly anticipated conference: more than 1,200 attendees, 39 nationalities, the biggest Symfony community reunion of the year, great discoveries... and a fun atmosphere. This year was extra special because it was the 20th anniversary of Symfony. SensioLabs was there: we'll tell you all about our experience there!

Read more
The SensioLabs team celebrating the 20th anniversary of Symfony with balloons
Jules Daunay

The Story Continues: SensioLabs Celebrates Symfony's 20th Anniversary

Time flies, especially when you're busy shaping the future of development! The SensioLabs team has just reached a milestone with the anniversary of the Symfony framework. We marked the occasion at the office, but the party isn't over yet. The date is already set for an XXL celebration at SymfonyCon Amsterdam 2025, from November 27 to 28.

Read more
PHP 8.5 URI extension
Oskar Stark

PHP 8.5's New URI Extension: A Game-Changer for URL Parsing

PHP 8.5 introduces a powerful new URI extension that modernizes URL handling. With support for both RFC 3986 and WHATWG standards, the new Uri class provides immutable objects, fluent interfaces, and proper validation - addressing all the limitations of the legacy parse_url() function. This guide shows practical before/after examples and explains when to use each standard.

Read more
3 dog heads
Mathieu Santostefano

Bring Your Own HTTP client

Break free from rigid dependencies in your PHP SDKs. Learn how to use PSR-7, PSR-17, and PSR-18 standards along with php-http/discovery to allow users to bring their favorite HTTP client, whether it's Guzzle, Symfony HttpClient, or another. A must-read for PHP and Symfony developers.

Read more
Blue sign on a building with several Now What? letters
Thibaut Chieux

How To Prioritize Messages When Building Asynchronous Applications With Symfony Messenger

Asynchronous processing offers benefits like decoupled processes and faster response times, but managing message priorities can become a challenge. When dealing with tasks ranging from password resets to complex exports, ensuring timely delivery of critical messages is essential. This article explores common asynchronous processing issues and provides solutions using Symfony Messenger, allowing you to optimize your application without extensive refactoring.

Read more
Image