Create a Custom Builder – A GotenbergBundle Story
1 min read

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:

  • 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 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: true

Step 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.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')], yoursensiolabs_gotenberg.yaml reads naturally, and you can configure margins, footer, or paper size per builder without any workaround.

  • It scales. Need aReportPdfBuilder? 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.

Jasa Backlink

Download Anime Batch

Leave a Reply

Your email address will not be published. Required fields are marked *