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
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:
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.
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],
];
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.
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 :
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 :
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
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:
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);
}
}
}
}
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.
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 :
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 :)