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 ! 😎
» 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.9
- 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 :
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 :
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 :
Ç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 :
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 :
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. 😊
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 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 ! 😉
[🇫🇷] Deuxième article de l'année (oui, je suis très en retard !) : "Mise en place d'un workflow utilisateur avec Symfony et EasyAdmin3" https://t.co/W1yJnOJwLb Relectures, retours, likes et retweets sont les bienvenus ! 😉 Objectif annuel : 2/10 #symfony #php #easyadmin #workflow
— COil #StaySafe 🏡 #OnEstLaTech ✊ (@C0il) June 30, 2021