Retour

Travailler avec des bundles sous Symfony

Introduction

Il été une fois, il y a fort fort longtemps, ou dans un projet Symfony TOUT était Bundle... Ce n'est plus le cas aujourd'hui, tant cela donnait des projets d'une complexité inutile, mais travailler avec des bundles était extrêmement utile, surtout lorsqu'on ne souhaitait pas devoir réutiliser son code grâce au sacro-saint Ctrl-C - Ctrl-V.

Et bien, le saviez-vous? C'est toujours possible sur les projets récents ! Oui ma bonne dame! A vous les joies de la mutualisation de code à travers vos projets, de la consolidation de fonctionnalités par vos tests transverse à toutes vos applications! Certaines personnes disent même que cela peux vous faire repousser les cheveux, mais pour ça, je m'avancerais pas...

Nous verrons donc dans cet article donc comment est composé un bundle Symfony sur les architectures récentes (on utilisera Symfony 7.1 ici), comment l'intégrer dans un projet directement pour pouvoir le tester, mais également comment l'envoyer sur packagist pour que vous puissiez l'installer magiquement par un simple :

composer install dgarden/solid-bundle

Le Bundle

Plutôt que de rester dans le vague, prenons un exemple de bundle concret. Imaginons donc qu'on souhaite avoir un bundle avec le rôle suivant :

Donner à disposition du développeur un ensemble de classes, outils et listeners permettant de l'aider à une intégration SOLID de son code et de le décharger des actions récurrentes.

On aurait donc dans ce bundle:

  • Des interfaces et des traits pour une intégration communes de champs d'entités les plus communs.
  • Des events listeners associés à certains champs pour faire certaines tâches automatiquement sans que le dev n'ait à les coder.
  • Des commandes supplémentaires pour les actions courantes.
  • D'autres services pouvant être utiles.
  • Et tous ça configurable (longueurs de champs, noms, etc.).

Appelons donc DigitalGardenSolidBundle, "Digital Garden" parce que c'est des GOATs, et "SolidBundle" parce qu'on parle de SOLID dans le speech et que j'ai pas d'inspiration.

Application de dev & initialisation du Bundle

Pour ce tutoriel, on va créer d'abord un projet Symfony "classique", il nous permettra de plus tard tester notre bundle en condition réelle, on verra comment l'intégrer en développement pour ne pas avoir à faire un composer update à chaque fois que les graphistes nous demanderont de bouger un bouton de 3px sur la droite.

Allez donc dans votre dossier de dev et créez un nouveau projet pour tester vos bundles. Perso, je préfère utiliser directement composer, donc :

~$ composer create-project symfony/skeleton:"7.1.x-dev" bundles-dev

On se retrouve donc avec un nouveau dossier bundles-dev contenant notre application de développement Symfony. Ceci n'est pas notre bundle, mais l'application qui va nous servir pour le développement de ce dernier. Nous n'avons donc besoin de rien d'autre dans cette application niveau dépendance pour le moment.

Allez dans votre dossier bundles-dev et créez un nouveau sous-dossier bundles. C'est ce dossier qui contiendra nos bundles en développement, puis donc un dossier SolidBundle pour notre bundle. Vous devriez donc vous retrouver avec une hiérarchie de fichiers comme suit :

bin/
bundles/
└──
SolidBundle/
config/
public/
vendor/
composer.json
composer.lock
symfony.lock

Allez ensuite dans votre répertoire SolidBundle et tapez la commande suivante :

~/bundles-dev/bundles/SolidBundle$ composer init

Composer vous posera alors un ensemble de questions pour générer le composer.json du bundle. Mettez ce que vous voulez, le seul champ important étant le type où vous devez choisir symfony-bundle. Personnellement, voici le fichier composer.json généré :

{
   "name": "dgarden/solid-bundle",
   "description": "SOLID coding helpers",
   "type": "symfony-bundle",
   "license": "MIT",
   "autoload": {
       "psr-4": {
           "Dgarden\\SolidBundle\\": "src/"
       }
   },
   "require": {}
}

Déjà un truc qui me chagrine, je veux que mon namespace principal soit DigitalGarden, je change donc "Dgarden\\SolidBundle\\": "src/"  en "DigitalGarden\\SolidBundle\\": "src/". Donc vous aussi, si jamais vous voyez quelque chose qui ne vous convient pas, vous pouvez changer le fichier à votre guise sans problème. Après la commande composer, on se retrouve donc avec les fichiers suivants :

.
├── composer.json
├── src
└── vendor
        ├── autoload.php
        └── composer
                ├── autoload_classmap.php
                ├── autoload_namespaces.php
                ├── autoload_psr4.php
                ├── autoload_real.php
                ├── autoload_static.php
                ├── ClassLoader.php
                └── LICENSE


Le bundle a donc déjà tout ce qu'il faut pour gérer ses dépendances, avec un dossier src pour notre code, ce qui respecte bien les bonnes pratiques dans la structuration d'un bundle. Il ne nous manque plus qu'une chose, créer le Bundle en tant que tel. Et pour ça, rien de plus simple. On va créer un fichier à la base des sources de notre bundle nommé DigitalGardenSolidBundle.php avec le contenu suivant :

<?php
// bundles/SolidBundle/src/DigitalGardenBundle.php
namespace DigitalGarden\SolidBundle; // Le namespace défini dans le psr-4 de votre composer.json

use Symfony\Component\HttpKernel\Bundle\AbstractBundle;

class DigitalGardenSolidBundle extends AbstractBundle
{
}

Et c'est tout pour le bundle pour le moment. Cependant, pour le moment, notre application ne connait le namespace associé à notre bundle, donc si j'essaie d'intégrer mon bundle à mon fichier config/bundles.php, je me taperais un bon petit "[critical] Uncaught Error: Class "DigitalGarden\SolidBundle\DigitalGardenSolidBundle" not found". Pour régler ça, on pourrait modifier le "psr-4" du composer.json de notre application, mais il existe beaucoup plus malin, utiliser le plugin wikimedia/composer-merge-plugin. Pour cela, il vous suffit de retourner à la racine de l'application de dev et de taper :

~/dev/bundle$ composer require wikimedia/composer-merge-plugin

Il vous demandera de "trust" le plugin, dites oui. Une fois installé, modifiez le composer.json de votre application pour rajouter dans le champ "extra" :

"extra": {
 "symfony": //...
 "merge-plugin": {
   "include": [
     "bundles/*/composer.json"
   ]
 }

}

Et voilà. Le plugin possède plein de paramètres, n'hésitez pas à aller jeter un coup d’œil à sa documentation. Refaites un petit composer dump-autoload pour être sûr, et vous devriez vous retrouver avec le namespace de votre bundle dans vendor/composer/autoload_psr4.php :

'DigitalGarden\\SolidBundle\\' => array($baseDir . '/bundles/SolidBundle/src')

Plus qu'à ajouter votre bundle à votre fichier bundles.php :

<?php
// config/bundles.php

return [
 //...
 DigitalGarden\SolidBundle\DigitalGardenSolidBundle::class => ['all' => true],
];

Gestion des dépendances

Dans notre cahier de charge de base pour notre bundle, nous avons parlé d'ajouter des interfaces et traits pour nous aider à standardiser nos modèles.

Exemple : avoir uniquement une simple classe à faire pour toutes nos entités nommées.

<?php
// src/Entity/Book.php

namespace App\Entity;

use DigitalGarden\SolidBundle\Entity\Field\Primary\IdEntityInterface;
use DigitalGarden\SolidBundle\Entity\Field\Primary\IdTrait;
use DigitalGarden\SolidBundle\Entity\Field\String\NamedEntityInterface;
use DigitalGarden\SolidBundle\Entity\Field\String\NameTrait;

class Book implements IdEntityInterface, NamedEntityInterface
{
   use IdTrait;
   use NameTrait;
}

Et ainsi avoir le même comportement et configuration pour toutes nos entités nommées. Rien de bien compliqué, il me suffit de créer mes interfaces et trait dans mon bundle, avec par exemple:

<?php
// bundles/SolidBundle/src/Entity/NamedEntityInterface.php
namespace DigitalGarden\SolidBundle\Entity;

use Stringable;

/**
* Entity with name.
*/
interface NamedEntityInterface extends Stringable
{
   /**
    * Get entity name.
    *
    * @return string
    */
   public function getName(): ?string;

   /**
    * Set the entity name.
    *
    * @param string|null $name The name.
    *
    * @return self
    */
   public function setName(?string $name): self;
}
<?php
// bundles/SolidBundle/src/Entity/NamedEntityTrait.php
namespace DigitalGarden\SolidBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* Trait for entities with name.
*/
trait NamedEntityTrait
{
   /**
    * Entity name.
    *
    * @var string|null
    */
   #[ORM\Column(type: 'string', length: 255, nullable: false)]
   private ?string $name = null;

   /**
    * {@inheritDoc}
    */
   public function __toString(): string
   {
       return "$this->name";
   }

   /**
     * {@inheritDoc}
     */
   public function getName(): ?string
   {
       return $this->name;
   }

   /**
    * {@inheritDoc}
    */
   public function setName(?string $name): self
   {
       $this->name = $name;

       return $this;
   }
}

Très bien... Rien de bien complexe et ça ne change pas beaucoup de l'avoir fait dans notre dossier source, mais voilà, si vous avez bien suivi le chapitre précédent, on est dans une application Symfony microservice, donc Doctrine\ORM\Mapping n'est pas dans les dépendances de l'application... On va du coup rajouter la dépendance doctrine/orm à notre bundle, simplement dans le dossier de notre bundle et en faisant un :

~/bundles-dev/bundles/SolidBundle$ composer require --no-update doctrine/orm

--no-update afin de ne pas télécharger le vendor. En effet, si on veut que ce soit bien le composer.json du bundle qui ait la dépendance, le vendor va lui être téléchargé par notre application de test, grâce à composer-merge-plugin. On retourne donc au niveau de notre application pour exécuter :

~/dev/bundles-dev$ composer install

Et là vous verrez donc, sans même avoir édité le composer.json de votre application, la dépendance doctrine/orm se télécharger. Votre application de test pourra donc servir dans le développement de tous vos autres bundles futurs.

Déclaration de services dans un bundle

On a donc dans notre bundle nos traits et nos interfaces pouvant être réutilisées sur tous nos projets, bien. Mais cela n'a rien de Symfony, ça pourrait être dans une dépendance classique que ça marcherait tout pareil. Entrons maintenant dans le vif du sujet du développement du bundle en commençant par lui ajouter un petit service sous la forme d'un event-listener mettant à jour les dates de création / mise à jour de nos entités.

Créons déjà une interface et un trait à imposer sur nos entités datées :

<?php
// bundles/SolidBundle/src/Entity/DatedEntityInterface.php

namespace DigitalGarden\SolidBundle\Entity;

use DateTimeInterface;

/**
* Entity with a createdAt and an updatedAt datetime fields.
*/
interface DatedEntityInterface
{
   /**
    * Get the entity creation date.
    *
    * @return DateTimeInterface|null
    */
   public function getCreatedAt(): ?DateTimeInterface;

   /**
    * Set the entity creation date.
    *
    * @param DateTimeInterface|null $createdAt The entity creation date.
    *
    * @return self
    */
   public function setCreatedAt(?DateTimeInterface $createdAt): self;

   /**
    * Get the entity last update datetime.
    *
    * @return DateTimeInterface|null
    */
   public function getUpdatedAt(): ?DateTimeInterface;

   /**
    * Set the entity last update datetime.
    *
    * @param DateTimeInterface|null $updatedAt The entity last update datetime.
    *
    * @return self
    */
   public function setUpdatedAt(?DateTimeInterface $updatedAt): self;
}
<?php
// bundles/SolidBundle/src/Entity/DatedEntityTrait.php

namespace DigitalGarden\SolidBundle\Entity;

use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;

/**
* Trait for entities creation & update datetime.
*
*   To enable the auto-setting, your entity has to implement:
*   DigitalGarden\SolidBundle\Entity\DatedEntityInterface
*/
trait DatedEntityTrait
{
   /**
    * The entity creation date.
    *
    * @var DateTimeInterface|null
    */
   #[ORM\Column(type: 'datetime', nullable: false, options: ['default' => 'CURRENT_TIMESTAMP'])]
   private ?DateTimeInterface $createdAt = null;

   /**
    * The entity last update datetime.
    *
    * @var DateTimeInterface|null
    */
    #[ORM\Column(type: 'datetime', nullable: true)]
    private ?DateTimeInterface $updatedAt = null;

   /**
    * Get the entity creation date.
    *
    * @return DateTimeInterface|null
    */
   public function getCreatedAt(): ?DateTimeInterface
   {
       return $this->createdAt;
   }

   /**
    * Set the entity creation date.
    *
    * @param DateTimeInterface|null $creationDate The entity creation date.
    *
    * @return $this
    */
   public function setCreatedAt(?DateTimeInterface $creationDate): self
   {
       $this->createdAt = $creationDate;

       return $this;
   }

   /**
    * Get the entity last update datetime.
    *
    * @return DateTimeInterface|null
    */
   public function getUpdatedAt(): ?DateTimeInterface
   {
      return $this->updatedAt;
   }

   /**
    * Set the entity last update datetime.
    *
    * @param DateTimeInterface|null $updatedAt The entity last update datetime.
    *
    * @return UpdatedAtTrait
    */
   public function setUpdatedAt(?DateTimeInterface $updatedAt): self
   {
       $this->updatedAt = $updatedAt;

       return $this;
   }
}

On crée ensuite notre event listener :

<?php
namespace DigitalGarden\SolidBundle\EventListener\Entity;

use DateTime;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use DigitalGarden\SolidBundle\Entity\DatedEntityInterface;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;

/**
* Auto set entity dates.
*/
#[AsDoctrineListener(event: 'prePersist')]
#[AsDoctrineListener(event: 'preUpdate')]
class EntityDateAutoSetter
{
   /**
    * Handle entities pre-persist & pre-update events to set createdAt & updatedAt dates.
    *
    * @param PrePersistEventArgs $args The event arguments.
    *
    * @return void
    */
   public function __invoke(LifecycleEventArgs $args): void
   {
       $entity = $args->getObject();

       if ($args instanceof PrePersistEventArgs && $entity instanceof CreatedAtEntityInterface && null === $entity->getCreatedAt()) {
           $entity->setCreatedAt(new DateTime());
       } elseif ($args instanceof PreUpdateEventArgs && $entity instanceof UpdatedAtEntityInterface) {
           $entity->setUpdatedAt(new DateTime());
       }
     
   }
}

On va ensuite ajouter une configuration à notre Bundle. Pour cela, il existe 3 formats de configurations :

  • YAML, celle plus communément utilisée dans les applications. Nécessite symfony/yaml
  • XML, souvent utilisé dans les bundles.
  • PHP

Pour avoir testé les 3, j'ai pu voir que la plus performante est PHP (logique - pas de parser), c'est donc celle que je vais utiliser ici, mais sachez que les configurations de services sont conservées dans le cache, donc ce n'est pas très important.

On va donc créer un fichier config/services.php comme suit:

<?php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

return function (ContainerConfigurator $container) {
   $bundleDir = realpath(__DIR__.'/../../..');

   $services = $container->services()
       ->defaults()
           ->autowire()
           ->autoconfigure();

   $services->load('DigitalGarden\SolidBundle\EventListener\\', "$bundleDir/src/EventListener");
};

Et c'est tout. On a donc :

  • Aller chercher le répertoire du bundle.
  • Définit le comportement par défaut des services intégrés:
    • autowire à true – le container va chercher à automatiquement résoudre les dépendances
    • autoconfigure à true - le container va chercher à appliquer automatiquement certaines configurations aux services (typiquements, lui appliquer automatiquement les tags).
  • Enfin, j'ajoute mon dossier EventListener aux services, ainsi toutes les classes ainsi définies dans le répertoire src/EventListener seront ajoutées comme services, et par le autoconfigure et les attributs associés, configurés comme des event-listener doctrine.

On ajoute ce fichier à notre bundle en modifiant DigitalGardenSolidBundle comme suit :

class DigitalGardenSolidBundle extends AbstractBundle
{
   /**
    * {@inheritDoc}
    */
   public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
   {
       // Load services
       $container->import(
__DIR__ . '/../config/services.php');
   }

}

Et voilà. Vous pouvez vérifier le bon import de vos services grâce à la commande debug:container :

~/bundles-dev$ bin/console debug:container | grep SolidBundle
DigitalGarden\SolidBundle\EventListener\Entity\EntityDateAutoSetter
                DigitalGarden\SolidBundle\EventListener\Entity\EntityDateAutoSetter

Configuration du bundle

Afin de rendre son bundle plus modulaire, il est souvent interessant d'en permettre une configuration. Imaginons, dans notre cas, nous avons, en dur, un NamedEntityTrait qui définit le nom comme un VARCHAR(255), si on parle en MySQL.

Cela reste totalement surchargeable en recopiant le paramètre dans notre entité finale, par exemple:

#[ORM\Column(type: 'string', length: 125, nullable: false)]
private ?string $name = null;

Mais il pourrait être intéressant de rendre cette taille modifiable de manière générale, par une configuration à mettre dans l'application finale, du genre :

# config/packages/dgarden_solid.yaml

dgarden:
 solid:
   name_length: 125

Pour cela, on va modifier la classe DigitalGardenSolidBundle comme suit :

// ...
class DigitalGardenSolidBundle extends AbstractBundle
{
   protected string $extensionAlias =
'dgarden';
   public function configure(DefinitionConfigurator $definition): void
   {
       $definition->rootNode()
           ->children()
               ->arrayNode('
solid')->children()
                    ->integerNode(
'name_length')->defaultValue(255)->end()
               ->end()->addDefaultsIfNotSet()
           ->end()
       ;
   }
  // ...
}

Avec:

  • La définition d'un alias d'extension (protected string $extensionAlias = 'dgarden';), ce qui permet d'avoir toute notre configuration sous le noeud dgarden (pour éviter les conflits)
  • Définition des enfants du noeud root (donc dgarden):
    • ayant comme enfant un noeud solid de type array (donc tableau associatif)
    • ayant lui-même comme enfant un noeud integer nommé name_length de type integer avec une valeur par défaut de 255
  • Et enfin on indique qu'on souhaite que le configurateur envoie les valeurs par défaut plutôt que null par un addDefaultsIfNotSet().

Ainsi fait, si vous ajoutez le fichier yaml décrit plus tôt à votre application, vous ne devriez pas avoir d'erreur... Mais la valeur en tant que telle est perdue dans la nature. Il nous faut maintenant l'injecter dans le container, en modifiant  à nouveau la méthode DigitalGardenSolidBundle::loadExtension:

public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
{
   $bundle = $config['solid'] ?? [];

   // Load services
   $container->import(__DIR__ . '/../config/services.php');

   // Configure parameters
   $container->parameters()->set('dgarden.solid.name_length', $bundle['name_length']);
}

Et ainsi :

~/bundles-dev$ bin/console debug:container \
      --parameter dgarden.solid.name_length
-------------------------------      -----------------------
Parameter                                      Value
-------------------------------    -----------------------
dgarden.solid.name_length    125
-------------------------------    -----------------------

Il ne vous reste plus qu'à configurer dans votre bundle un listener qui va utiliser cette valeur si la longueur du champ name n'est pas mis (supprimez donc la valeur dans le trait). Ce n'est pas le sujet de l'article, je vous conseille donc d'aller voir sur le site de Doctrine comment ça marche, mais voici ce que ça donne:

<?php
namespace DigitalGarden\SolidBundle\EventListener\Entity\Loader;

use DigitalGarden\SolidBundle\Entity\Field\String\NamedEntityInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\LoadClassMetadataEventArgs;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

#[AsDoctrineListener(event: 'loadClassMetadata')]
class NameMetadataLoader
{
   public function __construct(
       #[Autowire(param: 'dgarden.solid.name_length')]
       private int $length
   )
   {
   }

   public function __invoke(LoadClassMetadataEventArgs $args): void
   {
       $classMetadata = $args->getClassMetadata();
       $reflectionClass = $classMetadata->getReflectionClass();

       // The class should be a NamedEntityInterface & have the field "name".
       if ($reflectionClass->implementsInterface(NamedEntityInterface::class)
           && $classMetadata->hasField('name')
       ) {
           $mapping = $classMetadata->getFieldMapping('name');

           // We set the default length only if the field "length" is not set.
           if (($mapping['length'] ?? null) === null) {
               $mapping['length'] = $this->length;
               $classMetadata->setAttributeOverride('name', $mapping);
           }
       }
   }
}

Bonus: Génération d'un fichier de configuration par défaut

Si vous êtes habitués à Symfony, vous avez certainement installé des bundles qui vous généraient automatiquement dans votre fichier config/packages le fichier de configuration qui va bien... Il s'agit du fonctionnement de Symfony Flex. Si cette fonctionnalité vous intéresse, je vous invite à aller sur le dépôt recipes de Symfony pour comprendre comment cela fonctionne.

Conclusion

Dans cet article nous avons vu comment créer un bundle réutilisable pour vos projets, mais également comment avoir une application de test pour tous vos futurs bundles.

Nous avons donc :

  • Créé une nouvelle application avec composer create-project symfony/skeleton
  • Créé un nouveau bundle avec composer init.
  • Configuré un dossier de développement pour nos bundles dans notre application grâce à wikimedia/composer-merge-plugin
  • Installé notre bundle dans bundles.php
  • Ajouté des interfaces & traits réutilisables dans notre bundle.
  • Vu comment ajouter un service dans notre bundle.
  • Vu comment ajouter une configuration à notre bundle.

C'est à vous maintenant. Fouillez dans vos anciens projets, je suis sûr que vous pourriez trouver des choses à "bundleliser" (barbarisme non soumis à copyright) et ainsi mutualiser votre code à fond !

Et si vous avez besoin d'aide, on peut toujours vous former ou coacher une équipe tech en cas de besoin, on est certifié Qualiopi :)

D’autres articles à découvrir