Créer un Custom Builder - L'histoire du GotenbergBundle
Nous avons déjà vu comment générer un fichier PDF en quelques lignes de code à l'aide de Gotenberg et de GotenbergBundle, un bundle Symfony. Mais que faire lorsque votre application doit générer plusieurs fichiers PDF différents, chacun avec sa propre mise en page, ses propres styles et ses propres données ?
Lire l'article précédent >> ici
On se retrouve rapidement avec de la configuration dupliquée, des services encombrés et une logique conditionnelle difficile à maintenir. Cet article reprend exactement là où nous nous sommes arrêtés. Nous allons construire un Builder personnalisé (Custom Builder), une classe dédiée qui encapsule toute la configuration et la logique pour un type de PDF spécifique. À la fin, votre contrôleur sera réduit à un seul appel, et chaque type de PDF résidera dans sa propre classe propre et sera testable.
Quel est l'intérêt d'un Custom Builder ?
L'objectif est de vous faciliter la vie lorsque vous générez de nombreux PDF.
Si vous générez plusieurs PDF à travers votre application, la configuration ne peut parfois pas être partagée entre eux, principalement à cause des différences concernant le style.
C’est ici que le Design Pattern Builder entre en jeu. C'est un patron de conception qui vous aide à construire des objets complexes étape par étape, en séparant la construction de l'objet de sa représentation finale. Dans ce contexte, cela vous permet d'encapsuler toute la configuration et la logique d'un type de PDF spécifique dans une classe builder dédiée.
En créant un Builder personnalisé, vous évitez de polluer votre service avec de la logique conditionnelle ou de dupliquer du code. Au lieu de cela, chaque builder gère sa propre configuration de manière propre et efficace, rendant votre codebase plus facile à maintenir et à faire évoluer.
Étape 1 : Mettre à jour les dépendances
TL;DR : voir ce commit
Tout d'abord, vous devez mettre à jour vos dépendances pour obtenir la dernière version du GotenbergBundle. Enfin la v1.2, qui n'est plus expérimentale ! Yeah! 🍾
composer require sensiolabs/gotenberg-bundle:1.2.*Étape 2 : Créer la classe du Custom Builder
TL;DR : voir ce commit
Créez une classe qui étend AbstractBuilder et implémente 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.
}
}Tous les builders natifs étendent AbstractBuilder, qui définit la méthode generate. Celle-ci stocke toutes les configurations nécessaires (margin, largeur, etc.) et prépare le payload avant de l'envoyer à l'API Gotenberg.
Parce que vous ajoutez des assets dans le template Twig, vous devez implémenter l'interface BuilderAssetInterface.
Ajoutez l'attribut #[WithBuilderConfiguration('pdf', 'invoice')] en haut de la classe.
<?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
}Cet attribut vous permettra d'obtenir une configuration sémantique pour ce builder. Le premier argument sert à injecter ce nouveau type de builder dans la section 'pdf' ou 'screenshot'. Le second correspond au nom que vous souhaitez lui donner.
Implémentons maintenant les méthodes. Concernant getEndpoint, utilisons la constante existante HtmlPdfBuilder::ENDPOINT, car l'endpoint de l'API Gotenberg est le même que celui utilisé par le HtmlPdfBuilder.
Si vous voulez générer des PDF, les traits disponibles sont :
AssetTrait: Inclut les méthodes pour ajouter des assets.ContentTrait: Inclut les méthodes pour ajouter différentes parties de contenu à votre PDF.CookieTrait: Méthodes pour définir et transférer des cookies à l'API.CustomHttpHeadersTrait: Méthodes pour ajouter des headers HTTP.EmulatedMediaTypeTrait: Méthode pour émuler le mode screen ou print.FailOnTrait: Permet de personnaliser le comportement en cas de code d'état (status code) invalide.PdfPagePropertiesTrait: Méthodes pour personnaliser le rendu du PDF (tailles, marges...).PerformanceModeTrait: Méthode pour ne pas attendre que le réseau de Chromium soit inactif.WaitBeforeRenderingTrait: Ajoute un délai avant la conversion.DownloadFromTrait: Permet d'ajouter des ressources externes.MetadataTrait: Permet d'ajouter des métadonnées.PdfFormatTrait: Méthodes relatives aux formats PDF.SplitTrait: Méthodes pour découper le PDF.WebhookTrait: Méthodes pour utiliser des webhooks.
Tous ces traits sont combinés dans le ChromiumPdfTrait.
Si vous souhaitez créer un builder personnalisé pour Office ou des captures d'écran, vous trouverez tous les traits disponibles dans le code source du GotenbergBundle sur GitHub.
Ajoutons donc l'AssetTrait qui implémentera la méthode addAsset pour nous. Sous le capot, cette méthode stocke les assets provenant des templates Twig ou ceux ajoutés à la volée.
Le ContentTrait nous donne la possibilité d'utiliser les méthodes header et content comme dans le contrôleur, ou le footer configuré dans le fichier sensiolabs_gotenberg.yaml.
Enfin, le PdfPagePropertiesTrait gère tout ce qui concerne les marges, l'orientation paysage, la largeur du papier et la personnalisation du rendu.
<?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;
}
}Étape 3 : Mettre à jour le fichier de configuration
TL;DR : voir ce commit
Il vous suffit de mettre à jour le nom du builder. Le nom est celui que vous avez configuré dans l'attribut WithBuilderConfiguration.
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Étape 4 : Mettre à jour le contrôleur
TL;DR : voir ce commit
Comme pour la configuration, seules deux lignes doivent être modifiées.
#[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()
;
}Étape 5 : Faire fonctionner le tout 💪
TL;DR : voir ce commit
Si vous utilisez une version du GotenbergBundle inférieure à la v1.2, vous devez ajouter le configurateur manuellement :
# services.yaml
services:
App\Pdf\InvoicePdfBuilder:
configurator: '@sensiolabs_gotenberg.builder_configurator'Cependant, depuis la version v1.0.1, vous pouvez l’automatiser. Enregistrez simplement le builder dans la méthode build de votre classe Kernel.
Il suffit de récupérer l'extension sensiolabs_gotenberg, puis d'appeler la méthode registerBuilder avec le FQCN de votre builder personnalisé. Et le tour est joué !
class Kernel extends BaseKernel
{
use MicroKernelTrait;
protected function build(ContainerBuilder $container): void
{
/** @var SensiolabsGotenbergExtension $extension */
$extension = $container->getExtension('sensiolabs_gotenberg');
$extension->registerBuilder(InvoicePdfBuilder::class);
}
}Étape 6 : Attendez... on a oublié l'essentiel ! 🤦
TL;DR : voir ce commit
Nous étions tellement concentrés sur la création du builder que nous avons oublié son but premier : encapsuler la logique. Actuellement, le contrôleur en sait encore trop : il récupère les données de facturation, les passe au header, les passe au contenu... C’est précisément ce genre de responsabilité qui devrait se trouver dans InvoicePdfBuilder. Déplaçons tout cela.
#[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
];
}
}Désormais, le contrôleur devient extrêmement simple, respectant les best practices de 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()
+ ;
}Le contrôleur ne connaît plus les templates ni la structure des données. Il dit simplement "donne-moi le PDF de la facture" et le builder s'occupe du reste. C'est tout l'intérêt de la démarche 🎯
Conclusion
Désormais, lorsque vous lancez php bin/console debug:config sensiolabs_gotenberg, vous verrez la configuration de votre builder personnalisé parfaitement intégrée aux côtés des builders natifs — le signe que tout est bien câblé.
Voici ce que nous avons accompli :
Le contrôleur est propre. Il ignore tout des templates et de la logique de rendu.
La logique est encapsulée.
InvoicePdfBuildercentralise tout ce qui concerne les factures : l'endpoint, les traits, les templates et les données.La configuration est sémantique. Grâce à l'attribut, votre fichier YAML est naturel et permet de configurer les marges ou le format de papier par builder sans bidouillage.
C’est scalable. Besoin d’un
ReportPdfBuilder? Suivez les mêmes étapes. Chaque type de PDF a son propre bloc de configuration sans interférer avec les autres.
C'est le pattern Builder à son meilleur dans un contexte Symfony : prévisible, maintenable et facile à étendre. Essayez-le dans votre prochain projet 🚀