← Retour aux cas d'études
Architecture Événementielle & Composant Symfony Workflow

Gérer le cycle de vie des coordonnées bancaires (Multi-RIB) avec le Composant Symfony Workflow

Les points clés de ce retour d'expérience :

  • State Machine déclarative : Configuration YAML stricte dans workflow_payment_account.yaml définissant les états (CREATED, EDITING, VALIDATED) et les transitions autorisées via des constantes d'énumération PHP 8.
  • Tâche d'application dédiée (PaymentAccountTransitionTask) : Encapsulation du Registry Symfony Workflow pour sécuriser l'exécution des transitions et consigner les logs d'échec.
  • Effets de bord découplés avec EventSubscribers : Implémentation de PaymentAccountTransitionSubscriber écoutant les événements de transition pour réassigner automatiquement les comptes bancaires aux annonces.
  • Documentation visuelle automatisée : Génération continue des schémas d'état au format SVG via bin/console workflow:dump payment_account_status.

1. Exigences produit et complexité métier du Multi-RIB

Sur le plan fonctionnel et réglementaire, les spécifications produit exigeaient de permettre aux hébergeurs de gérer plusieurs comptes bancaires (ex : compte personnel vs compte SCI) tout en assurant un contrôle strict avant l'émission des virements de loyers.

Ces règles métier imposaient plusieurs contraintes fortes :

  • Un compte bancaire créé devait obligatoirement passer par une phase de validation avant de pouvoir être crédité.
  • Toute modification d'un compte validé (changement d'IBAN ou de titulaire) devait le repasser immédiatement à l'état non validé (EDITING).
  • Lorsqu'un compte bancaire passait à l'état validé (VALIDATED), les annonces de l'hôte dépourvues de compte rattaché devaient être automatiquement assignées à ce nouveau compte.

Pour répondre à ces exigences sans éparpiller des if/else fragiles dans mes contrôleurs HTTP, j'ai fait le choix d'intégrer le Composant Symfony Workflow sous forme de State Machine stricte.

Vue d'ensemble de l'architecture FinTech :
Cet article est un sous-article technique centré sur la State Machine Symfony Workflow. Pour découvrir la vision globale de la refonte bancaire Multi-RIB (découplage du domaine et routage des versements par annonce), consultez le méta-article dédié :
👉 Lire le méta-article : Refactoriser la gestion bancaire hébergeurs sous Symfony (Multi-RIB) →

Note importante et confidentialité : Pour respecter mes obligations de confidentialité et préserver la propriété intellectuelle, l'ensemble des extraits et exemples de code présentés ici a été entièrement recréé et anonymisé. De ce fait, des imprécisions ou de légères erreurs peuvent exister dans les exemples : le but de cet article est d'illustrer la démarche d'architecture et la réflexion technique, et non de fournir du code prêt à l'emploi.

2. Tableau comparatif : Avant / Après Workflow

Axe d'analyse Avant (Logique procédurale dispersée) Après (Composant Symfony Workflow)
Définition des états Statuts modifiables à tout moment via des setters directs sans garde-fou. State Machine déclarative en YAML : transitions interdites bloquées par exception.
Gestion des effets de bord Appels de services imbriqués dans les contrôleurs HTTP. PaymentAccountTransitionSubscriber dédié écoutant les événements de transition.
Lisibilité & Maintenance Règles métier dispersées dans plusieurs contrôleurs et endpoints API (CreatePaymentAccountApi, DeletePaymentAccountApi, PayoutController, etc.). Fichier workflow_payment_account.yaml centralisé et visualisable avec workflow:dump.

3. Configuration déclarative : workflow_payment_account.yaml

J'ai déclaré la machine à états dans config/packages/workflows/workflow_payment_account.yaml en utilisant le type state_machine pour garantir l'unicité de l'état :

framework:
    workflows:
        payment_account_status:
            type: 'state_machine'
            marking_store:
                type: 'method'
                property: 'statusFromWorkflow'
            supports:
                - App\Domain\Entity\PaymentAccount
            initial_marking: !php/const App\Domain\Enum\PaymentAccountStatus::CREATED
            places:
                - !php/const App\Domain\Enum\PaymentAccountStatus::CREATED
                - !php/const App\Domain\Enum\PaymentAccountStatus::EDITING
                - !php/const App\Domain\Enum\PaymentAccountStatus::VALIDATED
            transitions:
                !php/const App\Domain\Enum\PaymentAccountStatusTransition::EDIT:
                    from: [!php/const App\Domain\Enum\PaymentAccountStatus::EDITING, !php/const App\Domain\Enum\PaymentAccountStatus::VALIDATED]
                    to: !php/const App\Domain\Enum\PaymentAccountStatus::EDITING
                !php/const App\Domain\Enum\PaymentAccountStatusTransition::VALIDATE_SUCCESS:
                    from: [!php/const App\Domain\Enum\PaymentAccountStatus::CREATED, !php/const App\Domain\Enum\PaymentAccountStatus::EDITING]
                    to: !php/const App\Domain\Enum\PaymentAccountStatus::VALIDATED
                !php/const App\Domain\Enum\PaymentAccountStatusTransition::VALIDATE_REJECT:
                    from: [!php/const App\Domain\Enum\PaymentAccountStatus::CREATED, !php/const App\Domain\Enum\PaymentAccountStatus::EDITING]
                    to: !php/const App\Domain\Enum\PaymentAccountStatus::EDITING

4. Service d'application : PaymentAccountTransitionTask

J'ai créé la tâche applicative PaymentAccountTransitionTask pour isoler l'utilisation du Registry Symfony et consigner les rejets de transition :

<?php

declare(strict_types=1);

namespace App\Application\Task\Transition;

use App\Application\Exception\PaymentAccountTransitionException;
use App\Domain\Entity\PaymentAccount;
use App\Domain\Enum\PaymentAccountStatusTransition;
use App\Domain\Manager\EntityManagerInterface;
use App\Domain\Utils\LogUtils;
use ErrorException;
use LogicException;
use Symfony\Component\Workflow\Registry;

final class PaymentAccountTransitionTask extends AbstractTransitionTask
{
    private ?PaymentAccount $paymentAccount = null;
    private ?array $fields = null;

    public function __construct(
        Registry $registry,
        private readonly EntityManagerInterface $em,
    ) {
        parent::__construct($registry);
    }

    public function exec(): void
    {
        $this->checkParams();
        if (!isset($this->paymentAccount)) {
            throw new ErrorException('bad params');
        }
        if (!PaymentAccountStatusTransition::isValid($this->transition)) {
            throw new ErrorException('bad payment_account transition: ' . $this->transition);
        }

        $paymentAccount = $this->paymentAccount;
        $transition = $this->transition;
        $from = $this->from;
        $context = $this->context;

        $workflow = $this->registry->get($paymentAccount, 'payment_account_status');
        $context['from'] = $from;

        if ($this->fields) {
            $context['fields'] = $this->fields;
        }

        try {
            $workflow->apply($paymentAccount, $transition, $context);
            $this->em->flush();
        } catch (LogicException $e) {
            LogUtils::exception2($e, 'workflow LOGIC FAILED', [
                'transition' => $transition,
                'status' => $paymentAccount->getStatus(),
                'context' => $context,
            ], false);
            throw new PaymentAccountTransitionException($e->getMessage());
        }
    }

    public function withPaymentAccount(PaymentAccount $paymentAccount): self
    {
        $this->paymentAccount = $paymentAccount;
        return $this;
    }
}

Points d'appel et déclenchement du Workflow :
Cette tâche applicative est injectée et appelée dans plusieurs contrôleurs et endpoints API pour piloter le cycle de vie du compte bancaire :

  • Modification de RIB en AJAX (UpdateRibDataApi) : Dès qu'un hôte modifie un champ (IBAN, BIC, titulaire) sur un compte déjà existant, la tâche est déclenchée avec la transition EDIT pour le repasser immédiatement à l'état non vérifié EDITING.
  • Changement de pays de versement (PayoutChangeCountryApi) : Le changement de pays réinvoque la tâche avec la transition EDIT afin de réévaluer les règles bancaires du nouveau pays.
  • Callback & Webhook de validation FinTech (WiseWebhookHandler) : Lors de la réception de la confirmation bancaire tierce, la tâche est appelée avec la transition VALIDATE_SUCCESS (ou VALIDATE_REJECT en cas de rejet par la banque).

Voici un exemple concrétisant son appel au sein de l'API de mise à jour des coordonnées (UpdateRibDataApi) :

<?php

declare(strict_types=1);

namespace App\Infrastructure\Api\Front\MySpace\MyProfile;

use App\Application\Task\Transition\PaymentAccountTransitionTask;
use App\Domain\Enum\PaymentAccountStatus;
use App\Domain\Enum\PaymentAccountStatusTransition;
use App\Domain\Utils\LogUtils;
use Exception;

//! don't apply workflow on created PaymentAccount
// as editing them w/o a first validation have no business impact and data are persisted solely for user convenience
if (PaymentAccountStatus::CREATED !== $paymentAccount->getStatus()) {
    try {
        $this->paymentAccountTransitionTask
            ->withPaymentAccount($paymentAccount)
            ->withFrom(__CLASS__)
            ->withTransition(PaymentAccountStatusTransition::EDIT)
            ->exec();
    } catch (Exception $e) {
        LogUtils::exception2($e, 'Update PaymentAccount data api error: ', ['error' => $e->getMessage()]);
        return new JsonStatusResponse('ko');
    }
} else {
    $this->em->flush();
}

return new JsonStatusOkResponse();

5. Automatisation événementielle avec PaymentAccountTransitionSubscriber

J'ai mis en place l'EventSubscriber suivant pour traiter les effets de bord métiers lors des transitions :

<?php

declare(strict_types=1);

namespace App\Application\Workflow;

use App\Domain\Business\PaymentAccountBusiness;
use App\Domain\Entity\PaymentAccount;
use App\Domain\Enum\PaymentAccountStatusTransition;
use App\Domain\Repository\ListingRepositoryInterface;
use App\Domain\Repository\PaymentAccountRepositoryInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\TransitionEvent;

final class PaymentAccountTransitionSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly PaymentAccountBusiness $paymentAccountBusiness,
        private readonly ListingRepositoryInterface $listingRepository,
        private readonly PaymentAccountRepositoryInterface $paymentAccountRepository,
    ) {
    }

    public function processTransition(TransitionEvent $event): void
    {
        /** @var PaymentAccount $paymentAccount */
        $paymentAccount = $event->getSubject();
        $transition = $event->getTransition()->getName();

        switch ($transition) {
            case PaymentAccountStatusTransition::VALIDATE_SUCCESS:
                $noPaymentAccountListings = $this->listingRepository->findAllByPaymentAccountOrNullForUser($paymentAccount->getUser(), null);
                $this->paymentAccountBusiness->ensureDefaultPaymentAccount($paymentAccount, $noPaymentAccountListings);
                break;

            case PaymentAccountStatusTransition::EDIT:
                $paymentAccountListings = $this->listingRepository->findAllByPaymentAccount($paymentAccount);
                $newDefaultPaymentAccount = $this->paymentAccountRepository->findDefaultCandidateByUser($paymentAccount->getUser(), $paymentAccount);
                $this->paymentAccountBusiness->unvalidatePaymentAccount($paymentAccount, $newDefaultPaymentAccount, $paymentAccountListings);
                break;

            default:
                break;
        }
    }

    public static function getSubscribedEvents(): array
    {
        return [
            'workflow.payment_account_status.transition' => 'processTransition',
        ];
    }
}

6. Enseignements et arbitrages d'ingénierie

Sur le plan technique et personnel, cette implémentation m'a permis de mettre en pratique plusieurs principes d'architecture fondamentaux :

  • Découplage événementiel des effets de bord : En extrayant les réassignations d'annonces et l'invalidation des comptes bancaires dans PaymentAccountTransitionSubscriber, j'ai totalement purgé mes contrôleurs HTTP de la logique procédurale.
  • Rigueur de la State Machine YAML : J'ai configuré la State Machine workflow_payment_account.yaml avec typage strict par constantes d'énumération PHP 8 (PaymentAccountStatus / PaymentAccountStatusTransition), garantissant qu'aucune transition non déclarée ne peut être franchie.
  • Isolation de la tâche d'exécution : J'ai encapsulé l'accès au Registry Symfony au sein de PaymentAccountTransitionTask pour centraliser le traitement des erreurs et les logs de rejets sans polluer le domaine.