Mise en place d'un workflow utilisateur avec Symfony et EasyAdmin3

Publié le 30/06/2021 • Actualisé le 30/06/2021

Dans cet article, nous allons voir comment mettre en place d'un workflow utilisateur avec Symfony et EasyAdmin3. Nous allons utiliser le composant workflow pour gérer le statut des utilisateurs alors que EasyAdmin3 nous permettra de modifier ce statut avec des actions personnalisées. 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 757" (du 28 juin au au 4 juillet 2021).

Prérequis

Je présumerai que vous avez au moins des connaissances basiques de Symfony et que vous savez comment installer un composant et un bundle.

Configuration

  • PHP 8.3
  • Symfony 6.4.6
  • Le composant symfony/workflow
  • Le bundle EasyAdmin3
  • Le bundle fre5h/DoctrineEnumBundle

Introduction

Le composant workflow est un des plus utiles et puissants de tous les composants Symfony. C'est un besoin courant que de devoir gérer des états pour des objets dans une application. Voyons comment l'utiliser en conjonction avec EasyAdmin3 et tirer le meilleur des deux.

But

Le but est de créer un workflow pour des utilisateurs. Nous voulons qu'ils puissent passer d'un statut "créé" ou ils viennent de s'inscrire, à un statut "validé" où ils pourront s'identifier. Une action personnalisée EasyAdmin3 permettra cette transition.

Préparation de la base de données

Tout d'abord, préparons la base de données pour que nous puissions utiliser un workflow sur une entité User. Ajoutons un champ "état". Ce champ sera la référence pour le workflow et sera de type enum. Pour créer ce champ, nous n'allons pas utiliser le bundle maker mais le bundle fre5h/DoctrineEnumBundle qui fournit un nouveau type enum pour Doctrine, c'est précisément ce dont nous avons besoin pour notre champ, installons-le :

composer req fresh/doctrine-enum-bundle

Ce bundle ajoute donc un nouveau type Doctrine que nous allons utiliser pour notre nouveau champ. Créons un nouveau fichier UserStateType.php ; on peut le placer dans le répertoire src/DBAL/Types par exemple :

<?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',
    ];
}

Ce fichier est assez explicite ; on déclare les constantes représentant les différentes valeurs que va pouvoir prendre le champ enum. Pour maintenant, restons simples : nous allons gérer uniquement deux états, "créé" et "validé". On déclare ces deux constantes ; puis on les utilise pour définir la propriété $choices utilisée par le bundle afin de savoir quelles sont les valeurs utilisées pour ce champ. OK, nous avons notre nouveau type Doctrine ; déclarons-le dans la configuration de l'ORM.

doctrine:
    dbal:
        driver: 'pdo_mysql'
        server_version: '5.7.41'
        charset: utf8mb4
        url: '%env(resolve:DATABASE_URL)%'
        types:
            ArticleType: App\DBAL\Types\ArticleType
            UserStateType: App\DBAL\Types\UserStateType

Voilà, Doctrine connait désormais le nouveau type et est capable de l'utiliser. Nous pouvons créer le nouveau champ :

     * @see config/packages/workflow.yaml
     */
    #[ORM\Column(name: 'state', type: 'UserStateType', nullable: false)]
    #[EnumType(entity: UserStateType::class)]
    private string $state = UserStateType::STATE_CREATED;

Notes : Le champ n'est pas nullable, car nous voulons que tous les utilisateurs aient un état, on utilise la première constante que nous avons introduite dans UserStateType comme valeur par défaut. On ajoute une assertion de validation afin de vérifier qu'uniquement les valeurs que nous avons définies (la propriété $choices) puissent être autorisées. Le bundle fournit cette assertion, donc autant en profiter. Rechargeons le modèle et les fixtures :


Le nouveau champ enum

On voit notre nouveau champ et uniquement les deux valeurs que nous avons définies dans le type UserStateType sont autorisées au niveau de la base de données. Comme nous avons mis une valeur par défaut dans l'entité, on a même pas à modifier les fixtures ; dans ce cas, la valeur "créé" sera utilisée (bien sûr, dans vos tests, vous voudrez probablement avoir les utilisateurs dans différents états). Nous avons notre nouveau champ ; voyons comment l'utiliser avec le composant workflow.

Configuration du workflow

Installons, le composant workflow :

composer req workflow

Vous remarquerez que Flex a créé un nouveau fichier vide config/packages/workflow.yaml. Nous pouvons configurer nos workflow ici. Voici une configuration de base pour l'entité User et sa propriété state :

# 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

Comme vous pouvez le voir, nous retrouvons les deux valeurs que nous avons configurées pour la clé places. supports indique quelle entité est concernée et finalement, marking_store indique que la référence du workflow (le marqueur) sera la propriété state et sera manipulée par ces accesseurs. On indique aussi la valeur initiale de l'état avec initial_marking, qui doit être la même valeur que celle que nous avons mise par défaut dans la déclaration de la propriété de l'entité. Vérifions si tout est correctement configuré ; nous pouvons dans un premier temps utiliser la commande :

bin/console debug:autowiring workflow
Autowirable Types
=================

 The following classes & interfaces can be used as type-hints when autowiring:
 (only showing classes/interfaces matching workflow)

 Psr\Log\LoggerInterface $workflowLogger (monolog.logger.workflow)

 Symfony\Component\Workflow\Registry (workflow.registry)

 Symfony\Component\Workflow\WorkflowInterface $userStateMachine (state_machine.user)

Nous avons un nouveau service state_machine.user et un bind a été créé automatiquement, celui-ci est prêt à être injecté dans nos services. Utilisons-le pour créer un service proxy qui nous simplifiera la vie en fournissant quelques raccourcis. Appelons le UserWorkflow et mettons-le dans src/workflow. Le voici :

<?php

declare(strict_types=1);

namespace App\Workflow;

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

final class UserWorkflow
{
    public const TRANSITION_VALIDATE = 'validate';

    // public const WORKFLOW_USER_COMPLETED_VALIDATE = 'workflow.user.completed.validate';
    // use this event to send a confirmation email to your user for example.

    private WorkflowInterface $userStateMachine;

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

    public function canValidate(User $user): bool
    {
        return $this->userStateMachine->can($user, self::TRANSITION_VALIDATE);
    }

    public function validate(User $user): void
    {
        if (!$this->userStateMachine->can($user, self::TRANSITION_VALIDATE)) {
            throw new \LogicException("Can't apply the 'validate' transition on user n°{$user->getId()}°, current state: '{$user->getState()}'.");
        }

        $this->userStateMachine->apply($user, self::TRANSITION_VALIDATE);
    }
}

On injecte $userStateMachine que nous venons juste de voir dans le constructeur. On introduit une constante "validate" pour la transition de validation ; c'est la valeur que nous avons définie dans le fichier workflow.yaml. Nous créons deux méthodes : canValidate() vérifie si la transition "validate" peut-être appliquée à un utilisateur, alors que validate() va essayer d'appliquer la transition et lever une exception si ce n'est pas possible. Nous utiliserons ces deux raccourcis dans le chapitre suivant.

On peut aussi sortir le graphe du workflow afin qu'il soit plus facilement compréhensible qu'à la lecture du fichier YAML (c'est bien sûr beaucoup plus utile pour des scénarios plus complexes) :

bin/console workflow:dump user | dot -Tpng -o user-worflow.png

Voici le résultat, on retrouve les états gérés ainsi que leur seule transition possible :


Liste des utilisateurs

Génération et personnalisation du contrôleur CRUD EasyAdmin

Maintenant que le workflow est configuré, nous pouvons générer l'interface d'administration. Installons EasyAdmin (je ne détaillerai pas chaque étape, merci de vous référer à l'excellente documentation sur le site Symfony) :

composer req admin

Puis on génère le contrôleur CRUD pour l'entité User en lançant la commande et en répondant aux questions posées :

bin/console make:admin:crud

On a le fichier (presque vide) suivant :

<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\Entity\User;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;

final class UserCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return User::class;
    }

    /*
    public function configureFields(string $pageName): iterable
    {
        return [
            IdField::new('id'),
            TextField::new('title'),
            TextEditorField::new('description'),
        ];
    }
    */
}

Si on accède à cette page d'administration, on obtient l'erreur suivante (vous devez d'abord déclarer le nouveau contrôleur CRUD dans votre contrôleur de tableau de bord principal) :

The Doctrine type of the "state" field is "UserStateType", which is not supported by EasyAdmin yet.

C'est parce qu'EasyAdmin ne connait pas encore le nouveau type Doctrine qu nous avons introduit. Nous devons le configurer explicitement. Modifiez la fonction configureFields() pour gérer quelques champs, donc le champ state comme ceci :

use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
            IdField::new('id'),
            TextField::new('username'),
            ChoiceField::new('state')->setChoices(UserStateType::getChoices())->setRequired(true),
        ];
    }

    public function configureActions(Actions $actions): Actions
    {

Quelques explications : on utilise un type ChoiceField pour state, car c'est exactement ce qu'un type enum est. Pour récupérer les valeurs possibles, nous utilisons la fonction getChoices qui est fournie par le bundle DoctrineEnum. Maintenant, rafraichissons la page, nous devons avoir la sortie suivante :


Liste des utilisateurs

Ça fonctionne désormais ; on peut voir les trois champs dans la liste, mais pour la colonne correspondant au champ state, on a un code. Celui-ci correspond à ce que nous avons mis dans le type Doctrine UserState.php. On peut utiliser les traductions pour afficher quelque chose de plus compréhensible :

# translations/messages.fr.yaml
user_state_created: Créé
user_state_validated: Validé

Rafraichissons ; on doit voir les traductions affichées au lieu des codes. Voilà, nous avons une liste fonctionnelle et le champ state est correctement affiché. Désormais, ajoutons une action pour appliquer la transition "validate" du workflow à nos utilisateurs.

Création de l'action personnalisée

À la droite de la liste, dans la dernière colonne, on a deux champs : un pour modifier les utilisateurs et un pour les supprimer. Cette fois, nous devons utiliser la méthode configureActions() pour ajouter une action :

            ->linkToCrudAction('validate')
            ->displayIf(fn (User $user) => $this->userWorkflow->canValidate($user))
            ->addCssClass('btn-sm btn-success');
        $actions
            ->add(Crud::PAGE_INDEX, $validateAction);

        return $actions;
    }

    public function validate(AdminContext $context, AdminUrlGenerator $adminUrlGenerator): Response
    {

Quelques explications : on crée une nouvelle action qui pointe vers une action personnalisée (linkToCrudAction()), la fonction displayIf() va permettre d'afficher ou pas l'action uniquement si certaines conditions sont remplies. Dans notre cas, si la transition peut être appliquée à l'utilisateur. Cette fonction accepte une closure ou une fonction fléchée qui prend en argument un objet User, on peut le passer à un service pour faire des vérifications. Pour ce faire, on utilise le service UserWorkflow que nous avons introduit dans le chapitre précédent. Il est injecté dans le constructeur du contrôleur CRUD. Rafraichissons la page ; nous devrions voir les nouveaux liens :

Liste des utilisateurs

C'est OK, mais si on clique sur l'un d'entre eux, nous avons l'erreur suivante :

The controller for URI "/admin/" is not callable: Expected method "validate" on class "App\Controller\Admin\UserCrudController"

C'est parce que nous n'avons pas encore implémenté l'action personnalisée "validate". Ajoutons-la :

        if (!$user instanceof User) {
            throw new \RuntimeException('Invalid user');
        }

        /** @var Session $session */
        $session = $context->getRequest()->getSession();
        $adminUrlGenerator->setController(self::class)->setAction('index')->removeReferrer()->setEntityId(null);

        try {
            $this->userWorkflow->validate($user);
            $this->manager->flush();
            $session->getFlashBag()->add('success', "User n°{$user->getId()} validated.");
        } catch (\Exception $e) {
            $session->getFlashBag()->add('error', $e->getMessage());
        }

        return $this->redirect($adminUrlGenerator->generateUrl());
    }
}

Quelques explications. Premièrement, on récupère l'utilisateur sur qui nous voulons appliquer la transition. Il y a un helper $context pour cela ™. On vérifie le type pour s'assurer qu'on a bien affaire à un objet de type User. On peut désormais essayer d'appliquer la transition. On utilise aussi notre service UserWorkflow dans un bloc try / catch au cas où il y ait une erreur. Finalement, on peut rediriger vers la page d'accueil de ce contrôleur CRUD (la liste). Une bonne pratique est d'afficher un message flash pour donner un retour aux utilisateurs si l'action a été réussie. EasyAdmin3 les prend en charge automatiquement. Essayons l'action sur le premier utilisateur :


Liste des utilisateurs

On est redirigé, un message flash est affiché, l'état de l'utilisateur a bien été changé. Son lien de validation n'est plus disponible puisque la transition ne peut plus être appliquée à cet utilisateur. 🎉

Cliquez ici pour voir le code source complet de UserCrudController.php.
<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\DBAL\Types\UserStateType;
use App\Entity\User;
use App\Workflow\UserWorkflow;
use Doctrine\ORM\EntityManagerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;

/**
 * EasyAdmin bundle rocks, thanks Javier! 😉.
 */
final class UserCrudController extends AbstractCrudController
{
    public function __construct(
        public readonly UserWorkflow $userWorkflow,
        public readonly EntityManagerInterface $manager,
    ) {
    }

    public static function getEntityFqcn(): string
    {
        return User::class;
    }

    public function configureFields(string $pageName): iterable
    {
        return [
            IdField::new('id'),
            TextField::new('username'),
            ChoiceField::new('state')->setChoices(UserStateType::getChoices())->setRequired(true),
        ];
    }

    public function configureActions(Actions $actions): Actions
    {
        $validateAction = Action::new('validate', 'Valider', 'fa fa-check')
            ->linkToCrudAction('validate')
            ->displayIf(fn (User $user) => $this->userWorkflow->canValidate($user))
            ->addCssClass('btn-sm btn-success');
        $actions
            ->add(Crud::PAGE_INDEX, $validateAction);

        return $actions;
    }

    public function validate(AdminContext $context, AdminUrlGenerator $adminUrlGenerator): Response
    {
        $user = $context->getEntity()->getInstance();
        if (!$user instanceof User) {
            throw new \RuntimeException('Invalid user');
        }

        /** @var Session $session */
        $session = $context->getRequest()->getSession();
        $adminUrlGenerator->setController(self::class)->setAction('index')->removeReferrer()->setEntityId(null);

        try {
            $this->userWorkflow->validate($user);
            $this->manager->flush();
            $session->getFlashBag()->add('success', "User n°{$user->getId()} validated.");
        } catch (\Exception $e) {
            $session->getFlashBag()->add('error', $e->getMessage());
        }

        return $this->redirect($adminUrlGenerator->generateUrl());
    }
}

Conclusion

Nous avons vu un exemple concret d'implémentation d'un workflow dans une application Symfony. Bien sûr, c'est un exemple simple ; il y a beaucoup plus à découvrir : On peut intercepter l'événement workflow.user.completed.validate pour envoyer un email de confirmation aux utilisateurs, on peut se servir du champ état pour permettre ou pas aux utilisateurs de s'identifier... Dans le dernier SymfonyWorld Online Summer Edition 2021, il y a eu une conférence de Łukasz Chruściel à ce sujet ; "a short tale about state machine", ça vaut le coup de la regarder (le lien est réservé aux participants de la conférence pour l'instant). 😉

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  Plus sur le web

  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