Впровадження залежностей у Symfony: контейнер сервісів, autowiring і теги 2026

Поглиблений розбір впровадження залежностей у Symfony: контейнер сервісів, autowiring, атрибут #[Autowire], прив'язування інтерфейсів, теговані сервіси з #[AutowireIterator], compiler passes та ліниві сервіси в Symfony 7.4 і 8.0.

Діаграма архітектури контейнера сервісів та autowiring у впровадженні залежностей Symfony

Впровадження залежностей (dependency injection) — це основа кожного застосунку Symfony: контейнер сервісів створює, конфігурує та зв'язує об'єкти між собою, тож класи оголошують те, що їм потрібно, замість того щоб конструювати це самостійно. У Symfony 7.4 та 8.0 autowiring, атрибути PHP і теговані сервіси звели явну конфігурацію майже до нуля, водночас залишаючи контейнер повністю розв'язаним і передбачуваним на етапі компіляції. Розуміння того, як працює це розв'язання, відділяє розробників, які борються з контейнером, від тих, хто дозволяє йому виконувати роботу.

Що таке впровадження залежностей у Symfony?

Впровадження залежностей у Symfony — це патерн, за якого контейнер сервісів передає залежності об'єкта через його конструктор, а не дозволяє об'єкту створювати їх самостійно. Autowiring автоматично зіставляє кожен аргумент із підказкою типу (type hint) з відповідним сервісом, тож більшість класів не потребують жодної ручної конфігурації.

Як контейнер сервісів Symfony розв'язує залежності

Контейнер сервісів — це скомпільований клас PHP, який знає, як побудувати кожен сервіс у застосунку. Під час фази компіляції контейнера Symfony сканує визначення класів, читає сигнатури конструкторів і створює закешовану фабрику. Під час виконання запит сервісу повертає ліниво побудований, спільний екземпляр. Оскільки зв'язування відбувається на етапі компіляції, неправильно налаштована залежність дає збій під час побудови кешу, а не тоді, коли запит потрапляє на продакшн.

Сервіс — це просто клас, який виконує певне завдання і керується контейнером. Типовий файл config/services.yaml реєструє все з каталогу src/ як сервіс з увімкненими autowiring та autoconfiguration, тому звичайний клас працює без жодного додаткового налаштування.

yaml
# config/services.yaml
services:
    _defaults:
        autowire: true       # inject arguments by type-hint
        autoconfigure: true  # apply tags based on implemented interfaces

    App\:
        resource: '../src/'
        exclude:
            - '../src/Entity/'
            - '../src/Kernel.php'

За таких типових налаштувань клас запитує співпрацівників через свій конструктор, а контейнер їх надає. Жодної фабрики, жодного new, жодного ручного списку аргументів.

src/Notification/SlackNotifier.phpphp
namespace App\Notification;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class SlackNotifier
{
    // The container reads each type-hint and injects a matching service
    public function __construct(
        private readonly HttpClientInterface $httpClient,
        private readonly LoggerInterface $logger,
    ) {}

    public function send(string $channel, string $message): void
    {
        $this->httpClient->request('POST', 'https://slack.example/api/post', [
            'json' => ['channel' => $channel, 'text' => $message],
        ]);
        $this->logger->info('Slack message dispatched', ['channel' => $channel]);
    }
}

Контейнер створює SlackNotifier один раз, впроваджує спільний HTTP-клієнт і logger та повторно використовує цей екземпляр усюди, де його запитують. Саме ця поведінка спільного використання за замовчуванням робить контейнер водночас швидким та ощадливим щодо пам'яті. Повну модель задокументовано в офіційному посібнику з контейнера сервісів Symfony, який спирається на стандарт інтерфейсу контейнера PSR-11, спільний для всієї екосистеми PHP.

Спільне використання можна вимкнути, коли для кожного запиту потрібен свіжий екземпляр, наприклад для сервісу-білдера зі станом, що накопичує дані. Встановлення shared: false у визначенні перетворює його на фабрику, яка повертає новий об'єкт при кожному впровадженні. На практиці це трапляється рідко, оскільки більшість сервісів — це співпрацівники без стану, проте знання про існування цього типового значення та способу його перевизначення має значення, коли помилку вдається простежити до двох споживачів, які несподівано змінюють той самий об'єкт.

Autowiring у Symfony: підказки типів та атрибут Autowire

Autowiring у Symfony розв'язує аргументи конструктора за їхнім оголошенням типу. Коли аргумент має підказку типу у вигляді класу або інтерфейсу, що відповідає рівно одному сервісу, контейнер впроваджує його без будь-якої конфігурації. Autowiring перестає працювати лише для значень, які не є сервісами: скалярів, змінних середовища та параметрів. Атрибут #[Autowire] закриває цю прогалину безпосередньо на аргументі, тримаючи конфігурацію зв'язків поряд із кодом, який їх використовує.

src/Service/PaymentGateway.phpphp
namespace App\Service;

use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class PaymentGateway
{
    public function __construct(
        // Inject a container parameter with the %...% syntax
        #[Autowire('%kernel.environment%')]
        private readonly string $environment,

        // Inject an environment variable, resolved at runtime
        #[Autowire(env: 'STRIPE_SECRET')]
        private readonly string $apiKey,

        // Bind a specific service when the type alone is ambiguous
        #[Autowire(service: 'monolog.logger.payment')]
        private readonly LoggerInterface $logger,
    ) {}
}

Кожен аргумент оголошує власне джерело: параметр, змінну середовища або іменований сервіс. Атрибут також приймає аргумент expression: для значень Expression Language, що корисно, коли залежність походить з обчислення під час виконання, а не зі статичного визначення. Зберігання цих метаданих на конструкторі означає, що читач бачить точно, звідки походить кожне значення, не відкриваючи окремий файл YAML. Довідник з атрибута міститься в документації з autowiring Symfony.

Вказуйте типи інтерфейсів, а не конкретних класів

Autowiring конкретного класу, як-от S3Storage, прив'язує кожного споживача до цієї реалізації і робить її заміну в тестах болючою. Натомість слід вказувати тип інтерфейсу і дозволити #[AsAlias] або #[Autowire(service: ...)] вирішувати, який конкретний сервіс використати. Впровадження всього контейнера, щоб вручну діставати з нього сервіси, зводить нанівець перевірки на етапі компіляції і є саме тим антипатерном, який autowiring покликаний усунути.

Прив'язування інтерфейсів, коли існує кілька реалізацій

Autowiring щодо інтерфейсу працює бездоганно, доки його не реалізують два класи. У цей момент контейнер не може вгадати, який із них впровадити, і викидає помилку неоднозначності на етапі компіляції. Це розв'язують два атрибути: #[AsAlias] позначає одну реалізацію як типову для інтерфейсу, а #[Autowire(service: ...)] закріплює конкретну реалізацію за окремим аргументом.

src/Storage/StorageInterface.phpphp
namespace App\Storage;

interface StorageInterface
{
    public function put(string $key, string $contents): void;
}
src/Storage/S3Storage.phpphp
namespace App\Storage;

use Symfony\Component\DependencyInjection\Attribute\AsAlias;

// Becomes the default service returned for StorageInterface
#[AsAlias(StorageInterface::class)]
final class S3Storage implements StorageInterface
{
    public function put(string $key, string $contents): void { /* upload to S3 */ }
}
src/Service/BackupService.phpphp
namespace App\Service;

use App\Storage\LocalStorage;
use App\Storage\StorageInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class BackupService
{
    public function __construct(
        // Resolves to the #[AsAlias] default (S3Storage)
        private readonly StorageInterface $primary,

        // Pins the concrete implementation explicitly by service id
        #[Autowire(service: LocalStorage::class)]
        private readonly StorageInterface $fallback,
    ) {}
}

Атрибут #[Target] пропонує третій варіант: він посилається на реалізацію за її іменем аліаса autowiring, відділяючи ім'я змінної аргумента від способу розв'язання. Вибір між ними зводиться до наміру. Варто застосовувати #[AsAlias], коли одна реалізація справді є типовою в усій кодовій базі, та #[Autowire(service: ...)] чи #[Target], коли окремому споживачеві потрібен нетиповий варіант. Цей патерн постійно трапляється у продакшн-застосунках і є улюбленою темою модуля співбесідних питань про впровадження залежностей у Symfony.

Готовий до співбесід з Symfony?

Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.

Теги сервісів Symfony та колектор AutowireIterator

Теги — це спосіб, у який Symfony групує пов'язані сервіси, щоб інший сервіс міг спожити їх усі одразу. Це живить патерни стратегії, ланцюжка відповідальності та плагінів, які трапляються в самому фреймворку: розширення типів форм, розширення Twig і підписники подій — усі керуються тегами. Замість ручного тегування атрибут #[AutoconfigureTag], розміщений на інтерфейсі, каже контейнеру автоматично тегувати кожен клас, що його реалізує.

src/Export/ExporterInterface.phpphp
namespace App\Export;

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

// Every class implementing this interface is tagged automatically
#[AutoconfigureTag('app.exporter')]
interface ExporterInterface
{
    public function supports(string $format): bool;
    public function export(array $rows): string;
}

Окремі реалізації керують своєю позицією в колекції за допомогою #[AsTaggedItem], який задає пріоритет (вищий виконується першим) та необов'язковий рядковий індекс для доступу за ключем.

src/Export/CsvExporter.phpphp
namespace App\Export;

use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;

// Higher priority services appear earlier in the injected iterable
#[AsTaggedItem(priority: 10)]
final class CsvExporter implements ExporterInterface
{
    public function supports(string $format): bool
    {
        return $format === 'csv';
    }

    public function export(array $rows): string
    {
        return implode("\n", array_map(fn ($r) => implode(',', $r), $rows));
    }
}

Сервіс-споживач збирає всі теговані експортери у впорядкований об'єкт iterable за допомогою #[AutowireIterator]. Потім він проходить колекцією та делегує роботу першій стратегії, що підтримує запитаний формат.

src/Export/ExportManager.phpphp
namespace App\Export;

use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

final class ExportManager
{
    // Collects all services tagged 'app.exporter', ordered by priority
    public function __construct(
        #[AutowireIterator('app.exporter')]
        private readonly iterable $exporters,
    ) {}

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

        throw new \InvalidArgumentException("No exporter for format: $format");
    }
}

Коли колекція велика або дорога в побудові, #[AutowireLocator] є лінивою альтернативою: він впроваджує сумісний із PSR-11 контейнер, який створює тегований сервіс лише тоді, коли той фактично дістається за ключем. Ця відмінність між жадібною ітерацією та лінивим пошуком має значення для сервісів, що утримують з'єднання або важкий стан.

Autoconfiguration, compiler passes та ліниві сервіси

Autoconfiguration — це механізм, який читає атрибути на кшталт #[AutoconfigureTag] і #[AsTaggedItem] та застосовує їх під час компіляції. Саме тому реалізації EventSubscriberInterface достатньо, щоб зареєструвати підписника подій з нульовою конфігурацією. Для перетворень, які атрибути не можуть виразити, compiler pass вклинюється безпосередньо в побудову контейнера і змінює визначення до того, як буде записано кеш. Вихідний код компонента DependencyInjection на GitHub показує, як сам фреймворк використовує десятки таких проходів усередині.

Графи, чутливі до продуктивності, виграють від лінивих сервісів. Позначення аргумента атрибутом #[Autowire(lazy: true)] відкладає створення екземпляра до першого виклику залежності, спираючись на нативні ліниві об'єкти PHP 8.4, а не на згенеровану бібліотеку проксі.

src/Service/ReportBuilder.phpphp
namespace App\Service;

use App\Pdf\PdfRenderer;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class ReportBuilder
{
    public function __construct(
        // PdfRenderer is only instantiated on first actual use
        #[Autowire(lazy: true)]
        private readonly PdfRenderer $renderer,
    ) {}

    public function buildMonthly(): string
    {
        // If no method touches $this->renderer, it is never constructed
        return $this->renderer->render('monthly-report');
    }
}

Нативні ліниві об'єкти з'явилися в PHP 8.4 і детально розглянуті в посібнику про ліниві об'єкти в Symfony 8 і PHP 8.4. Виграш — це контейнер, який залишається легким: важкі співпрацівники, як-от генератори PDF, клієнти сторонніх SDK чи генератори звітів, нічого не коштують, доки насправді не запуститься той шлях коду, що їх потребує.

Питання та відповіді для співбесіди щодо DI у Symfony

Поширене питання співбесіди щодо DI у Symfony стосується різниці між autowiring та autoconfiguration. Autowiring розв'язує аргументи конструктора окремого сервісу за типом, тоді як autoconfiguration застосовує до сервісу теги, виклики методів і налаштування на основі інтерфейсів, які він реалізує. Це незалежні перемикачі: сервіс може бути autowired без autoconfigured, і навпаки.

Інше часте питання — чому контейнер розрізняє публічні та приватні сервіси. Сервіси за замовчуванням приватні, тобто їх не можна дістати безпосередньо з контейнера через $container->get(), а лише впровадити. Приватні сервіси можна вбудувати (inline) та усунути під час компіляції, що швидше; публічні сервіси мають залишатися доступними для отримання, тож оптимізатор залишає їх недоторканими. Ручне діставання сервісів із контейнера вважають антипатерном саме тому, що воно зводить нанівець цю оптимізацію та приховує залежності.

Циклічні залежності — це класична пастка і часте додаткове питання. Якщо сервіс A потребує B у своєму конструкторі, а B потребує A, контейнер не може побудувати жоден із них і повідомляє про циклічне посилання на етапі компіляції. Стандартні виправлення: розірвати цикл, винісши спільну поведінку в третій сервіс; перевести один бік на ліниве впровадження за допомогою #[Autowire(lazy: true)], щоб залежність розв'язувалася лише при першому виклику; або застосувати впровадження через setter, тож об'єкт існує до того, як до нього під'єднають співпрацівника. Розпізнавання того, чому цикл є проєктним запахом (design smell), а не лише того, як його заглушити, — ось що насправді слухають інтерв'юери.

Інтерв'юери також перевіряють час розв'язання в контейнері. Оскільки Symfony компілює контейнер один раз і кешує його, нерозв'язна залежність виявляється під час прогріву кешу, а не під час запиту. Ця гарантія є прямим наслідком моделі часу компіляції та однією з причин, чому великі застосунки Symfony залишаються передбачуваними під навантаженням. Ширший набір таких питань згруповано за компонентами та складністю у технологічному напрямі Symfony.

Висновок

  • Дозволити контейнеру виконувати роботу: реєструвати класи в каталозі src/, вказувати типи залежностей у конструкторі й повністю пропускати ручне зв'язування в YAML.
  • Вдаватися до #[Autowire] лише для не-сервісів, параметрів, змінних середовища та конкретних ідентифікаторів сервісів.
  • Розв'язувати неоднозначність інтерфейсів за допомогою #[AsAlias] для типової реалізації та #[Autowire(service: ...)] чи #[Target] для явного вибору.
  • Керувати патернами стратегії та плагінів за допомогою #[AutoconfigureTag], пріоритетів #[AsTaggedItem] і #[AutowireIterator]; переходити на #[AutowireLocator], коли лінивий пошук кращий за жадібну ітерацію.
  • Відкладати дорогих співпрацівників за допомогою #[Autowire(lazy: true)], який використовує нативні ліниві об'єкти PHP 8.4.
  • Тримати сервіси приватними та впровадженими, а не діставати їх, щоб компілятор міг вбудувати та оптимізувати граф.

Починай практикувати!

Перевір свої знання з нашими симуляторами співбесід та технічними тестами.

Теги

#symfony
#dependency injection
#service container
#autowiring
#php
#di

Поділитися

Пов'язані статті