Усе нижче перевірено на Symfony 7.4 LTS і 8.0, symfony/dependency-injection, PHP 8.4. Приклади — на атрибутах; де поведінка залежить від YAML-конфігурації, це показано окремо.
Тег замість списку в конструкторі
Найчастіша форма розширюваності — реєстр обробників: рендерери звітів, валідатори імпорту, стратегії розрахунку. Наївна реалізація виглядає так:
public function __construct(
private PdfRenderer $pdf,
private CsvRenderer $csv,
) {}
Кожен новий формат — правка конструктора, а якщо реєстр живе в бандлі чи в іншому модулі, то ще й правка чужого коду. Контейнер Symfony вирішує це тегами: сервіс позначається міткою, а споживач просить не конкретні класи, а «всі сервіси з таким тегом».
Тег вішається на інтерфейс, і всі його реалізації отримають його автоматично — за умови, що для них увімкнено autoconfigure (у стандартному services.yaml він увімкнений для App\):
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('app.report_renderer')]
interface ReportRenderer
{
public function supports(string $format): bool;
public function render(Report $report): string;
}
Споживач отримує їх ітератором:
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final readonly class ReportFactory
{
/**
* @param iterable<ReportRenderer> $renderers
*/
public function __construct(
#[AutowireIterator('app.report_renderer')]
private iterable $renderers,
) {}
}
iterable, а не array — це принципово. RewindableGenerator, який підставляє контейнер, інстанціює кожен сервіс лише в момент ітерації. Оголосите array — і всі рендерери разом з їхніми залежностями будуть створені на кожен запит, навіть якщо ви скористаєтеся одним.
Той самий тег у YAML, якщо сервіс описаний вручну:
services:
App\Report\ReportFactory:
arguments:
$renderers: !tagged_iterator app.report_renderer
Локатор, індекс і пріоритет
Ітерація списком доречна, коли ви питаєте кожного кандидата supports(). Коли ключ відомий заздалегідь, ітерувати немає сенсу — потрібен ServiceLocator:
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
use Psr\Container\ContainerInterface;
#[AsTaggedItem(index: 'pdf', priority: 10)]
final class PdfRenderer implements ReportRenderer { /* ... */ }
final readonly class ReportFactory
{
public function __construct(
#[AutowireLocator('app.report_renderer', indexAttribute: 'key')]
private ContainerInterface $renderers,
) {}
public function render(Report $report, string $format): string
{
if (!$this->renderers->has($format)) {
throw new UnsupportedFormat($format);
}
return $this->renderers->get($format)->render($report);
}
}
Локатор створює лише той сервіс, який реально запитали. Він же реалізує ServiceProviderInterface, тож getProvidedServices() дає список доступних ключів — зручно для повідомлення про помилку чи для валідації вхідних даних.
Про порядок і ключі є три речі, які варто знати одразу:
Пріоритет — більше значення йде першим. Це протилежно інтуїції тих, хто звик до «priority 1 = найвищий». Сервіси без явного пріоритету отримують 0 і зберігають порядок реєстрації, тобто фактично порядок файлів у services.yaml — покладатися на нього не варто.
Індекс не обов'язково задавати атрибутом. Якщо в тегу вказано default_index_method, контейнер викличе цей статичний метод класу під час компіляції. Те саме для default_priority_method. Так ключ живе поруч із реалізацією, а не в конфізі:
#[AutoconfigureTag('app.report_renderer', ['default_index_method' => 'format'])]
interface ReportRenderer
{
public static function format(): string;
}
Атрибути тегу — це масив, а не одне значення. Один сервіс може мати той самий тег двічі з різними атрибутами: рендерер, що обслуговує і pdf, і pdf-a, реєструється двома #[AsTaggedItem]… ні, атрибут не повторюваний — тут уже потрібен YAML або compiler pass. Це якраз та межа, за якою атрибутів перестає вистачати.
Коли потрібен compiler pass
Тег плюс !tagged_iterator покривають відсотків вісімдесят випадків. Compiler pass потрібен, коли:
- реєстр — чужий клас, який не приймає ітератор у конструкторі, а має метод
addRenderer(); - ви хочете провалити збірку контейнера, а не отримати помилку в рантаймі: тег без обов'язкового атрибута, дві реалізації з однаковим ключем, клас без потрібного інтерфейсу;
- потрібно розібрати конфігурацію бандла й породити з неї N сервісів;
- тег треба повісити на сервіси, які визначені у vendor і не проходять через ваш
autoconfigure.
Пас — це клас з одним методом, який працює з ContainerBuilder до того, як контейнер перетвориться на PHP-код:
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Reference;
final class RegisterReportRenderersPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->has(ReportFactory::class)) {
return;
}
$factory = $container->findDefinition(ReportFactory::class);
$seen = [];
foreach ($container->findTaggedServiceIds('app.report_renderer', true) as $id => $tags) {
foreach ($tags as $attributes) {
if (!isset($attributes['format'])) {
throw new InvalidArgumentException(
\sprintf('Сервіс "%s" має тег app.report_renderer без атрибута "format".', $id),
);
}
$format = $attributes['format'];
if (isset($seen[$format])) {
throw new InvalidArgumentException(
\sprintf('Формат "%s" уже зареєстрований сервісом "%s".', $format, $seen[$format]),
);
}
$seen[$format] = $id;
$factory->addMethodCall('addRenderer', [$format, new Reference($id)]);
}
}
}
}
Три деталі, які тут не косметичні. Другий аргумент findTaggedServiceIds($tag, true) вмикає виняток на абстрактних визначеннях — без нього шаблонне визначення з тегом мовчки потрапить у реєстр і зламається під час інстанціювання. findDefinition() замість getDefinition() розгортає аліаси: ReportFactory::class цілком може виявитися аліасом на app.report_factory. І перевірка has() на початку — пас виконується в усіх середовищах, зокрема в тих, де ваш бандл вимкнено.
Реєструється пас у build() — у ядрі застосунку або в класі бандла:
// src/Kernel.php
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(new RegisterReportRenderersPass());
}
Якщо ви пишете бандл і хочете, щоб тег автоматично чіплявся на реалізації в застосунку, це робиться в Extension::load():
$container->registerForAutoconfiguration(ReportRenderer::class)
->addTag('app.report_renderer');
Саме так працюють #[AsCommand], #[AsEventListener] і решта атрибутів фреймворку: під ними — тег плюс пас.
Порядок проходів і зниклі сервіси
Компіляція контейнера — це не один прохід, а конвеєр. addCompilerPass() другим аргументом приймає тип, третім — пріоритет усередині типу:
$container->addCompilerPass(
new RegisterReportRenderersPass(),
PassConfig::TYPE_BEFORE_OPTIMIZATION,
10,
);
Типи виконуються в порядку: TYPE_BEFORE_OPTIMIZATION → TYPE_OPTIMIZE → TYPE_BEFORE_REMOVING → TYPE_REMOVE → TYPE_AFTER_REMOVING. За замовчуванням — TYPE_BEFORE_OPTIMIZATION, і в дев'яти випадках з десяти це правильний вибір.
Найпоширеніша пастка тут — фаза видалення. На етапі TYPE_REMOVE контейнер викидає всі приватні сервіси, на які ніхто не посилається. Пас, який реєструє теговані сервіси на етапі TYPE_AFTER_REMOVING, знайде порожній список: посилань на ці сервіси на момент видалення ще не було, тож їх уже немає. Реєструйте зв'язки до оптимізації, а після видалення робіть лише те, що не створює нових посилань, — наприклад, збір статистики або перевірки.
Друга пастка — клас сервіса як рядок. $definition->getClass() може повернути %app.renderer.class% або клас, якого фізично немає (наприклад, він з'явиться після кодогенерації). Тому:
$class = $container->getParameterBag()->resolveValue($definition->getClass());
if (!$reflection = $container->getReflectionClass($class, false)) {
return;
}
if (!$reflection->implementsInterface(ReportRenderer::class)) {
throw new InvalidArgumentException(\sprintf('"%s" не реалізує ReportRenderer.', $class));
}
getReflectionClass() замість new \ReflectionClass() — не стилістика: він реєструє клас як ресурс контейнера, тож у дев-режимі контейнер перезбереться, коли цей файл зміниться.
І третя, суто побутова: контейнер компілюється один раз і кешується. Ви правите код паса, оновлюєте сторінку — і нічого не змінюється, бо файл самого паса не входить у список відстежуваних ресурсів. php bin/console cache:clear або $container->addObjectResource($this) всередині process(), якщо не хочете щоразу про це згадувати.
Декоратор замість паса
Якщо завдання не «зібрати список», а «змінити поведінку одного чужого сервіса», compiler pass не потрібен — є декорування:
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
#[AsDecorator(decorates: PdfRenderer::class, priority: 0)]
final readonly class CachedPdfRenderer implements ReportRenderer
{
public function __construct(
#[AutowireDecorated] private ReportRenderer $inner,
private CacheInterface $cache,
) {}
}
Контейнер перейменовує оригінал у ...inner і підставляє декоратор усім, хто просив PdfRenderer. Кілька декораторів на одному сервісі впорядковуються priority (більше — ближче до зовнішнього краю), а onInvalid: ContainerInterface::IGNORE_ON_INVALID_REFERENCE дозволяє декорувати сервіс, якого може не бути — тоді декоратор просто зникне разом з ним. Це рівно те, чого не варто робити пасом, який руками переписує Definition: декорування зберігає всі теги, аліаси й autowiring оригіналу.
Як це дебажити
Компільований контейнер — це PHP-файли у var/cache/{env}/, і туди інколи справді треба зазирнути. Але спершу:
php bin/console debug:container --tag=app.report_renderer
php bin/console debug:container --tags
php bin/console debug:container App\\Report\\ReportFactory --show-arguments
php bin/console lint:container
--show-arguments показує, що саме прилетіло в конструктор: конкретні Reference чи TaggedIteratorArgument і скільки в ньому елементів. lint:container перевіряє типи аргументів у всіх визначеннях і ловить випадок, коли ваш пас підставив у метод не той сервіс, — до деплою, а не після.
Якщо debug:container --tag=... показує сервіс, а в рантаймі його немає в ітераторі — майже завжди причина одна з двох: сервіс abstract, або він потрапив під exclude у services.yaml і взагалі не зареєстрований (тоді його не буде і в debug:container).
Коли не варто
Compiler pass — це код, який виконується під час збірки, погано покривається тестами і ламається мовчки. Він не потрібен, якщо:
Достатньо тегу й !tagged_iterator. Реєстр, який приймає iterable у конструкторі, не потребує жодного паса — вистачає #[AutoconfigureTag].
Вибір робиться в рантаймі за даними. Якщо стратегія залежить від запиту, вам потрібен ServiceLocator, а не логіка в контейнері.
Ви хочете «підмінити один сервіс». Для цього є декоратор або звичайний аліас у services.yaml; переписувати Definition пасом означає взяти на себе відповідальність за всі теги й аргументи, які там уже були.
Точка розширення — ваш власний код. Тег має сенс там, де ви публікуєте контракт для когось іншого — іншого модуля, бандла, команди. Усередині одного модуля прямий конструктор чесніший і читається краще, ніж непрямий зв'язок через рядок-тег, який не знайдеться пошуком по використаннях.
Головне, що варто зафіксувати: тег — це публічний API вашого модуля. Його назва, набір обов'язкових атрибутів і семантика пріоритету — частина контракту нарівні з інтерфейсом. Задокументуйте їх поруч з інтерфейсом і перевіряйте в пасі, інакше через півроку ніхто не згадає, що format обов'язковий, а priority рахується від більшого до меншого.