#[AutowireIterator] : le Strategy pattern en une ligne avec Symfony
J’ai un service de signature électronique en prod. DocuSign, ça tourne, pas de souci. Sauf qu’on veut intégrer Yousign en parallèle, d’abord pour 10% du trafic, puis progressivement monter. Et si Yousign plante, on veut retomber automatiquement sur DocuSign.
Le genre de besoin qui sent le if/else à rallonge si on ne structure pas un minimum.
Considérons l’exemple suivant.
L’interface commune
Première étape : définir ce que “signer un document” veut dire, indépendamment du provider.
namespace App\Signature;
interface SignatureProviderInterface{ public function getName(): string;
public function sign(Document $document, Signer $signer): SignatureResult;
public function isAvailable(): bool;}C’est le Strategy pattern dans sa forme la plus simple : une interface, plusieurs implémentations interchangeables. Le code appelant ne sait pas (et n’a pas besoin de savoir) quel provider il utilise.
Deux implémentations
namespace App\Signature\Provider;
class DocuSignProvider implements SignatureProviderInterface{ public function getName(): string { return 'docusign'; }
public function sign(Document $document, Signer $signer): SignatureResult { // API calls to DocuSign }
public function isAvailable(): bool { // Health check (ping API, check credentials, etc.) return true; }}namespace App\Signature\Provider;
class YousignProvider implements SignatureProviderInterface{ public function getName(): string { return 'yousign'; }
public function sign(Document $document, Signer $signer): SignatureResult { // API calls to Yousign }
public function isAvailable(): bool { return true; }}Rien de surprenant. Deux classes, même interface.
L’orchestrateur avec AutowireIterator
namespace App\Signature;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
class SignatureOrchestrator{ public function __construct( /** @var iterable<SignatureProviderInterface> */ #[AutowireIterator(SignatureProviderInterface::class, defaultPriorityMethod: 'getPriority')] private iterable $providers, ) { }}C’est cette ligne qui fait le travail. #[AutowireIterator] dit à Symfony : “injecte-moi tous les services qui implémentent SignatureProviderInterface”. Pas de configuration YAML, pas de CompilerPass, pas de câblage manuel. Symfony collecte automatiquement toutes les implémentations.
Le defaultPriorityMethod indique à Symfony d’appeler getPriority() sur chaque provider pour déterminer l’ordre d’itération. La priorité est portée par le contrat de l’interface, au même titre que le nom. Pas par la config du container.
Pour que le tag soit appliqué automatiquement à chaque classe qui implémente l’interface, il suffit de le déclarer une fois :
namespace App\Signature;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]interface SignatureProviderInterface{ public function getName(): string;
public function getPriority(): int;
public function sign(Document $document, Signer $signer): SignatureResult;
public function isAvailable(): bool;}Toute classe qui implémente SignatureProviderInterface reçoit le tag automatiquement, avec le FQCN de l’interface comme nom de tag (App\Signature\SignatureProviderInterface). Et on en profite pour ajouter getPriority() au contrat : chaque provider déclare son ordre de priorité, au même titre que son nom. Zéro configuration par provider.
Côté implémentation, chaque provider retourne simplement sa priorité. Plus la valeur est haute, plus il sera essayé en premier :
// DocuSignProviderpublic function getPriority(): int{ return 10; // Primary provider}
// YousignProviderpublic function getPriority(): int{ return 5; // Secondary provider}Circuit breaker
La logique de fallback. Si le provider choisi est indisponible ou plante, on passe au suivant :
public function sign(Document $document, Signer $signer): SignatureResult{ $lastException = null;
foreach ($this->providers as $provider) { if (!$provider->isAvailable()) { continue; }
try { return $provider->sign($document, $signer); } catch (SignatureProviderException $e) { $lastException = $e; // Log and try the next provider continue; } }
throw new NoAvailableProviderException( 'All signature providers failed.', previous: $lastException );}On itère sur les providers. Le premier qui répond, on prend. S’il plante, on essaie le suivant. Si aucun ne fonctionne, on remonte une exception explicite.
Pas besoin de librairie de circuit breaker externe pour ce cas simple. L’itérateur fait le boulot.
Rollout progressif
Pour le rollout, on veut diriger un pourcentage du trafic vers un provider spécifique. Une config simple suffit :
parameters: signature.rollout: yousign: 10 # 10% of traffic goes to Yousign docusign: 90 # 90% goes to DocuSignEt on adapte l’orchestrateur :
class SignatureOrchestrator{ public function __construct( /** @var iterable<SignatureProviderInterface> */ #[AutowireIterator(SignatureProviderInterface::class)] private iterable $providers, #[Autowire('%signature.rollout%')] private array $rolloutConfig, ) { }
public function sign(Document $document, Signer $signer): SignatureResult { $provider = $this->selectProvider();
try { return $provider->sign($document, $signer); } catch (SignatureProviderException) { // Fallback: try the others return $this->fallback($document, $signer, exclude: $provider->getName()); } }
private function selectProvider(): SignatureProviderInterface { $rand = random_int(1, 100); $cumulative = 0;
foreach ($this->providers as $provider) { $cumulative += $this->rolloutConfig[$provider->getName()] ?? 0;
if ($rand <= $cumulative) { return $provider; } }
throw new \LogicException('Rollout configuration does not cover all cases.'); }
private function fallback( Document $document, Signer $signer, string $exclude ): SignatureResult { foreach ($this->providers as $provider) { if ($provider->getName() === $exclude) { continue; }
if (!$provider->isAvailable()) { continue; }
try { return $provider->sign($document, $signer); } catch (SignatureProviderException) { continue; } }
throw new NoAvailableProviderException('All signature providers failed.'); }}On passe Yousign à 30% ? On change un chiffre dans la config. On veut tout basculer ? yousign: 100. Pas une ligne de code à toucher.
En production, on voudra probablement aller plus loin : un utilisateur orienté vers Yousign lors de sa première signature devrait y rester pour les suivantes, histoire de ne pas lui offrir une expérience incohérente. Ça se gère en persistant le choix du provider par utilisateur, mais c’est un sujet à part entière, pas le propos ici.
Ajouter un troisième provider
C’est là qu’on voit la puissance du pattern. On veut ajouter Universign ? Une seule classe :
namespace App\Signature\Provider;
class UniversignProvider implements SignatureProviderInterface{ public function getName(): string { return 'universign'; }
public function getPriority(): int { return 1; }
public function sign(Document $document, Signer $signer): SignatureResult { // API calls to Universign }
public function isAvailable(): bool { return true; }}Et on met à jour la config de rollout :
parameters: signature.rollout: yousign: 30 universign: 20 docusign: 50Zéro modification dans l’orchestrateur. Symfony détecte automatiquement le nouveau service, le tag est appliqué via l’interface, et AutowireIterator l’injecte. Seule la config de rollout change, le code reste intact.
Ce qu’il faut retenir
AutowireIterator ne se limite pas au Strategy pattern, mais il s’y prête particulièrement bien. Pas de factory manuelle, pas de CompilerPass, pas de switch/case qui grossit à chaque nouveau provider.
Le mécanisme est simple :
- Une interface avec
#[AutoconfigureTag] - Des classes qui implémentent cette interface
- Un service qui reçoit
#[AutowireIterator]et itère dessus
L’exemple présenté ici est volontairement simpliste, il ne couvre pas tous les edge cases d’un vrai système en production. Mais c’était le but : montrer avec un cas concret la puissance d’une des fonctionnalités que je préfère dans Symfony. Son injection de dépendances et la facilité avec laquelle on peut orchestrer tout un ensemble de services, c’est ce qui rend le framework aussi agréable à utiliser au quotidien.