Les points clés de ce retour d'expérience :
- Centralisation des règles par pays : Remplacement des conditions Twig et des tableaux éparpillés par des Country Policies découplées et testables.
- Validation dynamique par groupes Symfony : Attributs
#[Assert\Regex]isolés par groupes de pays et ordonnés viaGroupSequence. - Formulaire dynamique et FormEvents : Utilisation synchrone de
PRE_SET_DATAetPRE_SUBMITavec le pays passé en option de formulaire. - Dualité Policy vs DTO : Distinction entre l'expression métier des règles (Policy) et leur implémentation technique (DTO).
- Validation précoce pour Wise API : Contraintes sur-mesure pour détecter les erreurs de format avant les appels HTTP distants.
- Démonstration par les tests unitaires réels : Exploitation de
TypeTestCase, d'extensions de validation personnalisées et de DataProviders complets.
Introduction
Un formulaire de coordonnées bancaires paraît simple au départ, jusqu'à ce que l'application doive s'ouvrir à une dizaine de pays.
Un IBAN et un BIC pour la France, un BSB et un numéro de compte pour l'Australie, un code d'institution et un numéro de transit pour le Canada, un numéro de routage ABA pour les États-Unis... Quand un projet grandit vite, ces spécificités par pays finissent souvent par s'éparpiller directement dans les vues Twig, les contrôleurs et les scripts JavaScript.
C'est exactement le problème sur lequel je suis tombé. Voici comment j'ai refactorisé ce formulaire sous Symfony pour éviter de transformer chaque ajout de pays en jeu de pistes dans le code.
Vue d'ensemble FinTech & Refactorisation en amont :
La refonte de ce formulaire et la mise en place des CountryPolicy ont été réalisées en amont du chantier Multi-RIB. Cet assainissement préalable a servi de fondation solide pour faire évoluer l'application vers la gestion multi-comptes par annonce sans toucher aux règles de saisie. Pour découvrir l'architecture globale Multi-RIB, 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.
1. Le contexte métier et la gestion des paiements internationaux
Pour enregistrer les coordonnées de paiement d'un destinataire via l'API Wise, l'application doit collecter des informations qui varient selon les réseaux bancaires régionaux :
- Zone SEPA (FR, DE, ES...) : Saisie classique du couple IBAN et BIC.
- Canada (CA) : Le réseau local demande un numéro de compte, un code d'institution (3 chiffres) et un numéro de transit (5 chiffres).
- Australie (AU) : La banque exige un code BSB (Bank-State-Branch) à 6 chiffres, le numéro de compte et l'état (
bankState). - États-Unis (US) : Le système demande un numéro de routage ABA Routing Number (9 chiffres) et le type de compte (Checking / Savings).
- Argentine (AR) & Brésil (BR) : Il faut fournir l'identifiant fiscal du titulaire (CUIT/CUIL ou CPF).
L'API Wise permet en théorie de récupérer ces exigences dynamiquement (voir la documentation : Retrieve recipient account requirements dynamically). C'est la solution la plus propre, mais le workflow global du projet ne pouvait pas être modifié à ce moment-là. J'ai donc dû garder une validation locale pour assainir le code existant.
Avant le refactoring, le déroulement était le suivant :
- L'utilisateur choisissait un pays et remplissait les champs affichés.
- Le contrôleur PHP faisait une vérification minimale.
- L'application envoyait directement la requête à l'API Wise pour créer le compte bénéficiaire.
-
- Si l'API acceptait la saisie, les données étaient enregistrées avec l'identifiant du compte bénéficiaire renvoyé par Wise.
- En cas de rejet par Wise, l'utilisateur réaffichait le formulaire avec le message d'erreur retourné par l'API.
Le problème de ce fonctionnement : L'application déléguait toute la validation à l'API externe. Une simple faute de frappe provoquait un aller-retour réseau inutile et affichait une erreur tardive à l'utilisateur.
2. Le problème du code d'origine : la logique métier dans Twig
Sans architecture dédiée pour gérer les particularités de chaque pays, la solution retenue dans l'ancien code consistait à placer les règles d'affichage directement dans la vue Twig.
Voici un extrait du template d'origine :
{# CODE LEGACY TWIG : Conditions dures & tableaux de pays dispersés dans la vue #}
{% set class_no_errors = '' %}
{% if data.bic is not defined %}
{% set class_no_errors = 'new-payment-account-no-errors' %}
{% endif %}
<form action="{{ path('myspace_myprofile_payout') }}" method="POST" class="{{ class_no_errors }} vertical-form-container">
<div class="form-group">
<label>{{ 'name'|routeTr }}</label>
<input type="text" class="form-control paymentAccountInputField" name="cardholderName"
value="{% if data.cardholderName is defined %}{{ data.cardholderName }}{% endif %}"
placeholder="{{ 'plName'|routeTr }}" required>
<span id="js-error-for-cardholderName" class="small-text color-danger d-none">{{ 'nameError'|routeTr }}</span>
</div>
<!-- ... (champs communs : holderTown, holderPostalCode, bankName, bankAddress) ... -->
{% set bank_state_countries = ['AU'] %}
{% if countryPaymentAccount in bank_state_countries %}
<div class="form-group">
<label>{{ 'bankState'|routeTr }}</label>
<input type="text" class="form-control paymentAccountInputField" name="bankState"
value="{% if data.bankState is defined %}{{ data.bankState }}{% endif %}"
placeholder="{{ 'bankStatePlaceholder'|routeTr }}" required>
<span id="js-error-for-bankState" class="small-text color-danger d-none">{{ 'bankStateError'|routeTr }}</span>
</div>
{% endif %}
{% set no_iban_countries = ['NZ', 'CA', 'US', 'AU', 'MX', 'CU', 'VE', 'CL', 'CO', 'PE', 'EC', 'AR', 'BR', 'MA'] %}
{% if countryPaymentAccount not in no_iban_countries %}
<div class="form-group">
<label>{{ 'iban'|routeTr }}</label>
<input type="text" class="form-control paymentAccountInputField" name="iban"
value="{% if data.iban is defined %}{{ data.iban }}{% endif %}"
placeholder="{{ 'pliban'|routeTr }}" required>
<span id="js-error-for-iban" class="small-text color-danger d-none">{{ 'ibanError'|routeTr }}</span>
</div>
{% endif %}
<!-- ... (autres blocs conditionnels : bankCode, branchCode, institutionNumber, transit, accountNumber, bsb, sufix...) ... -->
{% set routing_countries = ['US'] %}
{% if countryPaymentAccount in routing_countries %}
<div class="form-group">
<label>{{ 'routingNumber'|routeTr }}</label>
<input type="text" class="form-control paymentAccountInputField" name="routingNumber"
value="{% if data.routingNumber is defined %}{{ data.routingNumber }}{% endif %}"
placeholder="{{ 'plroutingNumber'|routeTr }}" required>
<span id="js-error-for-routingNumber" class="small-text color-danger d-none">{{ 'routingNumberError'|routeTr }}</span>
</div>
{% endif %}
{% set fiscal_countries = ['BR', 'AR'] %}
{% if countryPaymentAccount in fiscal_countries %}
<div class="form-group">
<label>{{ 'fiscalNumber'|routeTr }}</label>
<input type="text" class="form-control paymentAccountInputField" name="fiscalNumber"
value="{% if data.fiscalNumber is defined %}{{ data.fiscalNumber }}{% endif %}"
placeholder="{{ 'plFiscalNumber'|routeTr }}" required>
<span id="js-error-for-fiscalNumber" class="small-text color-danger d-none">{{ 'fiscalNumberError'|routeTr }}</span>
</div>
{% endif %}
{% set account_type_countries = ['US'] %}
{% if countryPaymentAccount in account_type_countries %}
<div class="form-group">
<label>{{ 'AccountType'|routeTr }}</label>
<select class="form-control paymentAccountInputField" name="accountType" id="accountType">
<option {% if data.accountType == '' %}selected{% endif %}
value="">{{ 'choice'|routeTr }}</option>
<option {% if data.accountType == 'CHECKING' %}selected{% endif %}
value="CHECKING">{{ 'checking'|routeTr }}</option>
<option {% if data.accountType == 'SAVING' %}selected{% endif %}
value="SAVING">{{ 'saving'|routeTr }}</option>
</select>
<span id="js-error-for-AccountType" class="small-text color-danger d-none">{{ 'accountTypeError'|routeTr }}</span>
</div>
{% endif %}
<div class="tt-dis-flex tt-justify-end mt-1">
<input type="submit" id="js-payment-account-payout-submit" data-turbo="false" class="btn btn-primary btn-submit" value="{{ 'save'|routeTr }}">
</div>
</form>
Ce qui posait problème dans cette approche :
- Mélange des responsabilités : La vue Twig portait la connaissance des règles métiers (no_iban_countries, routing_countries, etc.).
- Logique dupliquée côté backend : Comme la vue n'était pas reliée à un FormType Symfony, le contrôleur devait réécrire des vérifications manuelles avant d'appeler Wise. Des écarts existaient entre le HTML et le PHP.
- Absence de tests unitaires : Pour vérifier qu'un champ s'affichait bien selon le pays, il fallait tester manuellement l'interface dans le navigateur.
3. Tableau comparatif Avant / Après
Voici le bilan comparatif entre l'ancien fonctionnement et la nouvelle organisation :
| Axe d'analyse | Avant (Legacy) | Après (Country Policies & DTO) |
|---|---|---|
| Localisation des règles | Dispersées dans Twig, JS, contrôleur et API distante | Centralisées dans PaymentAccountCountryPolicies & PaymentAccountDetailsDto |
| Validation des données | Champs HTML + conditions if/else dans le contrôleur | DTO typé avec attributs #[Assert\Regex] et GroupSequence |
| Gestion du formulaire | Template Twig monolithique rempli d'instructions if |
FormType dynamique réagissant aux événements Symfony Form |
| Ajout d'un pays | Modifications dans la vue Twig, le JS et le contrôleur PHP | 1 entrée dans la Policy, 1 groupe dans le DTO et son cas de test |
| Testabilité | Validation manuelle dans le navigateur | Tests unitaires rapides avec TypeTestCase |
Ce que cela change à l'usage :
- Pour l'utilisateur : Les erreurs de format (IBAN, BSB, routing number) sont attrapées directement par la validation locale avant d'envoyer la requête HTTP à l'API Wise.
- Pour le code : La logique éparpillée dans la vue Twig est désormais regroupée dans 1 Policy, 1 DTO, 1 FormType et testée avec PHPUnit.
4. L'architecture retenue et le découpage des responsabilités
L'objectif de la refonte était d'extraire les règles métier de la vue Twig tout en gardant un formulaire Symfony strict et testable.
CountryPolicies Résolution des champs & groupes
PRE_SET_DATA / PRE_SUBMIT
GroupSequence & Regex
Le traitement repose sur trois classes principales :
- La Policy (PaymentAccountCountryPolicies) : Une classe PHP simple qui associe à chaque pays la liste des champs autorisés et les groupes de validation à appliquer.
- Le Formulaire (PaymentAccountDetailsType) : Le FormType Symfony qui modifie la liste des champs selon le pays lors des événements du formulaire.
- Le DTO (PaymentAccountDetailsDto) : L'objet PHP 8 qui contient les données saisies et porte les contraintes de validation (attributs #[Assert\Regex]).
5. Déclaration des règles par pays dans la Policy
La classe PaymentAccountCountryPolicies utilise la structure match de PHP 8 pour retourner la configuration du pays :
<?php
declare(strict_types=1);
namespace App\Application\Policy\Form\PaymentAccount;
final class PaymentAccountCountryPolicies
{
public static function forCountry(string $country): PaymentAccountCountryPolicy
{
return match ($country) {
'AR' => new PaymentAccountCountryPolicy(
new PaymentAccountFieldPolicy(['accountNumber', 'fiscalNumber', 'cardholderName']),
new PaymentAccountValidationPolicy(['PAYMENT_ACCOUNT_AR']),
),
'AU' => new PaymentAccountCountryPolicy(
new PaymentAccountFieldPolicy(['accountNumber', 'bic', 'bsb', 'bankState', 'cardholderName']),
new PaymentAccountValidationPolicy(['PAYMENT_ACCOUNT_REQ_BIC', 'PAYMENT_ACCOUNT_REQ_ACCOUNT_NUMBER', 'PAYMENT_ACCOUNT_AU']),
),
'CA' => new PaymentAccountCountryPolicy(
new PaymentAccountFieldPolicy(['accountNumber', 'bic', 'institutionNumber', 'transit', 'cardholderName']),
new PaymentAccountValidationPolicy(['PAYMENT_ACCOUNT_REQ_BIC', 'PAYMENT_ACCOUNT_REQ_ACCOUNT_NUMBER', 'PAYMENT_ACCOUNT_CA']),
),
'US' => new PaymentAccountCountryPolicy(
new PaymentAccountFieldPolicy(['accountNumber', 'bic', 'routingNumber', 'cardholderName']),
new PaymentAccountValidationPolicy(['PAYMENT_ACCOUNT_REQ_BIC', 'PAYMENT_ACCOUNT_REQ_ACCOUNT_NUMBER', 'PAYMENT_ACCOUNT_US']),
),
default => new PaymentAccountCountryPolicy(
new PaymentAccountFieldPolicy(['iban', 'bic', 'cardholderName']),
new PaymentAccountValidationPolicy(['PAYMENT_ACCOUNT_REQ_IBAN', 'PAYMENT_ACCOUNT_REQ_BIC']),
),
};
}
}
Rôle de la Policy vs DTO :
- La Policy : Détermine quels champs sont requis et quels groupes de validation doivent s'activer pour un pays donné.
- Le DTO : Porte la définition exacte des règles de validation (regex, contraintes d'obligation) attachées à ces groupes.
Pour l'Australie par exemple, la policy indique les 5 champs nécessaires (accountNumber, bic, bsb, bankState, cardholderName). Le PaymentAccountDetailsType lit cette liste et génère la liste déroulante des états australiens (PaymentAccountBankState::AUSTRALIA) pour le champ bankState.
6. Construction dynamique du formulaire avec FormEvents
Dans notre cas, le pays du destinataire est sélectionné avant d'afficher le formulaire et passé dans l'option 'country'. Pour ajuster les champs au moment du rendu et à la soumission des données, le PaymentAccountDetailsType écoute deux événements : PRE_SET_DATA et PRE_SUBMIT.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Form\PaymentAccount;
use App\Application\Policy\Form\PaymentAccount\PaymentAccountCountryPolicies;
use App\Domain\Enum\PaymentAccountBankState;
use App\Infrastructure\Form\PaymentAccount\Dto\PaymentAccountDetailsDto;
use ReflectionClass;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\GroupSequence;
final class PaymentAccountDetailsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$country = $options['country'];
$countryFields = PaymentAccountCountryPolicies::forCountry($country)->fieldsPolicy();
// 1. Ajout initial des champs partagés
foreach (PaymentAccountDetailsDto::getSharedFields() as $field) {
$builder->add($field['name'], TextType::class, ['required' => true]);
}
// 2. Ajustement des champs à l'affichage
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($countryFields) {
$this->applyConditional($event->getForm(), $countryFields->fields());
});
// 3. Ajustement des champs à la soumission HTTP
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($countryFields) {
$this->applyConditional($event->getForm(), $countryFields->fields());
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => PaymentAccountDetailsDto::class,
'country' => null,
'validation_groups' => function (FormInterface $form) {
$country = $form->getConfig()->getOption('country');
$policy = PaymentAccountCountryPolicies::forCountry($country)->validationPolicy();
return new GroupSequence($policy->validationGroups());
},
]);
$resolver->setRequired('country');
}
private function applyConditional(FormInterface $form, array $required): void
{
$reflectionPaymentAccountDetails = new ReflectionClass(PaymentAccountDetailsDto::class);
$sharedFieldsList = PaymentAccountDetailsDto::getSharedFields();
$sharedFieldsListNames = PaymentAccountDetailsDto::retrieveOrderedByPriorityFieldsNames($sharedFieldsList);
// Suppression des champs non partagés qui ne concernent pas le pays
foreach ($reflectionPaymentAccountDetails->getProperties() as $property) {
if (!\in_array($property->getName(), $sharedFieldsListNames) && $form->has($property->getName())) {
$form->remove($property->getName());
}
}
// Ajout des champs requis pour le pays
foreach ($required as $name) {
if ('bankState' === $name) {
$choices = array_combine(PaymentAccountBankState::AUSTRALIA, PaymentAccountBankState::AUSTRALIA);
$form->add('bankState', ChoiceType::class, [
'required' => true,
'placeholder' => '',
'choices' => $choices,
]);
} else {
$form->add($name, TextType::class, ['required' => false]);
}
}
}
}
Note de sécurité : L'écoute sur PRE_SUBMIT est obligatoire. Même si du code JavaScript masque des éléments dans le navigateur pour améliorer l'affichage, seul le filtrage côté serveur sur PRE_SUBMIT garantit que l'utilisateur ne peut pas envoyer de champs non prévus pour son pays.
7. Validation ciblée dans le DTO avec GroupSequence
La validation du DTO s'appuie sur les groupes configurés dans configureOptions. Pour l'Australie, la policy fournit la séquence GroupSequence(['PAYMENT_ACCOUNT_REQ_BIC', 'PAYMENT_ACCOUNT_REQ_ACCOUNT_NUMBER', 'PAYMENT_ACCOUNT_AU']).
Voici un extrait de la classe PaymentAccountDetailsDto avec ses attributs de validation PHP 8 :
<?php
declare(strict_types=1);
namespace App\Infrastructure\Form\PaymentAccount\Dto;
use App\Application\Validator\Form\Constraint\AtLeastTwoWordsConstraint;
use App\Application\Validator\Form\Constraint\BicConstraint;
use App\Application\Validator\Form\Constraint\IbanConstraint;
use App\Application\Validator\Form\Constraint\Partners\Wise\AccountHolderNameConstraint;
use Symfony\Component\Validator\Constraints as Assert;
class PaymentAccountDetailsDto
{
#[Assert\NotBlank]
#[AtLeastTwoWordsConstraint]
#[AccountHolderNameConstraint]
#[PaymentAccountSharedFields]
private ?string $cardholderName = null;
// ... (champs communs : $holderTown, $holderPostalCode, $bankName, $bankAddress)
#[Assert\NotBlank(groups: ['PAYMENT_ACCOUNT_REQ_BIC'])]
#[BicConstraint(groups: ['PAYMENT_ACCOUNT_REQ_BIC'])]
private ?string $bic = null;
#[Assert\NotBlank(groups: ['PAYMENT_ACCOUNT_REQ_IBAN'])]
#[IbanConstraint(groups: ['PAYMENT_ACCOUNT_REQ_IBAN'])]
private ?string $iban = null;
#[Assert\NotBlank(groups: ['PAYMENT_ACCOUNT_REQ_ACCOUNT_NUMBER', 'PAYMENT_ACCOUNT_AR'])]
#[Assert\Regex('/^[\d\s]{0,32}$/', groups: ['PAYMENT_ACCOUNT_REQ_ACCOUNT_NUMBER'])]
#[Assert\Regex('/^\\d{3} ?\\d{4} ?\\d ?\\d{13} ?\\d$/', groups: ['PAYMENT_ACCOUNT_AR'])]
private ?string $accountNumber = null;
#[Assert\NotBlank(groups: ['PAYMENT_ACCOUNT_AU'])]
private ?string $bankState = null;
#[Assert\NotBlank(groups: ['PAYMENT_ACCOUNT_AU'])]
#[Assert\Regex('/^(?:\d{3}-\d{3}|\d{0,10})$/', groups: ['PAYMENT_ACCOUNT_AU'])]
private ?string $bsb = null;
#[Assert\NotBlank(groups: ['PAYMENT_ACCOUNT_US'])]
#[Assert\Regex('/^\d{9}$/', groups: ['PAYMENT_ACCOUNT_US'])]
private ?string $routingNumber = null;
#[Assert\NotBlank(groups: ['PAYMENT_ACCOUNT_AR', 'PAYMENT_ACCOUNT_BR'])]
#[Assert\Regex('/^\\d{2}-?\\d{8}-?\\d$/', groups: ['PAYMENT_ACCOUNT_AR'])]
private ?string $fiscalNumber = null;
// ... (autres champs spécifiques par pays, getters/setters et méthodes de réflexion)
}
L'intérêt d'une GroupSequence :
La GroupSequence exécute les groupes un par un dans l'ordre défini. Si un champ obligatoire manque lors de la première étape (PAYMENT_ACCOUNT_REQ_BIC), Symfony arrête la validation immédiatement. Il n'évalue pas les regex complexes du groupe suivant (PAYMENT_ACCOUNT_AU), ce qui évite de cumuler plusieurs messages d'erreur incompréhensibles sur un même champ vide.
8. Validation métier spécifique : les contraintes personnalisées Wise
Certaines règles de validation ne s'expriment pas facilement avec une simple regex. Pour le nom du titulaire, j'ai utilisé deux contraintes dédiées : AccountHolderNameConstraint et AtLeastTwoWordsConstraint.
Cela apporte deux avantages principaux :
- Une intention lisible : Le nom de la contrainte explique la règle métier (ex : vérifier la présence de deux mots séparés).
- Des erreurs évitées avant l'appel API : Les critères imposés par Wise sur le format des noms sont vérifiés localement avant d'envoyer la requête HTTP.
Voici l'implémentation du validateur AccountHolderNameValidator :
<?php
declare(strict_types=1);
namespace App\Application\Validator\Form\ConstraintValidator\Partners\Wise;
use App\Application\Validator\Form\Constraint\Partners\Wise\AccountHolderNameConstraint;
use App\Infrastructure\Repository\Cache\TextFrontCache;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class AccountHolderNameValidator extends ConstraintValidator
{
/**
* Voir documentation Wise : https://docs.wise.com/api-reference/recipient#create
*/
public const WISE_VALIDATION_REGEX = '/[^0-9A-Za-zÀ-ÖØ-öø-ÿ-_\'()*,\.\s]/u';
public const PRIVATE_WISE_VALIDATION_REGEX = '/[^A-Za-zÀ-ÖØ-öø-ÿ-_\'()*,\.\s]/u';
public function __construct(private TextFrontCache $textFrontCache)
{
}
/** @param AccountHolderNameConstraint $constraint */
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof AccountHolderNameConstraint) {
throw new UnexpectedTypeException($constraint, AccountHolderNameConstraint::class);
}
$message = $this->textFrontCache->find('privateAccountHolderNameValidationError', 'validator');
if (null === $value || '' === $value) {
return;
}
if (!\is_string($value)) {
return;
}
if (\preg_match(AccountHolderNameValidator::PRIVATE_WISE_VALIDATION_REGEX, $value)) {
$this->context->buildViolation($message)->addViolation();
return;
}
}
}
Selon la documentation de l'API Wise (Wise Recipient Specification), les caractères acceptés pour le nom du bénéficiaire différent s'il s'agit d'un particulier ou d'une entreprise.
Exemples de valeurs vérifiées par la regex pour un compte particulier :
| Valeur testée | Résultat | Détail |
|---|---|---|
Jean Dupont |
Valide | Lettres standard et espace |
François Müller |
Valide | Accents et umlauts autorisés (UTF-8 À-öø-ÿ) |
John O'Connor |
Valide | Apostrophe autorisée |
John @ Smith |
Invalide | Le caractère @ n'est pas accepté par Wise |
ACME <Ltd> |
Invalide | Les chevrons < > sont refusés |
Remarque : Cette validation locale reproduit les contraintes documentées par Wise pour intercepter les erreurs courantes. L'API distante conserve la décision finale au moment de créer le compte bénéficiaire.
9. Hydratation de l'entité vers le DTO avec PropertyAccess
Pour remplir le DTO lors de la modification d'un compte de paiement existant sans lier l'entité Doctrine directement au formulaire, le contrôleur s'appuie sur le composant PropertyAccess de Symfony :
<?php
declare(strict_types=1);
namespace App\Infrastructure\Controller;
use App\Domain\Entity\PaymentAccount;
use App\Infrastructure\Form\PaymentAccount\Dto\PaymentAccountDetailsDto;
use App\Infrastructure\Form\PaymentAccount\PaymentAccountDetailsType;
use Symfony\Component\PropertyAccess\PropertyAccess;
final class PaymentAccountModifyController
{
private function hydratePaymentAccountDetails(?PaymentAccount $paymentAccount = null): PaymentAccountDetailsDto
{
$paymentAccountDetails = new PaymentAccountDetailsDto();
if (!$paymentAccount) {
return $paymentAccountDetails;
}
$accessor = PropertyAccess::createPropertyAccessor();
foreach (PaymentAccountDetailsType::PAYMENT_ACCOUNT_TO_DETAILS_MAP as $property) {
if ($accessor->isReadable($paymentAccount, $property) && $accessor->isWritable($paymentAccountDetails, $property)) {
$accessor->setValue($paymentAccountDetails, $property, $accessor->getValue($paymentAccount, $property));
}
}
return $paymentAccountDetails;
}
}
Le contrôle combiné de isReadable() et isWritable() évite d'écrire à la main chaque affectation $dto->setX($entity->getX()) tout en tolérant les évolutions de propriétés. Ce découplage automatique reposant sur le nommage des champs, il convient de le couvrir par des tests unitaires.
10. Tests unitaires du formulaire (PaymentAccountDetailsTypeTest.php)
Grâce à l'utilisation de TypeTestCase fourni par Symfony, la validation du formulaire et l'intégration des contraintes personnalisées (Wise, BIC, IBAN) sont vérifiées directement par des tests unitaires automatisés :
<?php
declare(strict_types=1);
namespace Tests\Infrastructure\Form\PaymentAccount;
use App\Application\Validator\Form\Constraint\Partners\Wise\AccountHolderNameConstraint;
use App\Application\Validator\Form\ConstraintValidator\Partners\Wise\AccountHolderNameValidator;
use App\Infrastructure\Form\PaymentAccount\Dto\PaymentAccountDetailsDto;
use App\Infrastructure\Form\PaymentAccount\PaymentAccountDetailsType;
use App\Infrastructure\Repository\Cache\TextFrontCache;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidatorFactory;
use Symfony\Component\Validator\ConstraintValidatorFactoryInterface;
use Symfony\Component\Validator\ConstraintValidatorInterface;
class PaymentAccountDetailsTypeTest extends TypeTestCase
{
#[DataProvider('validPaymentAccount')]
public function test_submit_valid_data(string $country, PaymentAccountDetailsDto $datas): void
{
$form = $this->factory->create(PaymentAccountDetailsType::class, $datas, [
'country' => $country,
]);
$form->submit($datas->toArray());
$this->assertTrue($form->isSynchronized(), 'data is not bind to form');
if (!$form->isValid()) {
$errorMessages = $this->getFormErrors($form);
$this->fail('Form is not valid for country ' . $country . ':' . PHP_EOL . $errorMessages);
}
$this->assertTrue($form->isValid(), 'form is not valid');
}
#[DataProvider('invalidPaymentAccount')]
public function test_submit_invalid_data(
string $country,
PaymentAccountDetailsDto $datas,
array $expect
): void {
$form = $this->factory->create(PaymentAccountDetailsType::class, $datas, [
'country' => $country,
]);
$form->submit($datas->toArray());
$this->assertTrue($form->isSynchronized(), 'data is not bind to form');
$this->assertFalse($form->isValid(), "Form unexpectedly valid for country {$country}.");
$actualInvalidFields = $this->getInvalidFieldSet($form);
$expectedFields = $expect['inErrorFields'] ?? [];
$this->assertFieldsContain($expectedFields, $actualInvalidFields, $country);
}
private function getInvalidFieldSet(FormInterface $form): array
{
$set = [];
foreach ($form->getErrors(true) as $error) {
$origin = method_exists($error, 'getOrigin') ? $error->getOrigin() : null;
if ($origin instanceof FormInterface && $origin->getParent() !== null) {
$set[$this->buildFieldPath($origin)] = true;
}
}
return array_keys($set);
}
private function buildFieldPath(FormInterface $field): string
{
$parts = [];
$cur = $field;
while ($cur->getParent() instanceof FormInterface) {
array_unshift($parts, $cur->getName());
$cur = $cur->getParent();
}
return implode('.', $parts);
}
private function assertFieldsContain(array $expected, array $actual, string $country): void
{
$missing = array_values(array_diff($expected, $actual));
if (!empty($missing)) {
$this->fail("Missing invalid inErrorFields for {$country}: " . implode(', ', $missing));
}
}
}
11. Exemple pratique : ajouter le Mexique dans la configuration
Pour vérifier la souplesse du système, voici les étapes nécessaires pour prendre en charge un nouveau pays (le Mexique, code MX, exigeant un numéro CLABE à 18 chiffres, un BIC et le nom du titulaire) :
1. Ajout de la règle dans PaymentAccountCountryPolicies.php
'MX' => new PaymentAccountCountryPolicy(
new PaymentAccountFieldPolicy(['accountNumber', 'bic', 'cardholderName']),
new PaymentAccountValidationPolicy(['PAYMENT_ACCOUNT_REQ_BIC', 'PAYMENT_ACCOUNT_REQ_ACCOUNT_NUMBER', 'PAYMENT_ACCOUNT_MX']),
),
2. Déclaration du groupe dans le DTO PaymentAccountDetailsDto.php
#[Assert\NotBlank(groups: ['PAYMENT_ACCOUNT_MX'])]
#[Assert\Regex('/^\d{18}$/', groups: ['PAYMENT_ACCOUNT_MX'])]
private ?string $accountNumber = null;
3. Ajout du cas de test dans le DataProvider
yield 'MX_valid' => ['MX', (new PaymentAccountDetailsDto())->setAccountNumber('123456789012345678')->setBic('MEXIMXMM')->setCardholderName('Juan Perez')];
L'ajout s'effectue en 3 modifications ciblées (Policy, groupe du DTO, test). La vue Twig, le contrôleur et la couche JavaScript n'ont pas besoin d'être retouchés.
12. Limites et pistes d'amélioration
Cette approche présente deux compromis à garder en tête :
- Inspection par réflexion dans applyConditional : Lister les propriétés du DTO via ReflectionClass évite de répéter les noms de champs, mais introduit une passe dynamique. Si le nombre de champs augmente fortement, une déclaration explicite dans la Policy ('accountNumber' => TextType::class) peut s'avérer plus lisible.
- Nombre important de pays : Pour une dizaine de pays, l'instruction match reste simple à maintenir. Si le projet devait gérer plus de 50 pays avec des règles très variables, il deviendrait préférable d'isoler chaque pays dans une classe dédiée (Strategy Pattern) ou un fichier de configuration.
Pourquoi utiliser du code PHP plutôt qu'un fichier YAML/JSON ?
Le choix d'une classe PHP permet de profiter du typage strict de PHP 8, de l'autocomplétion dans l'IDE et de l'analyse statique avec PHPStan sans passer par le chargement d'un fichier de configuration externe.
13. Ce qu'il faut retenir
Les enseignements principaux de cette refonte :
- Des vues déchargées de la logique métier : Twig se concentre sur le rendu visuel sans contenir de conditions dures sur les pays.
- Des règles regroupées par pays : Déclarer la liste des champs et des groupes de validation dans une Policy facilite la lecture et évite les régressions.
- Une validation testable unitairement : L'utilisation d'un DTO typé et d'un FormType dynamique permet de vérifier l'ensemble des règles avec des tests PHPUnit rapides.
En résumé : L'objectif principal de ce refactoring a été d'extraire les règles de gestion du formulaire pour les placer dans des classes PHP dédiées et autonomes.