Skip to content

Service Subscribers & Locators

Edit this page

Sometimes, a service needs access to several other services without being sure that all of them will actually be used; in those cases, creating all the services from the start is a waste of resources.

You could inject each of those services as a lazy service or via a service closure: both are good options to delay the instantiation of one or a few specific dependencies. However, they require configuring every dependency individually, which becomes impractical when the number of services is large or not known in advance (e.g. all the services tagged with a certain tag).

For those cases, Symfony provides a service locator: a lightweight PSR-11 container that includes a predefined set of services, but only instantiates each of them when it's actually used.

Note

Injecting the whole Symfony container might look like an alternative, but it's strongly discouraged: it hides the real dependencies of your class, it gives access to too many services and it requires making all those services public.

You can define the list of services included in a locator in the place where the locator is injected (using a PHP attribute or configuration files) or in the class that uses the locator; in that case, the class is called a service subscriber. This article explains all the options.

Injecting a Service Locator

Suppose your application provides several payment methods and each of them is implemented in its own service. The Checkout service processes the payment method selected by the user, so it needs access to all the payment services, but it only uses one of them on each checkout. This is a perfect use case for a service locator; define it with the #[AutowireLocator] attribute or the service_locator argument type:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/Checkout.php
namespace App;

use App\Payment\CreditCardPayment;
use App\Payment\PaypalPayment;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;

class Checkout
{
    public function __construct(
        #[AutowireLocator([
            'credit_card' => CreditCardPayment::class,
            'paypal' => PaypalPayment::class,
        ])]
        private ContainerInterface $paymentHandlers,
    ) {
    }
}

Get the services from the locator using the keys defined for them. The services are not instantiated until you get them:

1
2
3
4
5
6
7
8
9
10
// src/Checkout.php
// ...

public function process(string $paymentMethod, int $amount): void
{
    // if e.g. $paymentMethod = 'paypal', only the PaypalPayment
    // service is instantiated during this checkout
    $paymentHandler = $this->paymentHandlers->get($paymentMethod);
    $paymentHandler->pay($amount);
}

If you omit the keys of the list, the class or interface name of each service is used as its key (e.g. $this->paymentHandlers->get(PaypalPayment::class)). In addition, you can mark any service as optional by prefixing its type with ?: the locator won't fail when that service doesn't exist; use has() to check it before getting it:

1
2
3
4
5
6
7
8
9
10
11
12
class Checkout
{
    public function __construct(
        #[AutowireLocator([
            'credit_card' => CreditCardPayment::class,
            // optional service: no error even if PaypalPayment doesn't exist
            'paypal' => '?'.PaypalPayment::class,
        ])]
        private ContainerInterface $paymentHandlers,
    ) {
    }
}

Counting and Iterating the Locator Services

You can type-hint the service locator argument with ServiceCollectionInterface instead of Psr\Container\ContainerInterface. By doing so, you'll be able to count and iterate over the services of the locator:

1
2
3
4
5
6
7
8
// ...
$numberOfHandlers = count($this->paymentHandlers);
$nameOfHandlers = array_keys($this->paymentHandlers->getProvidedServices());

// you can iterate through all services of the locator
foreach ($this->paymentHandlers as $serviceId => $service) {
    // do something with the service, the service id or both
}

Reusing a Service Locator in Multiple Services

If you inject the same service locator in several services, it's better to define the service locator as a stand-alone service and then inject it in the other services. To do so, create a new service definition using the ServiceLocator class:

1
2
3
4
5
6
7
8
9
10
11
# config/services.yaml
services:
    app.payment_locator:
        class: Symfony\Component\DependencyInjection\ServiceLocator
        arguments:
            -
                credit_card: '@App\Payment\CreditCardPayment'
                paypal: '@App\Payment\PaypalPayment'
        # if you are not using the default service autoconfiguration,
        # add the following tag to the service definition:
        # tags: ['container.service_locator']

Now you can inject the service locator in other services:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/Checkout.php
namespace App;

use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

class Checkout
{
    public function __construct(
        #[Autowire(service: 'app.payment_locator')]
        private ContainerInterface $paymentHandlers,
    ) {
    }
}

Injecting Tagged Services

Service locators are also useful when working with the services that have a certain service tag, because that set of services is not known in advance. Pass a tag name (instead of a list of services) to the #[AutowireLocator] attribute or use the tagged_locator argument type to create a locator with all the services tagged with that tag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/Checkout.php
namespace App;

use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;

class Checkout
{
    public function __construct(
        // creates a locator with all the services tagged with 'app.payment_handler'
        #[AutowireLocator('app.payment_handler')]
        private ContainerInterface $paymentHandlers,
    ) {
    }
}

If the service needs to loop over all the tagged services instead of getting only some of them, use the #[AutowireIterator] attribute or the tagged_iterator argument type instead. They inject an iterable with all the tagged services:

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/Checkout.php
namespace App;

use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

class Checkout
{
    public function __construct(
        // injects an iterable with all the services tagged with 'app.payment_handler'
        #[AutowireIterator('app.payment_handler')]
        private iterable $paymentHandlers,
    ) {
    }

    public function getAvailablePaymentMethods(): array
    {
        $methods = [];
        foreach ($this->paymentHandlers as $paymentHandler) {
            if ($paymentHandler->isEnabled()) {
                $methods[] = $paymentHandler->getName();
            }
        }

        return $methods;
    }
}

All these options provide extra features to sort, filter and index the injected services. Read all about them in the service tags article.

Service Subscribers

In the previous sections, the list of services included in the locator is defined where the locator is injected: in the PHP attribute, in configuration files or via a service tag. A service subscriber is a class that defines that list by itself, so the list is always the same no matter where or how the class is used.

Service subscribers are useful when other projects use your classes (e.g. classes included in bundles) and, above all, when extending a class that already subscribes to its own services, such as the AbstractController class extended by Symfony controllers.

To turn a class into a service subscriber, implement ServiceSubscriberInterface and return the list of needed services in the static getSubscribedServices() method:

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

use App\Payment\CreditCardPayment;
use App\Payment\PaypalPayment;
use Psr\Container\ContainerInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;

class Checkout implements ServiceSubscriberInterface
{
    public function __construct(
        private ContainerInterface $paymentHandlers,
    ) {
    }

    public static function getSubscribedServices(): array
    {
        return [
            'credit_card' => CreditCardPayment::class,
            'paypal' => PaypalPayment::class,
        ];
    }
}

There's nothing else to configure: when the container finds a service implementing ServiceSubscriberInterface, it creates a locator with the subscribed services and injects it into the constructor argument type-hinted with Psr\Container\ContainerInterface. The rest of the class works exactly the same as when injecting the locator explicitly.

Tip

The container detects service subscribers via autoconfiguration. If you disabled it, add the container.service_subscriber tag to the definition of the service that implements ServiceSubscriberInterface (App\Checkout in this example).

The entries of getSubscribedServices() support the same features as the #[AutowireLocator] attribute: omit the keys to use the type as the service key and prefix any type with ? to make that service optional:

1
2
3
4
5
6
7
8
9
10
11
12
use Psr\Log\LoggerInterface;

public static function getSubscribedServices(): array
{
    return [
        'credit_card' => CreditCardPayment::class,
        // the key of this service is 'Psr\Log\LoggerInterface'
        LoggerInterface::class,
        // optional service: no error even if it doesn't exist
        '?'.PaypalPayment::class,
    ];
}

When extending a class that also implements ServiceSubscriberInterface, it's your responsibility to call the parent when overriding the method, so the subscribed services of both classes are merged. This typically happens when extending AbstractController:

1
2
3
4
5
6
7
8
9
10
11
12
13
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class MyController extends AbstractController
{
    public static function getSubscribedServices(): array
    {
        return array_merge(parent::getSubscribedServices(), [
            // ...
            'logger' => LoggerInterface::class,
        ]);
    }
}

Customizing the Injected Services

By default, the container uses autowiring to decide which service to inject for each subscribed type. If you need to inject a different service, use the key and id attributes of the container.service_subscriber tag. Each tag maps a single key, so repeat the tag for each service to override; the subscribed services not included in any tag keep using autowiring:

1
2
3
4
5
6
# config/services.yaml
services:
    App\Checkout:
        tags:
            - { name: 'container.service_subscriber', key: 'logger', id: 'monolog.logger.event' }
            - { name: 'container.service_subscriber', key: 'paypal', id: 'app.payment.paypal_sandbox' }

Tip

The key attribute can be omitted if the service name internally is the same as in the service container.

You can get the same result without touching the configuration files (and use any other dependency injection feature, like injecting parameters or specific implementations). To do so, return SubscribedService objects in getSubscribedServices() and pass the needed dependency injection attributes in their attributes argument (you can combine these objects with regular string entries):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Contracts\Service\Attribute\SubscribedService;

public static function getSubscribedServices(): array
{
    return [
        // ...
        // inject a specific service for the given type
        new SubscribedService('logger', LoggerInterface::class, attributes: new Autowire(service: 'monolog.logger.event')),

        // inject a specific implementation of the given type
        new SubscribedService('payment.logger', LoggerInterface::class, attributes: new Target('paymentLogger')),

        // inject a container parameter
        new SubscribedService('env', 'string', attributes: new Autowire('%kernel.environment%')),
    ];
}

The supported attributes are Autowire, AutowireDecorated, AutowireIterator, AutowireLocator and Target.

Note

Returning SubscribedService objects requires version 3.2 or newer of symfony/service-contracts.

Service Subscriber Trait

Service subscribers require some boilerplate code: implementing the interface, listing the services in one method, injecting the locator in the constructor and getting the services from it. The ServiceMethodsSubscriberTrait removes most of that boilerplate: add the SubscribedService attribute to any private method that defines a return type and the trait will build the list of subscribed services using those return types. Each method returns the service associated with it, so the class ends up with a type-hinted helper method per dependency:

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
// src/Service/MyService.php
namespace App\Service;

use Psr\Log\LoggerInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;
use Symfony\Contracts\Service\ServiceMethodsSubscriberTrait;
use Symfony\Contracts\Service\ServiceSubscriberInterface;

class MyService implements ServiceSubscriberInterface
{
    use ServiceMethodsSubscriberTrait;

    public function doSomething(): void
    {
        // $this->router() ...
        // $this->logger() ...
    }

    #[SubscribedService]
    private function router(): RouterInterface
    {
        return $this->container->get(__METHOD__);
    }

    #[SubscribedService]
    private function logger(): LoggerInterface
    {
        return $this->container->get(__METHOD__);
    }
}

This allows you to create helper traits like RouterAware, LoggerAware, etc. and compose your services with them:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// src/Service/LoggerAware.php
namespace App\Service;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;

trait LoggerAware
{
    #[SubscribedService]
    private function logger(): LoggerInterface
    {
        return $this->container->get(__CLASS__.'::'.__FUNCTION__);
    }
}

// src/Service/RouterAware.php
namespace App\Service;

use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;

trait RouterAware
{
    #[SubscribedService]
    private function router(): RouterInterface
    {
        return $this->container->get(__CLASS__.'::'.__FUNCTION__);
    }
}

// src/Service/MyService.php
namespace App\Service;

use Symfony\Contracts\Service\ServiceMethodsSubscriberTrait;
use Symfony\Contracts\Service\ServiceSubscriberInterface;

class MyService implements ServiceSubscriberInterface
{
    use ServiceMethodsSubscriberTrait, LoggerAware, RouterAware;

    public function doSomething(): void
    {
        // $this->router() ...
        // $this->logger() ...
    }
}

Warning

When creating these helper traits, the service id cannot be __METHOD__ as this will include the trait name, not the class name. Instead, use __CLASS__.'::'.__FUNCTION__ as the service id.

Hooked Properties

3.7

Support for hooked properties in ServiceMethodsSubscriberTrait was introduced in symfony/service-contracts 3.7.

Instead of using methods, you can also use PHP virtual/hooked properties together with the SubscribedService attribute. The property must define a get hook that retrieves the service from the container:

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
// src/Service/MyService.php
namespace App\Service;

use Psr\Log\LoggerInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;
use Symfony\Contracts\Service\ServiceMethodsSubscriberTrait;
use Symfony\Contracts\Service\ServiceSubscriberInterface;

class MyService implements ServiceSubscriberInterface
{
    use ServiceMethodsSubscriberTrait;

    #[SubscribedService]
    public RouterInterface $router {
        get => $this->container->get(__METHOD__);
    }

    #[SubscribedService]
    public LoggerInterface $logger {
        get => $this->container->get(__METHOD__);
    }

    public function doSomething(): void
    {
        // $this->router ...
        // $this->logger ...
    }
}

SubscribedService Attributes

The #[SubscribedService] attribute also accepts the attributes argument to customize the injected services, as shown previously for the getSubscribedServices() method:

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
32
33
34
35
36
37
38
39
40
// src/Service/MyService.php
namespace App\Service;

use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;
use Symfony\Contracts\Service\ServiceMethodsSubscriberTrait;
use Symfony\Contracts\Service\ServiceSubscriberInterface;

class MyService implements ServiceSubscriberInterface
{
    use ServiceMethodsSubscriberTrait;

    public function doSomething(): void
    {
        // $this->environment() ...
        // $this->router() ...
        // $this->logger() ...
    }

    #[SubscribedService(attributes: new Autowire('%kernel.environment%'))]
    private function environment(): string
    {
        return $this->container->get(__METHOD__);
    }

    #[SubscribedService(attributes: new Autowire(service: 'router'))]
    private function router(): RouterInterface
    {
        return $this->container->get(__METHOD__);
    }

    #[SubscribedService(attributes: new Target('requestLogger'))]
    private function logger(): LoggerInterface
    {
        return $this->container->get(__METHOD__);
    }
}

Testing a Service Subscriber

To unit test a service subscriber, you can create a fake container:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use Symfony\Contracts\Service\ServiceLocatorTrait;
use Symfony\Contracts\Service\ServiceProviderInterface;

// Create the fake services
$foo = new stdClass();
$bar = new stdClass();
$bar->foo = $foo;

// Create the fake container
$container = new class([
    'foo' => fn () => $foo,
    'bar' => fn () => $bar,
]) implements ServiceProviderInterface {
    use ServiceLocatorTrait;
};

// Create the service subscriber
$serviceSubscriber = new MyService($container);
// ...

Note

When defining the service locator like this, beware that the getProvidedServices() of your container will use the return type of the closures as the values of the returned array. If no return type is defined, the value will be ?. If you want the values to reflect the classes of your services, the return type has to be set on your closures.

Another alternative is to mock it using PHPUnit:

1
2
3
4
5
6
7
8
9
10
11
12
13
use Psr\Container\ContainerInterface;

$container = $this->createMock(ContainerInterface::class);
$container->expects(self::any())
    ->method('get')
    ->willReturnMap([
        ['foo', $this->createStub(Foo::class)],
        ['bar', $this->createStub(Bar::class)],
    ])
;

$serviceSubscriber = new MyService($container);
// ...
This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version