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.