Accueil /  Blog / Symfony / Librarie Open Source : YokaiEnumBundle

Librarie Open Source : YokaiEnumBundle

Publié le vendredi 4 juin 2021

Un bundle OpenSource qui apporte un système d'énumération totalement intégré à votre projet Symfony.

Besoin

Lorsque l'on créé une application il arrive fréquemment que nous cherchions à contraindre les valeurs possibles d'une propriété.

Des propriétés pour lesquelles on souhaite ajouter une couche de validation et/ou pouvoir afficher à l'utilisateur un <select> (multiple ou non).

Problématique

Symfony ne propose pas de système tout prêt pour gérer ce cas d'usages (en tout cas, rien qui ne soit suffisamment simple à l'usage).

On se retrouve donc souvent à dupliquer beaucoup de code, à grand coup de @Assert\Choice() et de $builder->add('property', ChoiceType::class, ['choices' => []]).

Ce qui est assez dommage.

Solution

De ce constat, est né un bundle, qui se propose de packager les fonctionnalités du framework autour d'une nouvelle notion : les énumérations.

Le contrat de création de ce bundle était le suivant :

  1. s'installer le plus simplement possible
  2. faciliter et centraliser la déclaration des énumérations
  3. réutiliser au maximum les fonctionnalités existantes de Symfony
  4. s'intégrer à SonataAdmin

Vous pouvez sans attendre retrouver ce bundle sur GitHub et Packagist .

Installation

Ajoutez le bundle en dépendance de votre projet.

composer require yokai/enum-bundle

Si vous n'êtes pas encore passés sous symfony/flex (vous devriez y songer), ajoutez le bundle à votre kernel.

new Yokai\EnumBundle\YokaiEnumBundle(),

Voilà, c'est installé, reste à l'utiliser.

Utilisation

Cas d'usage

Disons que votre application a des utilisateurs. Ces utilisateurs ont (entre autres) un statut et peuvent souscrire à des mailing lists.

Note : On ne parle pas de persistance ici (pas de mapping Doctrine par exemple), car cela n'influe pas sur le bundle.

<?php

namespace App\Model;

use Symfony\Component\Validator\Constraints as Assert;

class User
{
    public const STATUS_NEW = 'new';
    public const STATUS_ACTIVE = 'active';
    public const STATUS_DISABLED = 'disabled';

    public const SUBSCRIBE_NEWSLETTER = 'newsletter';
    public const SUBSCRIBE_COMMERCIAL = 'commercial';

    /**
     * @Assert\NotNull()
     * @Assert\Choice({User::STATUS_NEW, User::STATUS_ACTIVE, User::STATUS_DISABLED})
     */
    public string $status = self::STATUS_NEW;

    /**
     * @Assert\Choice({User::SUBSCRIBE_NEWSLETTER, User::SUBSCRIBE_COMMERCIAL}, multiple=true)
     */
    public array $subscriptions;
}

Les propriétés $status & $subscriptions ne sont pas des propriétés libres, la liste des valeurs possibles est restreinte. On utilise la contrainte Choice pour faire appliquer la validation. Les valeurs possibles pour ces propriétés sont fixées dans des constantes de classe, car c'est pratique si l'on souhaite faire des règles de gestion sur ces valeurs par la suite.

Si l'on souhaite proposer un formulaire permettant d'éditer ces propriétés, on s'en sort avec le ChoiceType :

<?php

namespace App\Form\Type;

use App\Model\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add(
                'status',
                ChoiceType::class,
                [
                    'required' => true,
                    'choices' => [
                        'user‧status‧new' => User::STATUS_NEW,
                        'user‧status‧active' => User::STATUS_ACTIVE,
                        'user‧status‧disabled' => User::STATUS_DISABLED,
                    ],
                ]
            )
            ->add(
                'subscriptions',
                ChoiceType::class,
                [
                    'required' => false,
                    'multiple' => true,
                    'choices' => [
                        'user‧subscription‧newsletter' => User::SUBSCRIBE_NEWSLETTER,
                        'user‧subscription.commercial' => User::SUBSCRIBE_COMMERCIAL,
                    ],
                ]
            )
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefault('data_class', User::class);
    }
}

Enfin, si on souhaite afficher les valeurs d'un utilisateur existant dans un template Twig :

<label>Status</label>
<p>{{ ('user‧status.'~user‧status)|trans }}</p>
<label>Subscriptions</label>
{% for subscription in user‧subscriptions %}
    <p>{{ ('user‧subscription.'~subscription)|trans }}</p>
{% endfor %}

Problèmes

Le problème principal, c'est la duplication du code. Evidemment, si vous n'avez qu'une seule énumération ce n'est pas très grave, mais quand votre application grossira, vous allez perdre du temps en maintenance et en évolutions.

Mais même avec une seule énumération, nous avons aussi un problème de fragmentation :

  • les choix possibles sont définis à la fois dans la validation et dans le formulaire
  • la construction des labels est faite à la fois dans le formulaire et dans la vue

Solution

Nous allons déclarer notre énumération, en enregistrant un service

services:
    enum‧user‧status:
        class: Yokai\EnumBundle\ConstantListTranslatedEnum
        arguments:
            $constantsPattern: 'App\Model\User::STATUS_*'
            $transPattern: 'user‧status.%s'
            $name: 'user‧status'

    enum‧user‧subscription:
        class: Yokai\EnumBundle\ConstantListTranslatedEnum
        arguments:
            $constantsPattern: 'App\Model\User::SUBSCRIPTION_*'
            $transPattern: 'user‧subscription.%s'
            $name: 'user‧subscription'

Le bundle va ajouter automatiquement le tag yokai_enum‧enum sur ces 2 services, car ils implémentent l'interface Yokai\EnumBundle\EnumInterface, et enregistrer ces services auprès du "registre des énumérations" (Yokai\EnumBundle\EnumRegistry).

note il existe plusieurs façons de déclarer une énumération. Une liste de constantes définies en configuration n'est qu'une des façons possibles.

Maintenant, nous allons utiliser ces énumérations et modifier notre code.

Dans le modèle, nous allons remplacer la contrainte Choice de Symfony par Enum, fournie par le bundle. Et pour chacune de ces annotations on va préciser le name de l'enum dont nous avons besoin.

<?php

namespace App\Model;

use Symfony\Component\Validator\Constraints as Assert;
+use Yokai\EnumBundle\Validator\Constraints\Enum;

class User
{
    public const STATUS_NEW = 'new';
    public const STATUS_ACTIVE = 'active';
    public const STATUS_DISABLED = 'disabled';

    public const SUBSCRIBE_NEWSLETTER = 'newsletter';
    public const SUBSCRIBE_COMMERCIAL = 'commercial';

    /**
     * @Assert\NotNull()
-     * @Assert\Choice({User::STATUS_NEW, User::STATUS_ACTIVE, User::STATUS_DISABLED})
+     * @Enum("user.status")
     */
    public string $status = self::STATUS_NEW;

    /**
-     * @Assert\Choice({User::SUBSCRIBE_NEWSLETTER, User::SUBSCRIBE_COMMERCIAL}, multiple=true)
+     * @Enum("user.subscription", multiple=true)
     */
    public array $subscriptions;
}

Dans le formulaire, nous allons tout retirer excepté l'ajout des champs. Le bundle se chargera pour nous de trouver le bon FormType à affecter à notre champ.

<?php

namespace App\Form\Type;

use App\Model\User;
use Symfony\Component\Form\AbstractType;
-use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
-            ->add(
-                'status',
-                ChoiceType::class,
-                [
-                    'required' => true,
-                    'choices' => [
-                        'user‧status‧new' => User::STATUS_NEW,
-                        'user‧status‧active' => User::STATUS_ACTIVE,
-                        'user‧status‧disabled' => User::STATUS_DISABLED,
-                    ],
-                ]
-            )
+            ->add('status')
-            ->add(
-                'subscriptions',
-                ChoiceType::class,
-                [
-                    'required' => false,
-                    'multiple' => true,
-                    'choices' => [
-                        'user‧subscription‧newsletter' => User::SUBSCRIBE_NEWSLETTER,
-                        'user‧subscription.commercial' => User::SUBSCRIBE_COMMERCIAL,
-                    ],
-                ]
-            )
+            ->add('subscriptions')
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefault('data_class', User::class);
    }
}

Dans le template, on va remplacer la génération dynamique de la clé de traduction par un filtre que propose le bundle.

<label>Status</label>
-<p>{{ ('user‧status.'~user‧status)|trans }}</p>
+<p>{{ user‧status|enum_label('user‧status') }}</p>
<label>Subscriptions</label>
{% for subscription in user‧subscriptions %}
-    <p>{{ ('user‧subscription.'~subscription)|trans }}</p>
+    <p>{{ subscription|enum_label('user‧subscription') }}</p>
{% endfor %}

Voilà ! A partir de maintenant, les valeurs possibles et les labels associés sont centralisés dans un seul et même concept !

Sous le capot

En vérité, rien de plus que ce qui est décrit en préambule de l'article. Le bundle ré-utilise ce qui existe déjà dans Symfony. Il propose :

Le tout est orchestré par une seule et unique classe : EnumRegistry auprès de qui sont enregistrés toutes les énumérations de votre application.

Et demain

Dans sa version actuelle (3.x), les énumérations ne peuvent avoir que des valeurs de type string.

Avec l'arrivée de PHP 8.1 et de son support natif des énumérations, l'écosystème va naturellement s'orienter vers des énumérations dont les valeurs sont des objets.

C'est pourquoi la prochaine version majeure du bundle (4.x) donnera la possibilité de définir des énumérations avec des valeurs très diverses : string, int, bool, ... et les énumérations natives !

Cette version permettra également une intégration avec la, très populaire, librairie myclabs/php-enum.

Suivez notre actualité en avant première. Pas plus d’une newsletter par mois.