Tags and Tagged Iterators
A tag is a label on a service definition; a tagged iterator collects every labelled service, which is how you build a plugin point.
#[AutowireIterator('app.exporter', exclude: [], excludeSelf: true)] private iterable $exportersA tag is a string label attached to a service definition, optionally with extra attributes. On its own a tag does nothing. Its value is that something else can ask the container for every service carrying it.
The framework uses tags everywhere: a class tagged kernel.event_subscriber is registered as a subscriber, one tagged console.command appears in the command list. Autoconfiguration adds most of those for you from an interface or an attribute. The same mechanism is available for your own extension points.
Tagging without configuration
Put #[AutoconfigureTag('app.exporter')] on the interface and every class implementing it is tagged. The configuration longhand is an _instanceof block naming the interface and the tags to add.
Collecting them
#[AutowireIterator('app.exporter')] on an iterable argument injects every tagged service. In configuration the same argument is written !tagged_iterator app.exporter. Order comes from #[AsTaggedItem(priority: 10)] on each class: higher priority first. A tag attribute can also key the collection, with index_by naming the attribute to read.
Iterating builds each tagged service in turn. If you only ever need one of them, inject a service locator with #[AutowireLocator('app.exporter')] and fetch that one by id instead.
What trips people up
In Symfony 8.1 the older conventions — a getDefaultPriority() method and the default_index_method option — are deprecated in favour of #[AsTaggedItem].
Example
// src/Export/ExporterRegistry.php
declare(strict_types=1);
namespace App\Export;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class ExporterRegistry
{
/**
* @param iterable<ExporterInterface> $exporters every service tagged
* app.exporter, highest
* priority first
*/
public function __construct(
#[AutowireIterator('app.exporter')]
private iterable $exporters,
) {
}
public function forFormat(string $format): ExporterInterface
{
foreach ($this->exporters as $exporter) {
if ($exporter->supports($format)) {
return $exporter;
}
}
throw new \InvalidArgumentException(sprintf('No exporter handles "%s".', $format));
}
/**
* @return list<string>
*/
public function formats(): array
{
$formats = [];
foreach ($this->exporters as $exporter) {
$formats[] = $exporter->format();
}
return $formats;
}
}
// With CsvExporter (priority 10) and JsonExporter (priority 0) both tagged:
// $registry->formats()
// Output: ['csv', 'json']When to use it
- Let each export, import or payment format live in its own class, discovered automatically.
- Build a chain of validation or enrichment steps whose order you control with priorities.
- Audit which services the framework has registered for a tag such as kernel.event_listener.
More examples
The interface that tags itself, and one implementation
Adding a new format means adding one class: no registry edit, no configuration and no switch statement, which is the point of the pattern. Drop the priority attribute and the order is not something to rely on, so set it only when order actually matters.
// src/Export/ExporterInterface.php
declare(strict_types=1);
namespace App\Export;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
// every service implementing this interface is tagged app.exporter
#[AutoconfigureTag('app.exporter')]
interface ExporterInterface
{
public function format(): string;
public function supports(string $format): bool;
/**
* @param list<array<string, string|int>> $rows
*/
public function export(array $rows): string;
}
// src/Export/CsvExporter.php
declare(strict_types=1);
namespace App\Export;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
// higher priority means earlier in the iterator
#[AsTaggedItem(priority: 10)]
final class CsvExporter implements ExporterInterface
{
public function format(): string
{
return 'csv';
}
public function supports(string $format): bool
{
return 'csv' === $format;
}
public function export(array $rows): string
{
$lines = [];
foreach ($rows as $row) {
$lines[] = implode(',', array_map('strval', $row));
}
return implode("\n", $lines);
}
}The same wiring in configuration, keyed by a tag attribute
With index_by the injected collection is keyed by that tag attribute, but a tagged iterator arrives as a traversable rather than an array: run $exporters = iterator_to_array($exporters) before reading $exporters['csv'], or inject #[AutowireLocator('app.exporter')] when you only ever fetch one by key. Use the configuration form when the tag belongs to the application wiring rather than to the class, for example when tagging classes from a package you do not own.
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
# an _instanceof block can add the tag NAME to every implementation,
# but a tag attribute written there would give every exporter the same
# key and priority, so those go on each class instead
App\Export\CsvExporter:
tags:
- { name: app.exporter, key: csv, priority: 10 }
App\Export\JsonExporter:
tags:
- { name: app.exporter, key: json }
App\Export\ExporterRegistry:
arguments:
# longhand for #[AutowireIterator]; index_by names the tag
# attribute to use as the collection key
- !tagged_iterator { tag: app.exporter, index_by: key }Checking what carries a tag
Run this first when a tagged iterator comes back shorter than expected. A missing service is usually a class excluded from the resource block, or one that does not actually implement the tagged interface.
$ php bin/console debug:container --tag=app.exporter
Symfony Container Services Tagged with "app.exporter" Tag
=========================================================
------------------------- ----------
Service ID priority
------------------------- ----------
App\Export\CsvExporter 10
App\Export\JsonExporter
------------------------- ----------
Discussion