Créer un workflow ou une state-machine orientés métier avec le composant symfony-workflow

TL ; DR : je ne suis pas un grand fan de la manipulation de mes entités avec des mutateurs du type set*. L’utilisation du composant workflow avec cette contrainte n’est pas possible dans son comportement par défaut et… C’est embêtant. Heureusement, ce « problème » se corrige avec deux petits fichiers.

Le composant workflow est un composant créé par Grégoire Pineau dont l’objectif est de mettre en place « une machine à état ». Pour faire simple, cela permet de stocker l’état d’un objet via une propriété afin de pouvoir valider puis appliquer des modifications sur cet objet. Si vous n’avez jamais utilisé de machine à état, vous devez certainement manipuler beaucoup de conditions et comme vous avez pu vous en apercevoir, ce n’est pas une bonne pratique sur le long terme.

Pour résumer au plus simple, une machine à état fonctionne de la façon suivante :

Les transitions peuvent être plus ou moins complexes. C’est la différence entre une State Machine avec des transitions linéaires et un Workflow avec des transitions complexes. Le composant workflow propose bien entendu les deux possibilités.

Le problème

Les concepteurs·rices du framework Symfony ont fait un choix : celui de diriger la conception du code depuis la base de données. C’est pour cette raison que le code des entités se base sur des accesseurs/mutateurs ─ les getter/setter. Pour une propriété foo, un accesseur getFoo() ainsi qu’un mutateur setFoo($foo) vous seront proposés.

Le composant workflow, via son MarkingStore suit ce principe. Mon problème est donc le suivant : mon code ne respectant pas cette approche (au bénéfice d’une autre approche orienté métier), je n’utilise pas du tout ces mutateurs. Dans mon cas, une même opération agit sur une ou plusieurs propriétés et ces opérations sont explicitement nommées.

J’utilise souvent mes propres StateMachines dans ces projets métiers (alors que j’apprécie énormément ce composant dans des projets plus classiques) et ça m’embête. Ça m’embête d’avoir ces StateMachines à maintenir, ça m’embête d’avoir une StateMachines « maison » à côté d’un workflow etc.

La résolution

N'étant pas fan de cette situation, je me suis demandé s’il était possible de faire quelque chose et autant commencer par demander. Grégoire m'a alors rappelé qu'il est possible de créer son propre MarkingStore pour ensuite le déclarer dans la configuration du workflow.

Cela tombe très bien, car :

J’ai donc opté pour la résolution suivante :

Naïvement, on obtient les deux classes suivantes (copier-coller pour vous éviter de chercher et comprendre rapidement ce que fait le MarkingStore) :

<?php

namespace App\Infrastructure\Workflow;

use Symfony\Component\Workflow\Exception\LogicException;
use Symfony\Component\Workflow\Marking;
use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface;

final class AppMarkingStore implements MarkingStoreInterface
{
    private bool $singleState;
    private string $property;

    public function __construct(bool $singleState = true, string $property = 'state')
    {
        $this->singleState = $singleState;
        $this->property = $property;
    }

    public function getMarking(object $subject): Marking
    {
        $method = 'get'.ucfirst($this->property);
        if (!method_exists($subject, $method)) {
            throw new LogicException(sprintf('The method "%s::%s()" does not exist.', \get_class($subject), $method));
        }

        $marking = $subject->{$method}();
        if (!$marking) {
            return new Marking();
        }

        if ($this->singleState) {
            $marking = [(string) $marking => 1];
        }

        return new Marking($marking);
    }

    public function setMarking(object $subject, Marking $marking, array $context = []): void
    {
        $marking = $marking->getPlaces();

        if ($this->singleState) {
            $marking = key($marking);
        }

        $method = 'set'.ucfirst($this->property);

        // Le plus important
        if (true === isset($context['method'])) {
            if (true === method_exists($subject, $context['method'])) {
                $method = $context['method'];
            }
        }

        if (!method_exists($subject, $method)) {
            throw new LogicException(sprintf('The method "%s::%s()" does not exist.', \get_class($subject), $method));
        }

        $subject->{$method}($marking, $context);
    }
}
<?php
declare(strict_types=1);

namespace App\Infrastructure\Workflow;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\TransitionEvent;

final class TransitionEventSubscriber implements EventSubscriberInterface
{
    public function onWorkflowTransition(TransitionEvent $event)
    {
        $context = $event->getContext();

        // Si la transition s'appelle "register", le context indiquera au MarkingStore d'aller voir si la fonction register est disponible
        $context['method'] = $event->getTransition()->getName();

        $event->setContext($context);
    }

    public static function getSubscribedEvents()
    {
        return [
            TransitionEvent::class => 'onWorkflowTransition',
        ];
    }
}

Côté configuration, nous aurons par exemple :

framework:
    workflows:
        registration:
            type: 'state_machine'
            audit_trail:
                enabled: true
            marking_store:
                service: App\Infrastructure\Workflow\AppMarkingStore
            supports:
                - App\Domain\Entity\Account
            initial_marking: !php/const App\Domain\Enum\StateEnum::CREATING
            places:
                - !php/const App\Domain\Enum\StateEnum::CREATING
                - !php/const App\Domain\Enum\StateEnum::REGISTERED
                - !php/const App\Domain\Enum\StateEnum::KNOWN
                - !php/const App\Domain\Enum\StateEnum::AFFILIATED
            transitions:
                register:
                    from: !php/const App\Domain\Enum\StateEnum::CREATING
                    to: !php/const App\Domain\Enum\StateEnum::REGISTERED
                complete:
                    from: !php/const App\Domain\Enum\StateEnum::REGISTERED
                    to: !php/const App\Domain\Enum\StateEnum::KNOWN
                affiliate:
                    from: !php/const App\Domain\Enum\StateEnum::KNOWN
                    to: !php/const App\Domain\Enum\StateEnum::AFFILIATED

Bonus : piqûre de rappel sur l’utilisation des constantes PHP en yaml.

Pour l’utilisation, tout est identique, vous pouvez vous référer à la documentation officielle.

Conclusion

Une fois de plus avec Symfony et ses composants, beaucoup de choses sont possibles. Il suffit de chercher, de fouiller et d’essayer. Le fait est qu'il est très facile d'utiliser le composant workflow quel que soit son cas d’usage et c’est le point le plus important.

Au jour le jour, les développeurs juniors ne sont pas forcément dépaysés, ils n’ont même pas à s’intéresser plus que ça au code du MarkingStore et peuvent l’utiliser sans s'en préoccuper. Cela me convient parfaitement.