# Symfony Dependency Injection: Service Container, Autowiring and Tags in 2026 > Deep dive into Symfony dependency injection covering the service container, autowiring, the #[Autowire] attribute, interface binding, tagged services with #[AutowireIterator], compiler passes and lazy services in Symfony 7.4 and 8.0. - Published: 2026-07-06 - Updated: 2026-07-06 - Author: SharpSkill - Tags: symfony, dependency injection, service container, autowiring, php, di - Reading time: 11 min --- Symfony dependency injection is the backbone of every Symfony application: the service container instantiates, configures, and wires objects together so classes declare what they need instead of constructing it themselves. In Symfony 7.4 and 8.0, autowiring, PHP attributes, and tagged services have reduced explicit configuration to almost nothing while keeping the container fully resolved and predictable at compile time. Understanding how that resolution works separates developers who fight the container from those who let it do the work. > **What is dependency injection in Symfony?** > > Dependency injection in Symfony is a pattern where the service container passes an object's dependencies through its constructor rather than letting the object create them. Autowiring resolves each type-hinted argument to a matching service automatically, so most classes need no manual configuration at all. ## How the Symfony Service Container Resolves Dependencies The service container is a compiled PHP class that knows how to build every service in the application. During the container compilation phase, Symfony scans class definitions, reads constructor signatures, and produces a cached factory. At runtime, requesting a service returns a lazily-built, shared instance. Because the wiring happens at compile time, a misconfigured dependency fails when the cache is built, not when a request hits production. A service is simply a class that performs a task and is managed by the container. The default `config/services.yaml` registers everything under `src/` as a service with autowiring and autoconfiguration enabled, which is why a plain class works with no extra setup. ```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' ``` With those defaults in place, a class asks for collaborators through its constructor and the container supplies them. No factory, no `new`, no manual argument list. ```php // src/Notification/SlackNotifier.php 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]); } } ``` The container instantiates `SlackNotifier` once, injects the shared HTTP client and logger, and reuses that instance everywhere it is requested. This shared-by-default behavior is what makes the container both fast and memory-efficient. The full model is documented in the official [Symfony service container guide](https://symfony.com/doc/current/service_container.html), which builds on the [PSR-11 container interface](https://www.php-fig.org/psr/psr-11/) standard shared across the PHP ecosystem. Sharing can be turned off when a fresh instance is required per request, for example a stateful builder that accumulates data. Setting `shared: false` on a definition turns it into a factory that returns a new object every time it is injected. This is rare in practice, because most services are stateless collaborators, but knowing the default exists and how to override it matters when a bug traces back to two consumers unexpectedly mutating the same object. ## Symfony Autowiring: Type Hints and the Autowire Attribute Symfony autowiring resolves constructor arguments by their type declaration. When an argument is type-hinted with a class or interface that maps to exactly one service, the container injects it without any configuration. Autowiring only breaks down for values that are not services: scalars, environment variables, and parameters. The `#[Autowire]` attribute closes that gap directly on the argument, keeping wiring next to the code that uses it. ```php // src/Service/PaymentGateway.php 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, ) {} } ``` Each argument declares its own source: a parameter, an environment variable, or a named service. The attribute also accepts an `expression:` argument for Expression Language values, which is useful when a dependency comes from a runtime computation rather than a static definition. Keeping this metadata on the constructor means a reader sees exactly where every value originates without opening a separate YAML file. The attribute reference lives in the [Symfony autowiring documentation](https://symfony.com/doc/current/service_container/autowiring.html). > **Type-hint interfaces, not concrete classes** > > Autowiring a concrete class like `S3Storage` couples every consumer to that implementation and makes swapping it in tests painful. Type-hint the interface instead and let `#[AsAlias]` or `#[Autowire(service: ...)]` decide the concrete service. Injecting the whole container to fetch services by hand defeats compile-time checks and is the anti-pattern autowiring exists to remove. ## Binding Interfaces When Multiple Implementations Exist Autowiring against an interface works cleanly until two classes implement it. At that point the container cannot guess which one to inject and throws an ambiguity error at compile time. Two attributes resolve this: `#[AsAlias]` marks one implementation as the default for the interface, and `#[Autowire(service: ...)]` pins a specific implementation on an individual argument. ```php // src/Storage/StorageInterface.php namespace App\Storage; interface StorageInterface { public function put(string $key, string $contents): void; } ``` ```php // src/Storage/S3Storage.php 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 */ } } ``` ```php // src/Service/BackupService.php 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, ) {} } ``` The `#[Target]` attribute offers a third option: it references an implementation by its autowiring alias name, decoupling the argument variable name from the resolution. Choosing between these comes down to intent. Use `#[AsAlias]` when one implementation is genuinely the default across the codebase, and `#[Autowire(service: ...)]` or `#[Target]` when a single consumer needs a non-default variant. This pattern shows up constantly in production apps, and it is a favorite topic in the [Symfony dependency injection interview module](/technologies/symfony/interview-questions/dependency-injection). ## Symfony Service Tags and the AutowireIterator Collector Tags are how Symfony groups related services so another service can consume all of them at once. This powers the strategy, chain-of-responsibility, and plugin patterns that appear throughout the framework itself: form type extensions, Twig extensions, and event subscribers are all tag-driven. Rather than tagging manually, `#[AutoconfigureTag]` placed on an interface tells the container to tag every implementing class automatically. ```php // src/Export/ExporterInterface.php 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; } ``` Individual implementations control their position in the collection with `#[AsTaggedItem]`, which sets a priority (higher runs first) and an optional string index for keyed access. ```php // src/Export/CsvExporter.php 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)); } } ``` The consuming service collects every tagged exporter into an ordered `iterable` using `#[AutowireIterator]`. It then walks the collection and delegates to the first strategy that supports the requested format. ```php // src/Export/ExportManager.php 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"); } } ``` When the collection is large or expensive to build, `#[AutowireLocator]` is the lazy alternative: it injects a PSR-11 container that instantiates a tagged service only when it is actually fetched by key. That distinction between eager iteration and lazy lookup matters for services that hold connections or heavy state. ## Autoconfiguration, Compiler Passes and Lazy Services Autoconfiguration is the mechanism that reads attributes like `#[AutoconfigureTag]` and `#[AsTaggedItem]` and applies them during compilation. It is why implementing `EventSubscriberInterface` is enough to register an event subscriber with zero configuration. For transformations that attributes cannot express, a compiler pass hooks directly into the container build and mutates definitions before the cache is written. The `DependencyInjection` component source on [GitHub](https://github.com/symfony/dependency-injection) shows how the framework itself uses dozens of these passes internally. Performance-sensitive graphs benefit from lazy services. Marking an argument with `#[Autowire(lazy: true)]` defers instantiation until the dependency is first called, backed by PHP 8.4 native lazy objects rather than a generated proxy library. ```php // src/Service/ReportBuilder.php 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'); } } ``` Native lazy objects landed with PHP 8.4 and are covered in depth in the guide on [Symfony 8 and PHP 8.4 lazy objects](/blog/symfony/symfony-8-new-features-php-84-lazy-objects). The payoff is a container that stays lean: heavy collaborators like PDF renderers, third-party SDK clients, or report generators cost nothing until the code path that needs them actually runs. ## Symfony DI Interview Questions and Answers A common Symfony DI interview question asks for the difference between autowiring and autoconfiguration. Autowiring resolves an individual service's constructor arguments by type, while autoconfiguration applies tags, method calls, and settings to a service based on the interfaces it implements. They are independent switches: a service can be autowired without being autoconfigured, and the reverse. Another frequent question is why the container distinguishes public and private services. Services are private by default, meaning they cannot be fetched directly from the container with `$container->get()` and can only be injected. Private services can be inlined and optimized away during compilation, which is faster; public services must remain retrievable, so the optimizer leaves them intact. Fetching services manually from the container is treated as an anti-pattern precisely because it defeats this optimization and hides dependencies. Circular dependencies are a classic trap and a frequent follow-up question. If service A needs B in its constructor and B needs A, the container cannot build either and reports a circular reference at compile time. The standard fixes are to break the cycle by extracting the shared behavior into a third service, to switch one side to lazy injection with `#[Autowire(lazy: true)]` so the dependency is resolved only on first call, or to use setter injection so the object exists before its collaborator is wired in. Recognizing why the cycle is a design smell, and not just how to silence it, is what interviewers actually listen for. Interviewers also probe the timing of container resolution. Because Symfony compiles the container once and caches it, an unresolvable dependency surfaces during cache warmup rather than at request time. That guarantee is a direct consequence of the compile-time model and is one reason large Symfony applications stay predictable under load. For a broader set of these questions, the [Symfony technology track](/technologies/symfony) groups them by component and difficulty. ## Conclusion - Let the container work: register classes under `src/`, type-hint dependencies in the constructor, and skip manual YAML wiring entirely. - Reach for `#[Autowire]` only for non-services, parameters, environment variables, and specific service ids. - Resolve interface ambiguity with `#[AsAlias]` for the default and `#[Autowire(service: ...)]` or `#[Target]` for explicit picks. - Drive strategy and plugin patterns with `#[AutoconfigureTag]`, `#[AsTaggedItem]` priorities, and `#[AutowireIterator]`; switch to `#[AutowireLocator]` when lazy lookup is preferable to eager iteration. - Defer expensive collaborators with `#[Autowire(lazy: true)]`, which uses PHP 8.4 native lazy objects. - Keep services private and injected rather than fetched, so the compiler can inline and optimize the graph. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/symfony/symfony-dependency-injection-service-container-autowiring-tags