Ce composant, introduit dans Symfony 3.2, est extrêmement puissant. Il permet de créer des state machines (donc un seul état possible à chaque fois) ou des workflows (plusieurs états possibles à chaque fois). Cela peut être très utile lorsque vous désirez gérer une suite d’états dépendant d’un ou plusieurs autres états avec des règles précises de passage d’un état à un autre.
Il a été utilisé dans mon entreprise pour gérer le traitement de fichiers zip: dézippage, parcours de son contenu, lecture du xml contenu dedans, enregistrement en bdd…
Il est possible de s’en servir pour gérer des règles de gestion d’articles selon les rôles admin/journaliste, comme le montre le code de ce repo: https://github.com/lyrixx/SFLive-Paris2016-Workflow
Mots clef à retenir:
- Transition: passage d’un état à un autre, représenté par des flèches ou des rectangles
- Places: état, représenté par un rond
Les principales classes du composant workflow sont:
- Transition: représente une Transition
- Definition: contient les Places (string), les Transitions et l’état initial
- Marking: contient l’état actuel / les états actuels (il s’agit d’un array de type [‘accepted’ => 1])
- MarkingStore: est l’interface entre le workflow et le sujet (= l’objet)
- Workflow: nous aide à décider quelles actions sont autorisées pour un sujet
- Registry: stocke et fournit un accès aux différents workflows
Workflow vs Statemachine
La classe StateMachine étend la classe Workflow.
Dans une stateMachine, il n’y a toujours qu’un et un seul état possible:
Ici, soit on passe de in_review à changed_needed, soit on passe de in_review à published, mais on ne peut à la fois être dans l’état changes_needed et dans l’état published.
Dans un workflow, plusieurs états en parallèle sont possibles:
Ici, selon les transitions appliquées, on est dans un ou deux états parallèles:
- wait_for_journalist & wait_for_spellchecker,
- approved_by_journalist & wait_for_spellchecker,
- ou published.
Et détail croustillant, on ne peut passer à l’état published que si les deux états approved_by_journalist et approved_by_spellchecker sont atteints. Vous commencez à voir l’intérêt du composant workflow autre que juste s’assurer que des étapes s’exécutent dans le bon ordre?
Définissons notre workflow
Tout d’abord, nous définissons un workflow de type stateMachine:
framework: workflows: post: type: state_machine audit_trail: true supports: AppBundle\Entity\Post marking_store: type: single_state arguments: [status] type: state_machine places: [new, accepted, published] transitions: accept: from: new to: accepted reject: from: accepted to: new publish: from: accepted to: published unpublish: from: published to: accepted
post: il s’agit du nom du workflow. Il aura son importance pour attraper les events dispatchés par le composant.
type: state_machine ou workflow?
audit_trail: permet de logger l’entrée et la sortie d’une place, l’exécution d’une transition.
support: l’entité pour laquelle nous allons suivre l’évolution du statut
marking_store.type: est-ce de type single_state ou multiple_state?
marking_store.arguments: par défaut, cette valeur est marking, ce qui oblige à définir un attribut marking dans l’entité. Ici, nous décidons que le statut aura la propriété status en base et dans notre entité
places: les états possibles que notre entité va avoir
transitions: les transitions pour passer d’un état à un autre
Rajoutons l’attribut $status à notre entité
<?php namespace AppBundle\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; /** * @ORM\Entity(repositoryClass="AppBundle\Repository\PostRepository") * @ORM\Table(name="post") */ class Post { /** * @var int * * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") */ private $id; /** * @var string * * @ORM\Column(type="text") * @Assert\NotBlank(message="post.blank_content") * @Assert\Length(min=10, minMessage="post.too_short_content") */ private $content; /** * @var \DateTime * * @ORM\Column(type="datetime") * @Assert\DateTime */ private $publishedAt; /** * @var string * * @ORM\Column(type="string", nullable=true) */ private $status; public function getId() { return $this->id; } public function getContent() { return $this->content; } /** * @param string $content */ public function setContent($content) { $this->content = $content; } public function getPublishedAt() { return $this->publishedAt; } public function setPublishedAt(\DateTime $publishedAt = null) { $this->publishedAt = $publishedAt; } /** * @return string */ public function getStatus() { return $this->status; } /** * @param string $status */ public function setStatus($status) { $this->status = $status; } }
Les filtres Twig
Twig possède quatre filtres qui permettent d’interagir avec le composant workflow:
workflow_can()
Retournetrue
si l’objet donné peut faire la transitionworkflow_transitions()
Retourne un tableau avec toutes les transitions possibles pour l’objetworkflow_marked_places()
Retourne un tableau avec les états courants de l’objetworkflow_has_marked_place()
Retournetrue
si l’objet a bien l’état donné
<h3>Actions</h3> {% if workflow_can(post, 'publish') %} <a href="...">Publish article</a> {% endif %} {% if workflow_can(post, 'accept') %} <a href="...">Accept article</a> {% endif %} {% if workflow_can(post, 'reject') %} <a href="...">Reject article</a> {% endif %} {# Or loop through the enabled transitions #} {% for transition in workflow_transitions(post) %} <a href="...">{{ transition.name }}</a> {% else %} No actions available. {% endfor %} {# Check if the object is in some specific place #} {% if workflow_has_marked_place(post, 'accept') %} <p>This post is ready for being accepted.</p> {% endif %} {# Check if some place has been marked on the object #} {% if 'waiting_some_approval' in workflow_marked_places(post) %} <span class="label">PENDING</span> {% endif %}
Dans le cas d’un crud, nous pouvons mettre ceci dans la vue:
<div class="section actions"> <form action="{{ url('admin_post_edit_workflow', { id: post.id }) }}" method="post"> <div class="btn-toolbar" role="toolbar"> {% for transition in workflow_transitions(post) %} <button value="{{ transition.name }}" name="transition" type="submit" class="btn btn-primary btn-lg" > {{ transition.name|title }} </button> {% endfor %} </div> <input type="hidden" name="token" value="{{ csrf_token('') }}"> </form> </div>
Au départ, nous avons le bouton Accept:
En cliquant dessus, nous voyons apparaître les boutons reject et publish:
En cliquant sur publish, nous voyons apparaître le bouton unpublish
L’action du contrôleur pour appliquer les transitions
Mais pour que le comportement ci dessus fonctionne, il faut bien sûr appliquer la transition à chaque fois dans le contrôleur:
/** * @Route("/{id}/workflow", requirements={"id": "\d+"}, name="admin_post_edit_workflow") * @Method("POST") * @param Post $post * @param Request $request * * @return \Symfony\Component\HttpFoundation\RedirectResponse */ public function editWorkflowAction(Post $post, Request $request) { if (!$this->isCsrfTokenValid('', $request->request->get('token'))) { throw $this->createAccessDeniedException('noooope!'); } try { $this->get('state_machine.post')->apply($post, $request->request->get('transition')); } catch (LogicException $e) { $this->addFlash('warning', 'Oups, this transition is not available anymore.'); return $this->redirectToRoute('admin_post_edit', ['id' => $post->getId()]); } $this->get('doctrine.orm.default_entity_manager')->flush(); $this->addFlash('success', 'post.updated_successfully'); return $this->redirectToRoute('admin_post_edit', ['id' => $post->getId()]); }
Et c’est tout! A chaque fois que l’on appliquera une transition, le nom du nouveau statut sera enregistré en base sous forme de string.
Les events
Ah mais ce n’est pas tout. S’il ne permettait que faire cela, le composant workflow serait un peu limité. Mais à chaque étape, de nombreux events sont dispatchés!
workflow.guard
-
Valide si une transition est autorisée
Les trois events dispatchés sont:
workflow.guard
workflow.[workflow name].guard
workflow.[workflow name].guard.[transition name]
-
[su_spacer]
workflow.leave
-
L’objet est sur le point de quitter un état (une place)
Les trois events dispatchés sont:
workflow.leave
workflow.[workflow name].leave
workflow.[workflow name].leave.[place name]
-
[su_spacer]
workflow.transition
-
L’objet est en train d’effectuer sa transition
Les trois events dispatchés sont:
workflow.transition
workflow.[workflow name].transition
workflow.[workflow name].transition.[transition name]
-
[su_spacer]
workflow.enter
-
L’objet est entrée dans un nouvel état (place). C’est le premier event où l’objet est marqué comme étant à une nouvelle place
Les trois events dispatchés sont:
workflow.enter
workflow.[workflow name].enter
workflow.[workflow name].enter.[place name]
[su_spacer]
workflow.entered
Similaire à workflow.enter
, excepté que le champ status (le marking store) est mis à jour avant cette event (en faisant une bonne place pour flusher les data dans Doctrine).
Les trois events dispatchés sont:
workflow.entered
workflow.[workflow name].entered
workflow.[workflow name].entered.[place name]
-
[su_spacer]
workflow.announce
-
Lancé à chaque transition qui est à présent accessible pour l’objet
Les trois events dispatchés sont:
workflow.announce
workflow.[workflow name].announce
workflow.[workflow name].announce.[transition name]
[su_spacer]
Notre premier event
Créons un subscriber qui va écouter la transition publish et mettre à jour la date de publication:
<?php namespace AppBundle\EventListener; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Workflow\Event\Event; class PostWorkflowSubscriber implements EventSubscriberInterface { public static function getSubscribedEvents() { return [ 'workflow.post.transition.publish' => 'setPublishedAt', ]; } /** * @param Event $event */ public function setPublishedAt(Event $event) { $post = $event->getSubject(); if (!$post->getPublishedAt()) { $post->setPublishedAt(new \DateTime()); } } }
N’oublions pas de l’enregistrer dans la classe services.yml:
app.workflow.listener.post: class: AppBundle\EventListener\PostWorkflowSubscriber tags: - { name: kernel.event_subscriber }
Il n’y a rien d’autre à faire.
[su_spacer]
Et si je veux ne permettre qu’aux super admins de publier ou de dépublier?
Très bonne question, et la réponse est extrêmement simple:
1) soit en écoutant les events workflow.post.guard.publish et workflow.post.guard.unpublish en injectant l’authorization_checker et en appelant la méthode isGranted
<?php namespace AppBundle\EventListener; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Workflow\Event\Event; use Symfony\Component\Workflow\Event\GuardEvent; class PostWorkflowSubscriber implements EventSubscriberInterface { /** * @var AuthorizationCheckerInterface */ private $authorizationChecker; public function __construct(AuthorizationCheckerInterface $authorizationChecker) { $this->authorizationChecker = $authorizationChecker; } public static function getSubscribedEvents() { return [ 'workflow.post.transition.publish' => 'setPublishedAt', 'workflow.post.guard.publish' => 'onPublishTransition', 'workflow.post.guard.unpublish' => 'onUnpublishTransition', ]; } /** * @param Event $event */ public function setPublishedAt(Event $event) { $post = $event->getSubject(); if (!$post->getPublishedAt()) { $post->setPublishedAt(new \DateTime()); } } public function onPublishTransition(GuardEvent $event) { if (!$this->authorizationChecker->isGranted('ROLE_SUPER_ADMIN', $event->getSubject())) { $event->setBlocked(true); } } public function onUnpublishTransition(GuardEvent $event) { if (!$this->authorizationChecker->isGranted('ROLE_SUPER_ADMIN', $event->getSubject())) { $event->setBlocked(true); } } }
2) soit en faisant beaucoup plus simple que cela avec l’expression language:
framework: workflows: post: type: state_machine audit_trail: true supports: AppBundle\Entity\Post marking_store: type: single_state arguments: [status] type: state_machine places: [new, accepted, published] transitions: accept: from: new to: accepted reject: from: accepted to: new publish: from: accepted to: published guard: has_role('ROLE_SUPER_ADMIN') unpublish: from: published to: accepted guard: has_role('ROLE_SUPER_ADMIN')
Elle est pas belle, la vie ? 😉
[su_spacer]
Notre propre service de mise à jour de statut
Eh oui, jusque là, nous avons laissé le composant workflow mettre à jour le statut, mais si nous décidions qu’il devait être mis à jour selon des règles précises?
Nous allons créer un service permettant de mettre à jour le statut sous forme de bit (pour permettre le cumul des droits). Ainsi, IS_NEW correspondra au bit 1 (0001), ACCEPTED au bit 2 (0010), et PUBLISHED au bit 4 (0100).
<?php namespace AppBundle\Workflow; use Symfony\Component\Workflow\Marking; use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface; class BitMarkingStore implements MarkingStoreInterface { const IS_NEW = 1; const ACCEPTED = 2; const PUBLISHED = 4; public function getMarking($post) { $places = []; $bitField = $post->getStatus(); if ($bitField & self::IS_NEW) { $places['new'] = 1; } if ($bitField & self::ACCEPTED) { $places['accepted'] = 1; } if ($bitField & self::PUBLISHED) { $places['published'] = 1; } return new Marking($places); } public function setMarking($post, Marking $marking) { $bitField = 0; if ($marking->has('new')) { $bitField |= self::IS_NEW; } if ($marking->has('accepted')) { $bitField |= self::ACCEPTED; } if ($marking->has('published')) { $bitField |= self::PUBLISHED; } $post->setStatus($bitField); } }
Déclarons le service:
app.workflow.marking_store.bit: class: AppBundle\Workflow\BitMarkingStore
Modifions le workflow:
framework: workflows: post: type: state_machine audit_trail: true supports: AppBundle\Entity\Post marking_store: #type: single_state #arguments: [status] service: app.workflow.marking_store.bit type: state_machine places: [new, accepted, published] transitions: accept: from: new to: accepted reject: from: accepted to: new publish: from: accepted to: published guard: has_role('ROLE_SUPER_ADMIN') unpublish: from: published to: accepted guard: has_role('ROLE_SUPER_ADMIN')
Et modifions notre entité:
/** * @var integer * * @ORM\Column(type="integer", nullable=true) */ private $status; /** * @return integer */ public function getStatus() { return $this->status; } /** * @param integer $status */ public function setStatus($status) { $this->status = $status; }
Bon, là, je vous vois venir, le fait que j’ai utilisé des bits ne sert pas à grand chose et vous avez raison. En fait, c’est par prévoyance. C’est à dire que si je créé à un moment le statut 3, que va-t-il se passer? Bingo! J’aurai comme places possibles celle du statut 1 et celle du statut 2 car 0011, c’est l’addition de 0001 et 0010.
[su_spacer]
Modification de la requête de récupération des articles
Nous ne voulons récupérer que les articles qui sont publiés, donc qui ont le champ status avec un bit 4. Pour vérifier cela, nous allons faire appel à la fonction BIT_AND qui fait un & et qui regarde ce qu’il y a de commun entre deux nombres exprimés sous forme de bits.
Par exemple, 6 & 3 donnera 2: 0110 & 0011.
Ce qu’il y a en commun est 0010, soit 2.
Nous allons comparer le statut de chaque article avec 4 pour vérifier que le & donne un nombre supérieur à 0.
4 & 1 donne 0.
4 & 2 donne 0.
4 & 4 donne 4.
La requête devient donc:
$query = $this->getEntityManager() ->createQuery(' SELECT p FROM AppBundle:Post p WHERE p.publishedAt <= :now AND BIT_AND(p.status, :status) > 0 ORDER BY p.publishedAt DESC ') ->setParameter('now', new \DateTime()) ->setParameter('status', 4) ;
Le mot de la fin
Ce composant est vraiment puissant et regorge de possibilités.
Attention, il y a quelques spécificités:
- Si on utilise un service pour gérer le statut, on ne peut plus mettre type: single_state ou type: multiple_state dans la config (c’est pour ça que je l’ai commenté)
- Si on utilise un type worflow plutôt qu’un type state_machine, il faudra appeler le service $this->get(‘workflow.nom_du_workflow’) plutôt que $this->get(‘state_machine.nom_du_workflow’)
- Il est bien sûr possible, dans un workflow, de définir plusieurs places dans un from du style:
publish: from: [accepted, in_review] to: published
2 comments
Encore merci Jean Pierre pour cette présentation de l’outils workflow.
C’est exactement ce qu’il me faut pour un projet en cours.
Bonne continuation
Merci!