Skip to content

Service Decoration

Edit this page

Sometimes you need to change the behavior of an existing service without modifying its original class; for example, because the class belongs to Symfony or to a third-party package, or because you don't want to mix unrelated features in the same class. Service decoration solves this problem: it wraps the existing service inside a new service that adds the new behavior and calls the original service when needed. This is an implementation of the Decorator pattern.

Decoration is different from redefining a service. When you redefine an existing service definition, the original service is lost and you can't call it anymore. When you decorate a service, the container replaces it with your own service but keeps the original one available, so your decorator can still use it.

Decorating Services

Suppose you need to log every email sent by the App\Mailer service. Adding the logging code to the Mailer class is not always possible (e.g. when the class belongs to a third-party package) or desirable (e.g. to keep the class focused on sending emails). Instead, create an App\LoggingMailer service that decorates it:

1
2
3
4
5
6
7
8
9
10
11
// src/LoggingMailer.php
namespace App;

// ...
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;

#[AsDecorator(decorates: Mailer::class)]
class LoggingMailer
{
    // ...
}

From now on, any service that asks for App\Mailer receives an instance of App\LoggingMailer instead. You don't have to change anything in the services that inject App\Mailer: the container turns its ID into an alias of the decorating service, and that alias keeps the same visibility (public or private) as the original App\Mailer service. The original service is not lost: it's still available so the decorating service can inject and call it, as explained in the next section.

Tip

You can apply multiple #[AsDecorator] attributes to the same class to decorate multiple services with it.

Note

All custom service tags from the decorated service are removed and added to the new service. Only certain built-in service tags defined by Symfony are kept: container.service_locator, container.service_subscriber, kernel.event_subscriber, kernel.event_listener, kernel.locale_aware, and kernel.reset.

Accessing the Decorated Service

Most decorators need to call the service they decorate to do their work. In this example, LoggingMailer logs a message and then calls the original Mailer service to actually send the email. If you're using the default services.yaml configuration, the decorated service is injected automatically when the constructor of the decorating service has one argument type-hinted with the decorated service class.

If you are not using autowiring, or the decorating service has more than one constructor argument type-hinted with the decorated service class, inject the decorated service explicitly. Its ID is '.inner' in configuration files, whereas in PHP classes you use the #[AutowireDecorated] attribute:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src/LoggingMailer.php
namespace App;

// ...
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

#[AsDecorator(decorates: Mailer::class)]
class LoggingMailer
{
    public function __construct(
        // unlike other configuration formats (that must use $inner as the name of the
        // decorated service) when using attributes you can choose any variable name
        #[AutowireDecorated] private Mailer $mailer,
        private LoggerInterface $logger,
    ) {
    }

    public function send(string $to, string $subject, string $body): void
    {
        $this->logger->info('Sending email to '.$to);

        $this->mailer->send($to, $subject, $body);
    }
}

Behind the scenes, the original service keeps a real ID built as the decorating service ID plus the .inner suffix (e.g. 'App\LoggingMailer.inner'). In configuration files you can change that ID with the decoration_inner_name option:

1
2
3
4
5
6
7
8
# config/services.yaml
services:
    App\LoggingMailer:
        # ...
        # you can use any name here; if you decorate a lot of services,
        # consider adding the full original service ID as part of the new ID
        decoration_inner_name: 'App\Mailer.original'
        arguments: ['@App\Mailer.original']

When using the #[AutowireDecorated] attribute you don't need this option, because you can name the constructor argument that holds the decorated service however you like.

Decoration Priority

You can apply several decorators to the same service. For example, in addition to logging all emails with App\LoggingMailer, you may need an App\RateLimitingMailer decorator that only sends emails while the sending quota of your email provider is not exceeded. Use the decoration_priority option to control the order in which decorators are applied. Its value is an integer that defaults to 0 and higher priorities mean that decorators will be applied earlier.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
namespace App;

// ...
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

#[AsDecorator(decorates: Mailer::class, priority: 5)]
class LoggingMailer
{
    public function __construct(
        #[AutowireDecorated] private Mailer $mailer,
    ) {
    }

    // ...
}

#[AsDecorator(decorates: Mailer::class, priority: 1)]
class RateLimitingMailer
{
    public function __construct(
        // don't add the Mailer type to this argument: the injected service
        // is the next decorator in the chain (LoggingMailer in this example),
        // not the original Mailer; add a type only when the original service
        // and all its decorators implement the same interface
        #[AutowireDecorated] private $mailer,
    ) {
    }

    // ...
}

In practice, the container now creates the App\Mailer service like this (shown as simplified PHP code):

1
$mailer = new RateLimitingMailer(new LoggingMailer(new Mailer()));

Since higher priority decorators are applied earlier, they end up closer to the original service. When using the decorated service, the outermost decorator (i.e. the one with the lowest priority) runs first. In this example, RateLimitingMailer checks the sending quota before LoggingMailer logs anything, so no email is logged unless it's actually sent.

Decorating All Services with a Tag

8.1

The #[AsTagDecorator] attribute and the decorates_tag option were introduced in Symfony 8.1.

Instead of decorating a single service, you can decorate every service that has a given tag. Symfony creates one decorator per tagged service, and each one wraps its original service as .inner.

For example, if your application defines several mailer services and all of them are tagged with app.mailer, you can log the emails sent by all of them with a single decorator:

1
2
3
4
5
6
7
8
9
10
11
// src/LoggingMailer.php
namespace App;

// ...
use Symfony\Component\DependencyInjection\Attribute\AsTagDecorator;

#[AsTagDecorator(tag: 'app.mailer')]
class LoggingMailer
{
    // ...
}

Stacking Decorators

An alternative to decoration priorities is to define a stack: a list of ordered services where each one decorates the next one. Both approaches create the same nested objects, but they work differently:

  • When using decorates, the container replaces the original service everywhere it's injected. By default, a stack doesn't replace any service: it defines a new service (mailer_stack in this example) and only the services injecting it explicitly get the decorated behavior (although stacks can also decorate existing services, as explained later in this section);
  • When using priorities, each decorator is defined independently, maybe even in different files or bundles, and the final order depends on all the priority values. In a stack, you define the whole chain in a single place, in the exact order in which decorators are applied;
  • The same classes can be included in different stacks, allowing you to create several variants of the same decorated service.

This is how the example of the previous section looks when defined as a stack:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# config/services.yaml
services:
    mailer_stack:
        stack:
            - class: App\RateLimitingMailer
              arguments: ['@.inner']
            - class: App\LoggingMailer
              arguments: ['@.inner']
            - class: App\Mailer

    # alternatively, use this short syntax, where each frame is a class
    # name followed by its arguments; thanks to autowiring, you can even
    # omit the arguments:
    # mailer_stack:
    #     stack:
    #         - App\RateLimitingMailer: ~
    #         - App\LoggingMailer: ~
    #         - App\Mailer: ~

The mailer_stack service created by the container is the same nested object as in the previous section:

1
$mailer = new RateLimitingMailer(new LoggingMailer(new Mailer()));

Each frame of the stack can be either an inlined service, a reference to an existing service or a child definition. The latter allows embedding stack definitions into each others.

For example, imagine that legal regulations require your application to audit all sent emails and to add a legal disclaimer to their contents. You can define that compliance behavior as its own stack and embed it into the mailer stack:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# config/services.yaml
services:
    audit_mailer:
        class: App\AuditMailer

    mailer_compliance_stack:
        stack:
            - alias: audit_mailer
            - App\DisclaimerMailer: ~

    mailer_stack:
        stack:
            - parent: mailer_compliance_stack
            - App\RateLimitingMailer: ~
            - App\LoggingMailer: ~
            - App\Mailer: ~

The result will be:

1
2
3
$mailer = new AuditMailer(new DisclaimerMailer(
    new RateLimitingMailer(new LoggingMailer(new Mailer()))
));

Note

To change existing stacks (i.e. from a compiler pass), you can access each frame by its generated id with the following structure: .stack_id.frame_key. From the example above, .mailer_stack.1 would be a reference to the inlined App\RateLimitingMailer service and .mailer_stack.0 to the embedded stack. To get more explicit ids, you can give a name to each frame:

1
2
3
4
5
6
7
8
# ...
mailer_stack:
    stack:
        first:
            parent: mailer_compliance_stack
        second:
            App\RateLimitingMailer: ~
        # ...

The App\RateLimitingMailer frame id will now be .mailer_stack.second.

Using Stacks to Decorate Existing Services

8.1

The decorates and decorates_tag options on stacks were introduced in Symfony 8.1.

By default, stacks define a self-contained chain of services. But you can also use a stack to decorate an existing service by adding the decorates option. The innermost frame of the stack wraps the original service:

1
2
3
4
5
6
7
8
9
# config/services.yaml
services:
    mailer_stack:
        decorates: App\Mailer
        stack:
            - class: App\RateLimitingMailer
              arguments: ['@.inner']
            - class: App\LoggingMailer
              arguments: ['@.inner']

Unlike the previous examples, the stack doesn't define the App\Mailer frame itself. The container injects the original service instead:

1
$mailer = new RateLimitingMailer(new LoggingMailer($originalMailer));

You can also use the decorates_tag option to decorate all the services tagged with a specific tag. The stack is cloned once per tagged service and each clone decorates one of the tagged services:

1
2
3
4
5
6
7
8
9
# config/services.yaml
services:
    mailer_stack:
        decorates_tag: app.mailer
        stack:
            - class: App\RateLimitingMailer
              arguments: ['@.inner']
            - class: App\LoggingMailer
              arguments: ['@.inner']

Note

The decoration_inner_name, decoration_priority and decoration_on_invalid options can also be used on stacks, as with regular decorated services.

Control the Behavior When the Decorated Service Does Not Exist

When you decorate a service that doesn't exist, the decoration_on_invalid option allows you to choose the behavior to adopt.

Three different behaviors are available:

  • exception: (default) A ServiceNotFoundException will be thrown telling that decorator's dependency is missing.
  • ignore: The container will remove the decorator.
  • null: The container will keep the decorator service and will set the decorated one to null; declare the constructor argument that receives the decorated service as nullable (e.g. private ?Mailer $mailer) and check for null before calling it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/LoggingMailer.php
namespace App;

// ...
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\DependencyInjection\ContainerInterface;

#[AsDecorator(decorates: Mailer::class, onInvalid: ContainerInterface::IGNORE_ON_INVALID_REFERENCE)]
class LoggingMailer
{
    public function __construct(
        #[AutowireDecorated] private $mailer,
    ) {
    }

    // ...
}

Note

Sometimes, you may want to add a compiler pass that creates service definitions dynamically. If you want to decorate such a service, be sure that your compiler pass is registered with PassConfig::TYPE_BEFORE_OPTIMIZATION type so that the decoration pass will be able to find the created services.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version