Привет, Хабр!

DI‑контейнер — сердечко Symfony. Контроллеры, сервисы, слушатели событий, консольные команды, Voter, нормалайзеры — всё это сервисы, которые живут в контейнере и получают зависимости через него. Многие знают autowiring: поставил тайпхинт в конструктор — зависимость пришла. Но контейнер умеет гораздо больше.

Разберём три уровня глубины: autowiring для повседневной работы, теги для расширяемых архитектур, compiler passes для магии уровня фреймворка.

Autowiring

Контейнер на этапе компиляции смотрит на тайпхинты конструктора и находит сервис с подходящим типом.

class OrderService
{
    public function __construct(
        private EntityManagerInterface $em,
        private MailerInterface $mailer,
        private LoggerInterface $logger,
    ) {}
}

Для каждого параметра контейнер ищет: есть ли сервис с ID, совпадающим с FQCN интерфейса? Есть ли алиас?

Проблема начинается, когда сервисов с одним интерфейсом несколько. Два логгера (monolog.logger.app и monolog.logger.mailer)? Три кеша (cache.app, cache.system, cache.validator)? Autowiring не знает, какой выбрать, и бросает исключение при компиляции.

Решение находится в атрибуте #[Autowire]:

use Symfony\Component\DependencyInjection\Attribute\Autowire;

class ReportService
{
    public function __construct(
        // Конкретный сервис по ID
        #[Autowire(service: 'monolog.logger.reports')]
        private LoggerInterface $logger,

        // Параметр контейнера
        #[Autowire('%kernel.project_dir%/var/reports')]
        private string $reportsDir,

        // Переменная окружения
        #[Autowire(env: 'REPORT_API_KEY')]
        private string $apiKey,

        // Выражение (Expression Language)
        #[Autowire(expression: 'service("security.token_storage").getToken()?.getUser()')]
        private ?User $currentUser,
    ) {}
}

#[Autowire] подставляет конкретный сервис, параметр, env‑переменную или результат выражения.

Несколько реализаций одного интерфейса

Когда у интерфейса две реализации — не лепите #[Autowire(service:)] на каждый потребитель.

Задайте алиас:

# config/services.yaml
services:
    # По умолчанию PaymentGatewayInterface → Stripe
    App\Service\PaymentGatewayInterface:
        alias: App\Service\StripeGateway

    # Если параметр называется $backupGateway → PayPal
    App\Service\PaymentGatewayInterface $backupGateway:
        alias: App\Service\PayPalGateway
class CheckoutService
{
    public function __construct(
        private PaymentGatewayInterface $gateway,        // → Stripe
        private PaymentGatewayInterface $backupGateway,   // → PayPal
    ) {}
}

Контейнер выбирает реализацию по имени параметра. Чисто, явно, конфигурация в одном месте. Все потребители с параметром $gateway получат Stripe, все с $backupGateway — PayPal.

Другой подход — #[AsAlias] прямо на классе:

#[AsAlias(PaymentGatewayInterface::class)]
class StripeGateway implements PaymentGatewayInterface { /* ... */ }

Autoconfigure: автоматические теги по интерфейсу

Symfony автоматически тегирует сервисы на основе реализуемых интерфейсов. Если класс реализует EventSubscriberInterface — получает тег kernel.event_subscriber. Если наследует Command — тег console.command. Если реализует VoterInterface — security.voter.

Это работает благодаря autoconfigure: true в services.yaml (включено по умолчанию):

services:
    _defaults:
        autowire: true
        autoconfigure: true  # Вот это

    App\:
        resource: '../src/'
        exclude: '../src/{Entity,Kernel.php}'

Вам не нужно вручную тегировать стандартные сервисы. Создали класс, реализовали интерфейс — Symfony сама разберётся.

Теги: расширяемая архитектура

Стандартные теги — это удобно. Но суперсила тегов в ваших собственных расширяемых точках. Задача: система экспорта с несколькими форматами, где новые форматы добавляются без изменения существующего кода.

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.exporter')]
interface ExporterInterface
{
    public function supports(string $format): bool;
    public function export(array $data): string;
}

#[AutoconfigureTag] на интерфейсе — и каждый класс, который его реализует, автоматически получает тег app.exporter. Без YAML и без ручной разметки.

Реализации:

class CsvExporter implements ExporterInterface
{
    public function supports(string $format): bool
    {
        return $format === 'csv';
    }

    public function export(array $data): string
    {
        $output = implode(';', array_keys($data[0])) . "\n";
        foreach ($data as $row) {
            $output .= implode(';', $row) . "\n";
        }
        return $output;
    }
}

class JsonExporter implements ExporterInterface
{
    public function supports(string $format): bool
    {
        return $format === 'json';
    }

    public function export(array $data): string
    {
        return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
    }
}

class XlsxExporter implements ExporterInterface
{
    public function __construct(private SpreadsheetFactory $factory) {}

    public function supports(string $format): bool
    {
        return $format === 'xlsx';
    }

    public function export(array $data): string { /* ... */ }
}

Собираем через #[TaggedIterator]:

use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;

class ExportService
{
    /** @var iterable<ExporterInterface> */
    private iterable $exporters;

    public function __construct(
        #[TaggedIterator('app.exporter')]
        iterable $exporters
    ) {
        $this->exporters = $exporters;
    }

    public function export(array $data, string $format): string
    {
        foreach ($this->exporters as $exporter) {
            if ($exporter->supports($format)) {
                return $exporter->export($data);
            }
        }

        throw new \InvalidArgumentException(
            sprintf('Формат "%s" не поддерживается', $format)
        );
    }

    public function supportedFormats(): array
    {
        $formats = [];
        foreach ($this->exporters as $exporter) {
            // Можно расширить интерфейс методом getFormat()
        }
        return $formats;
    }
}

Добавить новый формат — создать класс, имплементировать ExporterInterface. Всё. ExportService не меняется, конфиг не меняется. Контейнер сам найдёт новый сервис по тегу.

TaggedLocator: ленивая загрузка по ключу

TaggedIterator инстанцирует все сервисы при первом обращении к итератору. Если экспортёров 20 и нужен только один, то создавать все 20 как будто бы расточительно.

TaggedLocator — это Service Locator, который создаёт сервис только при get():

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.exporter', attributes: ['key' => 'csv'])]
class CsvExporter implements ExporterInterface { /* ... */ }

#[AutoconfigureTag('app.exporter', attributes: ['key' => 'json'])]
class JsonExporter implements ExporterInterface { /* ... */ }

#[AutoconfigureTag('app.exporter', attributes: ['key' => 'xlsx'])]
class XlsxExporter implements ExporterInterface { /* ... */ }
use Symfony\Component\DependencyInjection\Attribute\TaggedLocator;
use Psr\Container\ContainerInterface;

class ExportService
{
    public function __construct(
        #[TaggedLocator('app.exporter', indexAttribute: 'key')]
        private ContainerInterface $exporters
    ) {}

    public function export(array $data, string $format): string
    {
        if (!$this->exporters->has($format)) {
            throw new \InvalidArgumentException("Формат '$format' не поддерживается");
        }

        // Создаётся только нужный экспортёр
        return $this->exporters->get($format)->export($data);
    }
}

get('csv') создаёт только CsvExporter. XlsxExporter с его тяжёлой зависимостью SpreadsheetFactory даже не трогается.

Приоритет тегов

Тегам можно задать приоритет — порядок, в котором сервисы появятся в итераторе:

#[AutoconfigureTag('app.exporter', attributes: ['priority' => 10])]
class CsvExporter implements ExporterInterface { /* ... */ }

#[AutoconfigureTag('app.exporter', attributes: ['priority' => 20])]
class JsonExporter implements ExporterInterface { /* ... */ }

Чем выше priority, тем раньше сервис в итераторе. По умолчанию 0.

Compiler Pass: вмешиваемся в сборку контейнера

Compiler Pass — код, который выполняется один раз при компиляции контейнера. Это самый мощный инструмент, но и самый редко нужный в прикладном коде. Фреймворк использует их повсюду (регистрация Twig‑расширений, подключение Event Listener'ов, настройка маршрутов), но вам Compiler Pass понадобится, когда TaggedIterator/TaggedLocator не хватает.

Самый частый кейс видится в кастомной логике при сборке: валидация конфигурации, динамическое изменение определений, сложная сортировка:

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

class ValidateExportersPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        $tagged = $container->findTaggedServiceIds('app.exporter');

        $formats = [];
        foreach ($tagged as $serviceId => $tags) {
            $format = $tags[0]['key'] ?? null;

            if (!$format) {
                throw new \LogicException(
                    "Экспортёр $serviceId должен указать атрибут 'key' в теге"
                );
            }

            if (isset($formats[$format])) {
                throw new \LogicException(
                    "Дублирование формата '$format': $serviceId и {$formats[$format]}"
                );
            }

            $formats[$format] = $serviceId;
        }
    }
}

Этот pass проверяет при компиляции, что у каждого экспортёра есть ключ и нет дубликатов. Ошибка обнаружится при cache:clear, а не в рантайме при запросе пользователя.

Регистрация:

// src/Kernel.php
use Symfony\Component\DependencyInjection\ContainerBuilder;

class Kernel extends BaseKernel
{
    protected function build(ContainerBuilder $container): void
    {
        $container->addCompilerPass(new ValidateExportersPass());
    }
}

Compiler Pass запускается при компиляции контейнера — в дев‑окружении при каждом изменении конфига/сервисов, в проде при cache:clear / деплое..

Отладка: что в контейнере?

Когда что‑то не работает — смотрите в контейнер:

# Все сервисы
php bin/console debug:container

# Поиск по имени
php bin/console debug:container mailer

# Информация о конкретном сервисе
php bin/console debug:container App\\Service\\OrderService

# Все сервисы с тегом
php bin/console debug:container --tag=app.exporter

# Autowiring — какие типы доступны
php bin/console debug:autowiring

# Поиск по типу
php bin/console debug:autowiring Logger

debug:autowiring — самая полезная команда при проблемах с autowiring. Показывает все доступные типы и какой сервис за ними стоит.

Хотите понять, насколько вы готовы решать реальные задачи на Symfony, а не только разбираться в базовом синтаксисе? Пройдите тестирование: оно поможет быстро оценить уровень и понять, в каких темах стоит усилиться.
[Проверить знания по Symfony]

Когда что использовать

  • Autowiring + #[Autowire] 80% задач.

  • Теги + TaggedIterator/TaggedLocator для плагинов, форматов стратегий, хендлеров, процессоры.

  • Compiler Pass — валидация при сборке, динамическое изменение определений, интеграция с бандлами.

Когда бизнес‑логика начинает расползаться по if/else, а статусы и переходы становятся источником ошибок, это сигнал, что систему пора проектировать иначе. Курс Symfony Framework поможет разобраться, как строить приложения на Symfony так, чтобы логика оставалась прозрачной, расширяемой и удобной для тестирования.

[Забрать курс Symfony со скидкой]

А если давно думали о системном обучении, лучше не тянуть: 30–31 марта действует скидка 10% по промокоду birthday на любые курсы OTUS, и она суммируется с другими скидками. Самое время забрать курс по более выгодной цене уже сейчас.

Для первого погружения подключайтесь к открытому уроку:

22 апреля в 20:00 — «Symfony Workflow: конечный автомат для реализации бизнес-логики».
На нём покажут подход, который особенно полезен там, где в приложении много статусов, переходов и правил.
[Хочу на открытый урок]