Decorators and Compiler Passes
The Symfony dependency injection container is more than a service locator. Decorators let you extend third-party services without touching their code. Compiler passes manipulate the container at build time and automate configurations that would otherwise have to be maintained by hand.
Table of Contents
- 1. DI container fundamentals: how Symfony manages services
- 2. Service decorators: extending services transparently
- 3. The #[AsDecorator] attribute in Symfony 7
- 4. Compiler passes: manipulating the container at build time
- 5. Service tags: processing groups of services automatically
- 6. Writing and registering your own compiler passes
- 7. Debugging the container and understanding pass ordering
- 8. A common pattern: Chain of Responsibility with a compiler pass
- 9. Decorator vs. preference vs. event: comparing the approaches
- 10. Summary
- 11. FAQ
1. DI container fundamentals: how Symfony manages services
The Symfony dependency injection container is a compiled PHP class that contains all service definitions. On the first call in an environment, Symfony compiles the container from every services.yaml file, bundle configuration and programmatically registered service into a finished PHP class, which is stored in var/cache/prod/. This compiled class no longer contains any abstraction, services are instantiated directly and dependencies are concrete objects. That makes the Symfony container extremely fast at runtime and allows complex transformations at build time that other frameworks perform at runtime instead.
The Symfony DI container distinguishes between shared services (singletons, the default) and non-shared services. For most use cases, repositories, managers, handlers, singletons are the right model: one instance per request, cached in the container. The compilation process runs through several phases in which compiler passes can manipulate the container. These passes are the extension point for bundle authors and for application developers who want to automate container-wide transformations without touching every service individually.
2. Service decorators: extending services transparently
The service decorator is the tool of choice when an existing service needs to be extended without changing its code. The classic scenario: a third-party bundle defines a LoggerInterface service, and the application wants to prefix every log entry with a context label. With a decorator, you register your own logger service that receives the original logger, delegates calls to it and runs its own logic before or after. From the perspective of every other service that gets the logger injected, nothing changes, they still get a LoggerInterface service that just happens to enrich every call with extra logic.
The decorator pattern in Symfony works through renaming: the original service ID Psr\Log\LoggerInterface is internally renamed to Psr\Log\LoggerInterface.inner, and the decorator takes over the original ID. Every other service that declared the original service as a dependency now automatically receives the decorator instead. That is the decisive difference from manual composition: no existing code has to be changed, neither the original service nor the services that use it. The Symfony DI decorator intervenes transparently in the dependency chain.
<?php
declare(strict_types=1);
namespace App\Service\Logger;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
/**
* Decorates the main logger to prepend application context to all log messages.
* All services injecting LoggerInterface automatically receive this decorator.
*/
#[AsDecorator(decorates: 'monolog.logger')]
final class ContextualLogger implements LoggerInterface
{
public function __construct(
#[AutowireDecorated]
private readonly LoggerInterface $inner,
private readonly string $context = 'app',
) {}
/**
* Forward log call with context prefix, inner logger handles actual output.
*/
public function log(mixed $level, string|\Stringable $message, array $context = []): void
{
// Prepend application context to every log entry
$prefixedMessage = "[{$this->context}] {$message}";
$this->inner->log($level, $prefixedMessage, $context);
}
// All other LoggerInterface methods delegate to inner logger unchanged
public function emergency(string|\Stringable $message, array $context = []): void
{
$this->log('emergency', $message, $context);
}
public function alert(string|\Stringable $message, array $context = []): void
{
$this->log('alert', $message, $context);
}
public function critical(string|\Stringable $message, array $context = []): void
{
$this->log('critical', $message, $context);
}
public function error(string|\Stringable $message, array $context = []): void
{
$this->log('error', $message, $context);
}
public function warning(string|\Stringable $message, array $context = []): void { $this->log('warning', $message, $context); }
public function notice(string|\Stringable $message, array $context = []): void { $this->log('notice', $message, $context); }
public function info(string|\Stringable $message, array $context = []): void { $this->log('info', $message, $context); }
public function debug(string|\Stringable $message, array $context = []): void { $this->log('debug', $message, $context); }
}
3. The #[AsDecorator] attribute in Symfony 7
Since Symfony 6.1, the Symfony DI decorator can be configured through the PHP attribute #[AsDecorator], without requiring a services.yaml entry. The attribute takes the service ID of the service to decorate as its parameter. Optionally, the priority parameter controls the order in which several decorators of a service are applied, the decorator with the highest priority becomes the outermost layer. That matters when several bundles or your own services decorate the same service: a logging decorator on the outside, a caching decorator on the inside, for example.
The companion #[AutowireDecorated] automatically injects the decorated (inner) service into the decorator's constructor, without having to specify the inner service ID manually as a string. Symfony manages the .inner ID internally. The decorator must implement the same interface as the decorated service so that all dependencies stay typed against the interface and never need to reference concrete classes. That is, at the same time, the SOLID principle of dependency inversion in practice: code depends on abstractions, not implementations.
4. Compiler passes: manipulating the container at build time
A compiler pass is a PHP class that runs during container compilation and transforms the container definitions. At this point the container is still mutable, all service definitions exist as Definition objects that can be read, modified and replaced. Compiler passes solve problems that would be too expensive, or simply impossible, at runtime: they find every service with a given tag, inject them as a list into another service and remove the tag afterwards, all before the first HTTP request.
The best-known example of a compiler pass in Symfony itself is the event dispatcher pass: it finds every service tagged with kernel.event_listener, reads the tag attributes (event name, method, priority) and configures the event dispatcher accordingly. The result is a fully configured listener chain with no runtime reflection and no manual registration by the developer. The same mechanism is available to your own bundles and applications, custom passes can work with the same degree of automation.
<?php
declare(strict_types=1);
namespace App\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
/**
* Compiler Pass: collect all services tagged with app.payment_gateway
* and inject them as a prioritized list into PaymentGatewayRegistry.
*/
final class PaymentGatewayPass implements CompilerPassInterface
{
/**
* Find all tagged payment gateways and wire them into the registry service.
*/
public function process(ContainerBuilder $container): void
{
// Skip if registry service was removed or not defined
if (!$container->has('App\Payment\PaymentGatewayRegistry')) {
return;
}
$registryDefinition = $container->findDefinition('App\Payment\PaymentGatewayRegistry');
// Find all services tagged with app.payment_gateway, sorted by priority attribute
$taggedServices = $container->findTaggedServiceIds('app.payment_gateway', true);
$gateways = [];
foreach ($taggedServices as $serviceId => $tags) {
foreach ($tags as $tag) {
$priority = $tag['priority'] ?? 0;
$gateways[$priority][] = new Reference($serviceId);
}
}
// Sort by priority descending, higher priority gateways checked first
krsort($gateways);
$sortedGateways = array_merge(...array_values($gateways));
// Inject the sorted gateway list into the registry constructor argument
$registryDefinition->setArgument('$gateways', $sortedGateways);
}
}
5. Service tags: processing groups of services automatically
Service tags are the mechanism through which Symfony DI categorizes services into groups. A tag is a name with optional attributes attached to a service definition. The kernel.event_listener tag is the best known, but the pattern is universally applicable: app.payment_gateway, app.import_handler, app.validator, any name that makes sense in your own application. A compiler pass then finds every service with that tag and processes it automatically.
In Symfony 7, custom tags can be declared through PHP attributes. Adding #[AutoconfigureTag('app.payment_gateway', ['priority' => 10])] to a class registers the service automatically with the tag and priority, as long as autoconfiguration is active. That avoids manual services.yaml entries for every new gateway and reduces configuration effort to zero: a new gateway implements the interface, carries the attribute and is automatically part of the system, with no manual registration and no changes to the existing configuration. The compiler pass picks it up automatically on the next cache clear.
6. Writing and registering your own compiler passes
A custom compiler pass is registered in the kernel class or in the bundle class. In an application without its own bundle, Kernel.php is the right place: the method build(ContainerBuilder $container) is called during container compilation and is the extension point for custom passes. Calling $container->addCompilerPass(new PaymentGatewayPass()) registers the pass. The order of several passes is controlled by the second parameter: PassConfig::TYPE_BEFORE_OPTIMIZATION, TYPE_OPTIMIZE, TYPE_BEFORE_REMOVING, TYPE_REMOVE and TYPE_AFTER_REMOVING are the available phases.
The phase determines which other passes have already run and what is still available in the container. A pass in the BEFORE_OPTIMIZATION phase sees every service, including ones that will later be removed. A pass in the AFTER_REMOVING phase only sees services that actually end up in the final container. For most custom passes, the default phase TYPE_BEFORE_OPTIMIZATION is correct. Symfony's own internal passes run at defined times, custom passes can be positioned precisely within a phase using the third parameter priority, when the order relative to other passes matters.
<?php
declare(strict_types=1);
// src/Kernel.php: register custom compiler pass in application kernel
namespace App;
use App\DependencyInjection\Compiler\PaymentGatewayPass;
use App\DependencyInjection\Compiler\ImportHandlerChainPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
/**
* Register custom compiler passes, called during container compilation.
*/
protected function build(ContainerBuilder $container): void
{
// Default phase: TYPE_BEFORE_OPTIMIZATION (most common use case)
$container->addCompilerPass(new PaymentGatewayPass());
// Explicit phase: runs after Symfony's optimization passes
$container->addCompilerPass(
new ImportHandlerChainPass(),
PassConfig::TYPE_BEFORE_REMOVING,
priority: 10, // Higher priority = runs first within this phase
);
}
}
// src/Payment/PaymentGatewayRegistry.php: receives the injected gateway list
final class PaymentGatewayRegistry
{
/** @param iterable<PaymentGatewayInterface> $gateways */
public function __construct(
private readonly iterable $gateways,
) {}
public function findGateway(string $method): ?PaymentGatewayInterface
{
foreach ($this->gateways as $gateway) {
if ($gateway->supports($method)) {
return $gateway;
}
}
return null;
}
}
7. Debugging the container and understanding pass ordering
The Symfony DI container can be inspected with the console commands bin/console debug:container and bin/console debug:autowiring. debug:container --tag=app.payment_gateway lists every service carrying a given tag, a quick check to see whether a new service was tagged correctly. debug:container PaymentGatewayRegistry shows the full definition of a service, including every injected argument after compiler pass processing. That is the first step when debugging compiler pass problems: what is the value of the argument the pass set, in the final container build?
To understand which compiler passes run in which order, bin/console debug:container --show-private gives deep insight into the container configuration. For compiler pass debugging, the Symfony Profiler in the dev environment is a good fit: the container tab shows every service definition with its arguments after pass processing. Anyone wanting to debug a pass can temporarily insert a dump($container->getDefinition('service.id')->getArguments()) into the pass, which prints the current arguments of the definition before and after the pass runs.
8. A common pattern: Chain of Responsibility with a compiler pass
The Chain of Responsibility pattern is one of the most common use cases for Symfony DI compiler passes. The pattern: several handlers process a request one after another, each handler deciding whether to handle the request or pass it on to the next one. In Symfony, this pattern is implemented through a chain of services that all implement the same interface and are automatically wired together in the right order by a compiler pass. The result is a fully automated system: a new handler implements the interface, carries the tag, and is automatically part of the chain.
Important with this pattern: the priority of the tags determines the order in the chain. Higher priority means the handler is invoked earlier. That is configurable in services.yaml or in the #[AutoconfigureTag] attribute. The compiler pass reads the priorities from the tag attributes, sorts the handlers and injects them in that order. At runtime there is no overhead for the sorting, that is already done at build time and sits as a finished array configuration inside the compiled container.
9. Decorator vs. preference vs. event: comparing the approaches
Symfony offers several ways to extend or modify services. Choosing the right approach depends on exactly what needs to be extended and how deep the intervention should go. Symfony DI decorators are the cleanest approach for transparently extending a service without changing the original code. Preferences (service aliases) replace a class entirely with another one, without delegation, there is no way to call the original logic. Events are meant for loose coupling, when the extending code does not necessarily need to access the result of the original code.
| Approach | Access to Original | Transparency | Best Use |
|---|---|---|---|
| DI decorator | Yes, delegates to inner | Fully transparent | Logging, caching, tracing |
| Service alias (preference) | No, replaces entirely | Full replacement | Alternative implementations |
| Event/hook | Only via event data | Loose coupling | Notifications, side effects |
| Compiler pass | Container-wide view | Build time, no runtime overhead | Tag-based automation |
| Middleware / stack | Yes, pipeline delegation | Explicitly configured | HTTP request processing |
Combining compiler passes and decorators is particularly powerful: a compiler pass finds every service with a tag, a decorator wraps the entire service stack. That is the pattern Symfony itself uses for the event dispatcher, the Messenger bus and the security voter chain. Anyone who understands this pattern understands how Symfony builds its own infrastructure, and can use the same tools to build equally flexible infrastructure of their own.
Mironsoft
Symfony architecture, DI container design and bundle development
Want to build a professional Symfony DI architecture?
We design scalable Symfony DI configurations with decorators, compiler passes and tag-based automation, from the first service definition to a production-ready bundle.
DI audit
Analyze existing service definitions and identify decorator and pass potential
Bundle development
Build reusable Symfony bundles with their own extension point and compiler passes
Architecture consulting
DI pattern selection, decorator vs. event decisions and container optimization
10. Summary
Symfony DI decorators and compiler passes are the two most powerful extension mechanisms of the Symfony container. Decorators let you extend services transparently, without changing their code and without other services knowing they received a decorator instead of the original. The #[AsDecorator] attribute makes the configuration idiomatic and type-safe. Compiler passes manipulate the container at build time and automate tag-based service wiring that would otherwise have to be maintained manually.
Combining both mechanisms is the foundation of Symfony's own infrastructure: the event dispatcher, the Messenger bus, security voters and the HTTP middleware stack are all built with compiler passes and decorators. The same tooling is available to application developers and bundle authors. Anyone who masters compiler passes and decorators can build their own frameworks and bundles with the same flexibility and extensibility as Symfony itself, with zero runtime overhead, because the entire configuration is finished at build time.
Symfony DI Decorators and Compiler Passes: The Essentials at a Glance
Service decorator
#[AsDecorator] + #[AutowireDecorated] extend services transparently. All dependencies automatically receive the decorator instead of the original.
Compiler pass
Implements CompilerPassInterface, finds tagged services with findTaggedServiceIds() and injects them as a list, zero runtime overhead.
Service tags
#[AutoconfigureTag] on the class, a new service with an interface and tag is automatically part of the system, with no manual configuration.
Debugging
bin/console debug:container --tag=app.my_tag and debug:container ServiceClass show the final container state after all passes.