/service-tags — Tags, collections, factories et décorateurs
Utilise quand tu collectes une famille de services (strategy, chain) ou décores un service existant. Pas quand tu enregistres un service simple →
/symfony:service-define. Pas quand tu câbles un argument scalaire/env →/symfony:service-wire.
Tu orchestres plusieurs services via le container : collection de règles métier, chain of responsibility, strategy pattern, décoration d'un service existant, construction via factory. Tu privilégies les attributs PHP (#[AutoconfigureTag], #[AsTaggedItem], #[AsDecorator]) à la configuration YAML, et tu ne passes aux Compiler Passes qu'en dernier recours.
Détection préalable (obligatoire)
- Lire
composer.json— vérifiersymfony/framework-bundle. - Lire
config/services.yamlpour repérer_defaults(autowire/autoconfigure actifs attendus) et les éventuels bindings de!tagged_iteratordéjà en place. - Si le service à tagger n'est pas encore enregistré → passer par
/symfony:service-define.
Règles fondamentales
- Ne pas réinventer un pattern déjà taggué : event listeners, commandes, messenger handlers, voters, twig extensions sont déjà auto-taggués par l'autoconfiguration (cf.
/symfony:service-define). Tagger manuellement uniquement pour un tag applicatif custom. #[AutoconfigureTag]sur l'interface : le bon point d'ancrage pour « toute classe qui implémentePaymentRuleInterfaceest taggée ». Évite de tagger service par service.- Consommer via
iterableouServiceLocator, jamais en scannant le container :!tagged_iteratorpour une chaîne à parcourir,!tagged_locatorpour un accès par clé (lazy). - Priorité via
#[AsTaggedItem]: quand l'ordre compte (chain of responsibility), poserprioritysur les classes. Plus grand = d'abord. - Décoration via
#[AsDecorator]uniquement : modifier un service vendor sans le remplacer. Jamais d'override de service vendor par redéfinition manuelle du même id. - Compiler Pass = dernier recours : à utiliser quand aucun attribut ni YAML ne permet la manipulation souhaitée (ex : supprimer un service selon une condition dynamique au build). Sinon, c'est une over-engineering.
Pattern 1 : collection de règles (iterator)
Cas d'usage
Appliquer plusieurs règles de validation sur une commande, chacune décidant si elle valide ou non. Le consommateur les parcourt toutes.
Définir l'interface et l'autoconfiguration
// src/Order/Rule/OrderRuleInterface.php
declare(strict_types=1);
namespace App\Order\Rule;
use App\Order\Order;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('app.order_rule')]
interface OrderRuleInterface
{
public function supports(Order $order): bool;
public function validate(Order $order): void;
}
Implémentations
// src/Order/Rule/MinimumAmountRule.php
declare(strict_types=1);
namespace App\Order\Rule;
use App\Order\Order;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
#[AsTaggedItem(priority: 100)]
final class MinimumAmountRule implements OrderRuleInterface
{
public function supports(Order $order): bool
{
return true;
}
public function validate(Order $order): void
{
if ($order->totalCents() < 500) {
throw new \DomainException('Minimum order is 5€');
}
}
}
Aucune ligne de config — #[AutoconfigureTag] tague l'implémentation, #[AsTaggedItem] donne sa priorité.
Consommer la collection
// src/Order/OrderValidator.php
declare(strict_types=1);
namespace App\Order;
use App\Order\Rule\OrderRuleInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class OrderValidator
{
public function __construct(
/** @var iterable<OrderRuleInterface> */
#[AutowireIterator('app.order_rule')]
private readonly iterable $rules,
) {}
public function validate(Order $order): void
{
foreach ($this->rules as $rule) {
if ($rule->supports($order)) {
$rule->validate($order);
}
}
}
}
Alternatives :
- Via YAML binding global (utile si plusieurs services consomment la même collection) :
services:
_defaults:
autowire: true
autoconfigure: true
bind:
iterable $orderRules: !tagged_iterator app.order_rule
- Via attribut sans binding :
#[AutowireIterator('app.order_rule')](depuis Symfony 6.3). Préféré pour un consommateur unique.
Tri par priorité
L'ordre est celui déclaré dans le container sauf si une priorité est posée. Tri descendant (100 avant 50 avant 0). Pour trier par une autre clé :
iterable $rules: !tagged_iterator { tag: app.order_rule, default_priority_method: getPriority }
getPriority() sur chaque classe retourne l'int. Pratique quand la priorité vient d'une config métier.
Pattern 2 : sélection par clé (service locator)
Cas d'usage
Choisir un handler parmi N en fonction d'une clé runtime (type d'événement, type de document, code pays). Le consommateur ne veut pas instancier tous les handlers, seulement celui demandé.
Définir les handlers avec un index
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
#[AsTaggedItem(index: 'invoice')]
final class InvoicePdfRenderer implements DocumentRendererInterface { /* ... */ }
#[AsTaggedItem(index: 'quote')]
final class QuotePdfRenderer implements DocumentRendererInterface { /* ... */ }
#[AsTaggedItem(index: 'receipt')]
final class ReceiptPdfRenderer implements DocumentRendererInterface { /* ... */ }
Consommer via ServiceLocator
use App\Document\DocumentRendererInterface;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
final class DocumentDispatcher
{
public function __construct(
#[AutowireLocator('app.document_renderer')]
private readonly ContainerInterface $renderers,
) {}
public function render(string $type, Document $doc): string
{
if (!$this->renderers->has($type)) {
throw new \InvalidArgumentException("No renderer for {$type}");
}
/** @var DocumentRendererInterface $renderer */
$renderer = $this->renderers->get($type);
return $renderer->render($doc);
}
}
Le locator instancie le handler uniquement au get() — aucun coût pour les renderers non utilisés.
Alternative via YAML
services:
App\Document\DocumentDispatcher:
arguments:
$renderers: !tagged_locator { tag: app.document_renderer, index_by: 'key' }
index_by: 'key' utilise l'attribut key du tag (ou index de #[AsTaggedItem]).
Pattern 3 : factory
Utiliser une factory quand l'instanciation du service dépend d'une logique trop complexe pour un constructeur (choix selon env, appel à une API externe au boot, calcul à partir de plusieurs sources).
Factory méthode statique
// src/Http/HttpClientFactory.php
declare(strict_types=1);
namespace App\Http;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class HttpClientFactory
{
public static function create(string $baseUrl, int $timeout): HttpClientInterface
{
return HttpClient::create([
'base_uri' => $baseUrl,
'timeout' => $timeout,
'headers' => ['Accept' => 'application/json'],
]);
}
}
services:
app.acme_http_client:
class: Symfony\Contracts\HttpClient\HttpClientInterface
factory: [App\Http\HttpClientFactory, create]
arguments:
$baseUrl: '%env(ACME_BASE_URL)%'
$timeout: 10
Factory service (méthode non-statique)
services:
App\Http\HttpClientFactory: ~
app.acme_http_client:
class: Symfony\Contracts\HttpClient\HttpClientInterface
factory: ['@App\Http\HttpClientFactory', 'create']
arguments:
$baseUrl: '%env(ACME_BASE_URL)%'
$timeout: 10
from_callable (Symfony 6.3+)
services:
app.message_formatter:
class: App\Service\MessageFormatterInterface
from_callable: [!service { class: App\Service\MessageUtils }, format]
Crée un adaptateur qui résout un callable en implémentation d'une interface à une seule méthode.
Pattern 4 : décoration (#[AsDecorator])
Cas d'usage
Ajouter un comportement autour d'un service existant (vendor, Symfony, Sylius) sans le remplacer : logging, cache, rate-limiting, transformation.
Avec attribut (préféré)
// src/Mailer/LoggingMailer.php
declare(strict_types=1);
namespace App\Mailer;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\RawMessage;
#[AsDecorator(decorates: 'mailer.mailer')]
final class LoggingMailer implements MailerInterface
{
public function __construct(
private readonly MailerInterface $inner,
private readonly LoggerInterface $logger,
) {}
public function send(RawMessage $message, ?Envelope $envelope = null): void
{
$this->logger->info('Sending email', ['subject' => $message->getHeaders()->get('Subject')?->getBodyAsString()]);
$this->inner->send($message, $envelope);
}
}
decorates: 'mailer.mailer'nomme le service cible (id).- L'argument
$innerreçoit automatiquement le service original — pas besoin de l'autowirer. - Chaîner plusieurs décorateurs :
#[AsDecorator(decorates: 'mailer.mailer', priority: 10)](priorité haute = plus proche du service original).
Avec YAML
services:
App\Mailer\LoggingMailer:
decorates: 'mailer.mailer'
arguments:
$inner: '@.inner'
$logger: '@logger'
Le placeholder @.inner (depuis 5.1) remplace l'ancien @app.mailer.inner.
Quand décorer vs étendre
- Décoration : le comportement s'ajoute autour de toutes les occurrences d'utilisation du service existant (logging, cache, metrics). Transparent pour les consommateurs.
- Alias vers une sous-classe : le comportement remplace la logique pour un cas d'usage précis. Plus intrusif, moins composable.
En règle générale : si on veut ajouter, décorer. Si on veut remplacer, alias.
Pattern 5 : #[AsAlias] pour exposer un service sous un autre nom
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
#[AsAlias(id: PaymentGatewayInterface::class)]
final class StripeGateway implements PaymentGatewayInterface { /* ... */ }
Équivalent attribut du App\Payment\PaymentGatewayInterface: '@App\Payment\StripeGateway' en YAML. Utile pour désigner l'implémentation par défaut d'une interface (complémentaire de /symfony:service-wire pour le câblage).
Pattern 6 : Compiler Pass (dernier recours)
Un Compiler Pass manipule le container pendant la compilation, après que toutes les extensions bundles ont tourné. À utiliser pour :
- Supprimer dynamiquement un service selon une condition calculée (pas un simple
when@env). - Injecter dans tous les services d'un tag une dépendance conditionnelle.
- Valider que certains services existent / sont bien taggués.
Exemple : injecter un logger dans tous les services taggués
// src/DependencyInjection/Compiler/AddAuditLoggerPass.php
declare(strict_types=1);
namespace App\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
final class AddAuditLoggerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->has('monolog.logger.audit')) {
return;
}
foreach ($container->findTaggedServiceIds('app.audited_action') as $id => $_) {
$container->getDefinition($id)->addMethodCall(
'setAuditLogger',
[new Reference('monolog.logger.audit')],
);
}
}
}
// src/Kernel.php
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(new AddAuditLoggerPass());
}
Avant d'écrire un Compiler Pass : se demander si l'une de ces alternatives suffit.
#[AutoconfigureTag]+!tagged_iteratorpour collecter.#[AsDecorator]pour wrapper._defaults.bindpour injecter partout.when@envou#[When]pour conditionner par environnement.
Dans 95 % des projets applicatifs, un Compiler Pass n'est pas nécessaire. On en rencontre surtout dans les bundles tiers.
Pattern 7 : tagged_iterator avec exclusion
iterable $rules: !tagged_iterator { tag: app.rule, exclude: ['app.rule.disabled'] }
Utile pour exclure un sous-ensemble (ex : règle désactivée par feature flag, appliquée via un second tag).
Commandes de debug
# Tous les services taggués avec un tag
symfony console debug:container --tag=app.order_rule
# Tous les tags déclarés
symfony console debug:container --tags
# Détail d'un service décoré (voir la chaîne)
symfony console debug:container mailer.mailer
# Valider la compilation
symfony console lint:container
Pièges fréquents
- Tag sans autoconfiguration ni config manuelle : un service n'est pas taggué automatiquement par le simple fait d'implémenter une interface — il faut
#[AutoconfigureTag]sur l'interface etautoconfigure: truedans_defaults(qui est le défaut Flex, donc en général OK). - Ordre non déterministe : sans
priority, deux implémentations avec la même priorité (0 par défaut) sortent dans l'ordre de déclaration du container, qui peut varier. Poser explicitement une priorité si l'ordre importe. iterablevsarray: consommer eniterablepermet au container d'instancier lazy. En forçantarray, on perd la lazy-instantiation. Préféreriterablepartout.- Décorateur marqué
final: OK. Le service décoré (cible) n'a pas besoin d'être modifiable — Symfony remplace l'id, il ne fait pas d'héritage. - Compiler Pass qui mute le container sans priorité : les passes ont un ordre (
PassConfig::TYPE_BEFORE_OPTIMIZATIONpar défaut). Si un pass dépend d'un autre, passer$passTypeet$priorityexplicitement àaddCompilerPass(). ServiceLocatorutilisé pour éviter l'autowiring : si les clés sont connues à l'avance, autowirer directement les services est plus clair. Le locator brille seulement pour un dispatch runtime.
Déroulement
1 — Cadrer
- Collection (
iterable), sélection par clé (ServiceLocator), wrapper (décoration), ou construction custom (factory) ? - L'interface à tagger existe-t-elle ? Sinon, la créer (cf.
/symfony:service-definepour la classe). - Priorité / index nécessaire ?
2 — Implémenter
#[AutoconfigureTag('app.<nom>')]sur l'interface si collection.#[AsTaggedItem(priority: N, index: 'key')]sur chaque implémentation si nécessaire.- Consommateur :
#[AutowireIterator]/#[AutowireLocator]ou binding YAML. - Décoration :
#[AsDecorator]sur la classe wrapper. - Factory : entrée YAML avec
factory:oufrom_callable:.
3 — Vérifier
symfony console debug:container --tag=app.order_rule
symfony console lint:container
vendor/bin/phpstan analyse src
Test unitaire du consommateur en passant un new \ArrayIterator([$rule1, $rule2]) en argument, puis test functional qui récupère le consommateur via static::getContainer()->get(...) pour valider que le tag collecte bien tous les services.
4 — Clôture
Afficher :
- Tag utilisé (
app.<nom>). - Services taggués (résultat de
debug:container --tag=...). - Consommateur et mécanisme (
iterable,ServiceLocator, décoration, factory). - Ce qui reste : tests, documentation du tag si public (bundle partagé).
Delta Sylius
- Sylius expose de nombreux tags applicatifs (
sylius.order_processor,sylius.tax_calculator,sylius.shipping_method_resolver). Utiliser#[AsDecorator]pour wrapper, ou#[AsTaggedItem]pour ajouter une implémentation au tag existant. - Les extensions de checkout passent par
sylius.order_processor(chaîne de processors ordonnée par priorité). Ajouter un processor custom = nouvelle classe taggéesylius.order_processoravecpriority:réfléchie par rapport aux processors vendor. - Pour des factories Sylius : utiliser les abstractions
Sylius\Component\Resource\Factory\FactoryInterfaceplutôt que d'écrire une factory Symfony brute.
Argument optionnel
/symfony:service-tags OrderRuleInterface app.order_rule — monte la collection pour l'interface nommée sous le tag donné.
/symfony:service-tags "décorer mailer.mailer avec LoggingMailer" — crée le décorateur.
/symfony:service-tags src/Order/OrderValidator.php — audit : détecte si le service devrait consommer une collection taggée plutôt que d'injecter des implémentations en dur.
/symfony:service-tags sans argument — demande le pattern cible (collection, locator, décoration, factory).