Utilisation des énumérations PHP avec vos workflow Symfony

Publié le 08/04/2023 • Actualisé le 08/04/2023

Dans cet article, nous voyons comment utiliser les énumérations PHP avec les workflow Symfony. Profitons de cette nouvelle fonctionnalité PHP pour améliorer et simplifier tout le code relatif au composant Workflow. C'est parti ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧 Close

» Publié dans "Une semaine Symfonique 850" (du 10 au 16 avril 2023).

Prérequis

Je présumerai que vous avez des connaissances élémentaires de Symfony et que vous savez comment configurer un workflow avec le composant du même nom.

Configuration

  • PHP 8.3
  • Symfony 6.4.3

Introduction

La semaine dernière, je suis allé au Symfony Live Paris 2023 avec mes chers collègues de travail Les-Tilleuls.coop. Il y a eu, entre autres, une prise de parole / retour d'expérience à propos du composant workflow par Florence Cauchy de la société Sézane. À la fin de l'intervention, une des questions des spectateurs a été : "Avez-vous utilisé les énumérations PHP" et la réponse a été non. J'ai été un peu surpris puisque le projet utilise PHP 8.1, c'est donc un cas d'utilisation parfait des énumérations PHP.

But

Le but est d'utiliser les énumérations à la place de chaînes de caractères (et de constantes associées) pour les états d'un workflow et de tirer parti du nouveau paramètre Doctrine pour les énumérations.

Les états des workflow sont des chaînes de caractères

Les énumérations PHP sont assez récentes puisqu'elles sont disponibles depuis PHP 8.1. C'est une fonctionnalité élégante qui se révèle très utile. Le workflow, quant à lui, n'est pas nouveau et a été introduit en 2016 dans Symfony 3.2 par Fabpot and Lyrixx ; logiquement des chaines sont utilisées pour représenter les états. Si l'on regarde au dépôt GitHub, une PR (désormais fermée) essaie d'utiliser les énumérations au lieu de chaînes dans le composant. Comme le composant a été construit pour utiliser des chaînes, ce n'est pas si évident et le jeu n'en vaut tout simplement pas la chandelle. Voyons comment détourner le problème avec une solution pragmatique.

Dans un article précédent, j'ai utilisé le bundle fresh/doctrine-enum-bundle pour simuler les énumérations. Voici la classe qui était utilisée :

<?php

declare(strict_types=1);

namespace App\DBAL\Types;

use Fresh\DoctrineEnumBundle\DBAL\Types\AbstractEnumType;

/**
 * @extends AbstractEnumType<string,string>
 */
final class UserStateType extends AbstractEnumType
{
    public const STATE_CREATED = 'created';
    public const STATE_VALIDATED = 'validated';

    public const TYPES = [
        self::STATE_CREATED,
        self::STATE_VALIDATED,
    ];

    /**
     * @var array<string,string>
     */
    protected static array $choices = [
        self::STATE_CREATED => 'user_state_created',
        self::STATE_VALIDATED => 'user_state_validated',
    ];
}

La configuration Doctrine était :

    #[ORM\Column(name: 'state', type: 'UserStateType', nullable: false)]
    #[EnumType(entity: UserStateType::class)]
    private string $state = UserStateType::STATE_CREATED;

Ajoutons un champ pour la même propriété, mais avec cette fois une énumération PHP.

La nouvelle configuration Doctrine

Ajoutons la nouvelle propriété dans l'entité utilisateur. Nous la suffixons avec AsEnum pour s'assurer de ne pas confondre dans la propriété existante.

    #[ORM\Column(type: 'string', nullable: false, enumType: UserState::class)]
    protected UserState $stateAsEnum = UserState::CREATED;

Cette fois, nous utilisons PHP pour le type de la propriété, et nous utilisons l'option énumération de Doctrine. Comme on peut le voir, on doit spécifier la classe de l'énumération à utiliser grâce à l'option enumType.

Voici la classe utilisée :

<?php

declare(strict_types=1);

namespace App\Enum;

enum UserState: string
{
    case CREATED = 'created';
    case VALIDATED = 'validated';

    public function isCreated(): bool
    {
        return $this == self::CREATED;
    }

    public function isValidated(): bool
    {
        return $this == self::VALIDATED;
    }
}

On doit ajouter les accesseurs pour cette nouvelle propriété :

    public function getStateAsEnum(): UserState
    {
        return $this->stateAsEnum;
    }

    public function setStateAsEnum(UserState $stateAsEnum): User
    {
        $this->stateAsEnum = $stateAsEnum;

        return $this;
    }

Rechargeons les données factices. Maintenant, on a les deux champs, le type du nouveau champ est varchar(255), pas une vraie énumération de base de données.

Colonne Type Commentaire
state enum('created','validated') (DC2Type:UserStateType)
state_as_enum varchar(255)

Nouvelle configuration du workflow

Dupliquons le workflow existant afin de garder l'ancien :

# https://symfony.com/doc/current/workflow.html#configuration
# php bin/console debug:autowiring workflow
framework:
    workflows:
        user:
            type: 'state_machine'
            audit_trail:
                enabled: true
            marking_store:
                type: 'method'
                property: 'state'
            supports:
                - App\Entity\User
            initial_marking: created 
            places:
                - created
                - validated
            transitions:
                validate:
                    from: created
                    to:   validated

        userStateAsEnum:
            type: 'state_machine'
            audit_trail:
                enabled: true
            marking_store:
                type: 'method'
                property: 'stateAsString'
            supports:
                - App\Entity\User
            initial_marking: created
            places:
                - created
                - validated
            transitions:
                validate:
                    from: created
                    to:   validated

Il y a une seule différence, la valeur de la clé property dans la section marking_store est stateEnumString au lieu de "state". Cela signifie que le workflow va rechercher des accesseurs (car on a utilisé le réglage type: 'method') correspondant. Ceux-ci peuvent être "virtuels" et pas forcément liés directement à une propriété. Ajoutons ces fonctions :

    public function getStateAsString(): string
    {
        return $this->stateAsEnum->value;
    }

    public function setStateAsString(string $stateAsString): User
    {
        $this->stateAsEnum = UserState::from($stateAsString);

        return $this;
    }

C'est tout ! Ces fonctions prennent en charge la conversion de chaîne de caractères en énumération et vice versa. On comprend l'avantage désormais, on ne peut pas passer une valeur incorrecte au setter sinon une exception est lancée. Vérifions cela avec un test unitaire :

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Entity;

use App\Entity\User;
use App\Enum\UserState;
use PHPUnit\Framework\TestCase;

final class UserTest extends TestCase
{
    public function testSetStateAsString(): void
    {
        $user = new User();

        // nomical cases
        $user->setStateAsString(UserState::CREATED->value);
        self::assertTrue($user->getStateAsEnum()->isCreated());
        $user->setStateAsString(UserState::VALIDATED->value);
        self::assertTrue($user->getStateAsEnum()->isValidated());

        // incorrect value
        $this->expectException(\ValueError::class);
        $this->expectExceptionMessage('"foobar" is not a valid backing value');
        $user->setStateAsString('foobar');
    }

    public function testSetState(): void
    {
        $user = new User();
        $user->setState('foobar');
        self::assertSame('foobar', $user->getState());

        // This is wrong as the user is now in an unknown state.
        // And testing the value in the setter would be ugly.
    }
}

Nous sommes dorénavant sûrs de ne pas utiliser de valeur incorrecte. OK, nous avons utilisé une énumération pour les valeurs des états, mais quid des transitions ? On peut aussi utiliser une énumération : Voici la classe correspondante :

<?php

declare(strict_types=1);

namespace App\Enum;

enum UserStateTransition: string
{
    case VALIDATE = 'validate';

    /**
     * Can be provided by a trait.
     *
     * @return array<string,string>
     */
    public static function getAsArray(): array
    {
        return array_reduce(
            self::cases(),
            static fn (array $choices, self $type) => $choices + [$type->name => $type->value],
            [],
        );
    }
}

On a une seule transition pour l'instant ; validate. Maintenant, créons un service pour l'utiliser :

<?php

declare(strict_types=1);

namespace App\Workflow;

use App\Entity\User;
use App\Enum\UserStateTransition;
use Symfony\Component\Workflow\WorkflowInterface;

final class UserStateWorkflow
{
    private WorkflowInterface $userStateAsEnumStateMachine;

    public function __construct(WorkflowInterface $userStateAsEnumStateMachine)
    {
        $this->userStateAsEnumStateMachine = $userStateAsEnumStateMachine;
    }

    public function canValidate(User $user): bool
    {
        return $this->userStateAsEnumStateMachine->can($user, UserStateTransition::VALIDATE->value);
    }

    public function validate(User $user): void
    {
        if (!$this->canValidate($user)) {
            throw new \LogicException(sprintf("Can't apply the '%s' transition on user n°%s, current state: '%s'.", UserStateTransition::VALIDATE->value, $user->getId(), $user->getStateAsString()));
        }

        $this->userStateAsEnumStateMachine->apply($user, UserStateTransition::VALIDATE->value);
    }
}

Ici, on l'utilise pour créer un raccourci permettant de valider un utilisateur. Nous pouvons aussi ajouter d'autres méthodes testant l'état courant de l'utilisateur. Parce que l'on a utilisé une énumération, si l'on n'implémente pas tous les cas, on a un avertissement avec PHPStorm. Ce que nous n'aurions pas eu avec des constantes.


un avertissement PHPStorm

Bien sûr, on profite de tous les avantages de l'analyse statique liée aux énumérations.

Twig

Quelques remarques à propos de Twig. On peut utiliser et tester les état en faisant :

{{ user.stateAsEum.isValidated }}

On utilise les raccourcis que l'on a définis dans la classe de l'énumération ; c'est très commode, car ça évite de polluer l'entité principale, cela marche pour des cas simples. Pour des cas plus complexes, vous pouvez avoir besoin d'une extension Twig. À propos des autres helpers Twig que fournit le composant comme {{ workflow_can(). À la place d'utiliser des chaînes en dur, on peut faire :

{{ workflow_can(user, user_state_transition.VALIDATE) }}

Avant cela, on doit créer une extension simple qui injecte les cas de l'énumération dans une variable du contexte Twig global :

<?php

declare(strict_types=1);

namespace App\Twig\Extension;

use App\Enum\UserStateTransition;
use Twig\Extension\AbstractExtension;
use Twig\Extension\GlobalsInterface;

final class UserExtension extends AbstractExtension implements GlobalsInterface
{
    public function getGlobals(): array
    {
        return [
            'user_state_transition' => UserStateTransition::getAsArray(),
        ];
    }
}

On a plus de valeurs en dur dans les templates. De plus, on aura une erreur si le template est utilisé et qu'un cas d'énumération a été renommé ou n'existe pas. L'erreur mentionne aussi les valeurs qui sont autorisées :

{{ user_state_transition.FOOBAR }}

Key "FOOBAR" for array with keys "VALIDATE" does not exist.

Si seule la valeur du cas a été modifiée, alors la nouvelle valeur est automatiquement utilisée et l'on n'a rien à modifier. Le code est plus propre et plus maintenable : si l'on veut renommer les transitions, ce sera plus facile et rapide à faire. Comme indiqué dans les commentaires, la fonction getAsArray() de l'énumération peut être extraite dans un trait et réutilisée dans d'autres classes.

Plus sur les workflows et les énumérations sur ce blog

Conclusion

Nous avons vu comment utiliser des énumérations pour les états et les transitions d'un workflow. C'est assez limpide à implémenter, et nous profitons de cette élégante nouvelle fonctionnalité de PHP 8.1. Utiliser des énumérations permet de produire un code plus robuste puisqu'il est impossible d'assigner une valeur d'état incorrecte. Cet exemple utilise une machine à états ; je n'ai pas essayé avec un type workflow qui permet à une propriété d'être dans plusieurs simultanément ; mais ça devrait être possible. Les accesseurs devront être modifiés comme nous l'avons fait précédemment dans la fonction setStateAsString(). C'est un exemple simple qui n'utilise que deux états et une transition, mais évidemment le plus d'états et de transitions vous aurez, plus les bénéfices seront évidents.

Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À tantôt ! COil. 😊

  Lire la doc

  Travaillez avec moi !


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.
  • Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil