<? phpukraine СТАТТІ
Пошук по платформі
SYMFONY 4 вересня 2026 · 9 хв читання

Теговані сервіси й compiler passes: розширення без правки чужого коду

Точка розширення в Symfony — це не інтерфейс і не абстрактний клас, а тег у контейнері. Розбираємо, як тег перетворюється на `iterable` чи `ServiceLocator` у конструкторі, коли атрибутів уже не вистачає і треба писати compiler pass, у якому порядку проходи виконуються, чому ваш сервіс зникає до того, як пас до нього дійде, і як усе це дебажити через `debug:container`.

РP
Редакція phpukraine
Редакція платформи

Усе нижче перевірено на 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_OPTIMIZATIONTYPE_OPTIMIZETYPE_BEFORE_REMOVINGTYPE_REMOVETYPE_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 рахується від більшого до меншого.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 3 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті