Buy
Buy

Service Subscriber: Lazy Performance

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Our nice little Twig extension has a not-so-nice problem! And... it's subtle.

Normally, if you have a service like MarkdownHelper:

... lines 1 - 4
use App\Service\MarkdownHelper;
... lines 6 - 9
class AppExtension extends AbstractExtension
{
private $markdownHelper;
public function __construct(MarkdownHelper $markdownHelper)
{
$this->markdownHelper = $markdownHelper;
}
... lines 18 - 29
}

Symfony's container does not instantiate this service until and unless you actually use it during a request. For example, if we try to use MarkdownHelper in a controller, the container will, of course, instantiate MarkdownHelper and pass it to us.

But, in a different controller, if we don't use it, then that object will never be instantiated. And... that's perfect! Instantiating objects that we don't need would be a performance killer!

Twig Extensions: Always Instantiated

Well... Twig extensions are a special situation. If you go to a page that renders any Twig template, then the AppExtension will always be instantiated, even if we don't use any of its custom functions or filters. Twig needs to instantiate the extension so that it knows about those custom things.

But, in order to instantiate AppExtension, Symfony's container first needs to instantiate MarkdownHelper. So, for example, the homepage does not render anything through markdown. But because our AppExtension is instantiated, MarkdownHelper is also instantiated.

In other words, we are now instantiating an extra object - MarkdownHelper - on every request that uses Twig... even if we never actually use it! It sounds subtle, but as your Twig extension grows, this can become a real problem.

Creating a Service Subscriber

We somehow want to tell Symfony to pass us the MarkdownHelper, but not actually instantiate it until, and unless, we need it. That's totally possible.

But, it's a little bit tricky until you see the whole thing put together. So, watch closely.

First, make your class implement a new interface: ServiceSubscriberInterface:

... lines 1 - 6
use Symfony\Component\DependencyInjection\ServiceSubscriberInterface;
... lines 8 - 11
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 14 - 40
}

This will force us to have one new method. At the bottom of the class, I'll go to the "Code"->"Generate" menu - or Command+N on a Mac - and implement getSubscribedServices(). Return an array from this... but leave it empty for now:

... lines 1 - 6
use Symfony\Component\DependencyInjection\ServiceSubscriberInterface;
... lines 8 - 11
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 14 - 34
public static function getSubscribedServices()
{
return [
... line 38
];
}
}

Next, up on your constructor, remove the first argument and replace it with ContainerInterface - the one from Psr - $container:

... lines 1 - 5
use Psr\Container\ContainerInterface;
... lines 7 - 11
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 14 - 15
public function __construct(ContainerInterface $container)
{
... line 18
}
... lines 20 - 40
}

Also rename the property to $container:

... lines 1 - 5
use Psr\Container\ContainerInterface;
... lines 7 - 11
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
private $container;
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
... lines 20 - 40
}

Populating the Container

At this point... if you're totally confused... no worries! Here's the deal: when you make a service implements ServiceSubscriberInterface, Symfony will suddenly try to pass a service container to your constructor. It does this by looking for an argument that's type-hinted with ContainerInterface. So, you can still have other arguments, as long as one has this type-hint.

But, one important thing: this $container is not Symfony's big service container that holds hundreds of services. Nope, this is a mini-container, that holds a subset of those services. In fact, right now, it holds zero.

To tell Symfony which services you want in your mini-container, use getSubscribedServices(). Let's return the one service we need: MarkdownHelper::class:

... lines 1 - 4
use App\Service\MarkdownHelper;
... lines 6 - 11
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 14 - 34
public static function getSubscribedServices()
{
return [
MarkdownHelper::class,
];
}
}

When we do this, Symfony will basically autowire that service into the mini container, and make it public so that we can fetch it directly. In other words, down in processMarkdown(), we can use it with $this->container->get(MarkdownHelper::class) and then ->parse($value):

... lines 1 - 4
use App\Service\MarkdownHelper;
... lines 6 - 11
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 14 - 27
public function processMarkdown($value)
{
return $this->container
->get(MarkdownHelper::class)
->parse($value);
}
... lines 34 - 40
}

At this point, this might feel like just a more complex version of dependency injection. And yea... it kinda is! Instead of passing us the MarkdownHelper directly, Symfony is passing us a container that holds the MarkdownHelper. But, the key difference is that, thanks to this trick, the MarkdownHelper service is not instantiated until and unless we fetch it out of this container.

Understanding getSubscribedEvents()

Oh, and to hopefully make things a bit more clear, you can actually return a key-value pair from getSubscribedEvents(), like 'foo' => MarkdownHelper::class:

class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
    // ...
    public static function getSubscribedServices()
    {
        return [
            'foo' => MarkdownHelper::class,
        ];
    }
}

If we did this, it would still mean that the MarkdownHelper service is autowired into the mini-container, but we would reference it internally with the id foo.

If you just pass MarkdownHelper::class as the value, then that's also used as the key.

The end result is exactly the same as before, except MarkdownHelper is lazy! To prove it, put a die statement at the top of the MarkdownHelper constructor.

Now, go back to the article page and refresh. Not surprising: it hits the die statement when rendering the Twig template. But now, go back to the homepage. Yes! The whole page prints: MarkdownHelper is never instantiated.

Go back and remove that die statement.

Here's the super-duper-important takeaway: I want you to use normal dependency injection everywhere - just pass each service you need through the constructor, without all this fancy service-subscriber stuff.

But then, in just a couple of places in Symfony, the main ones being Twig extensions, event subscribers and security voters - a few topics we'll talk about in the future - you should consider using a service subscriber instead to avoid a performance hit.

Leave a comment!

  • 2018-11-05 Diego Aguiar

    Hey AbelardoLG

    I believe you are rendering an Article in your front page and because of that "die" statement your script stops being executed. Just remove the "die" statement :)

    Cheers!

  • 2018-11-02 AbelardoLG

    Hi there!

    I wrote "die;" at the top of the constructor and then I went to the article page. It resulted in a blank page (expected result); so, when I went to the 127.0.0.1:8000, it remains the blank page. What's wrong?

    Cheers!

  • 2018-08-20 Qcho

    Thanks for the clarification :)

  • 2018-08-20 weaverryan

    Hey Qcho!

    You make a great point! We are absolutely using a service locator pattern in this situation. In fact, the whole "service locator"/"service subscriber" feature we're using in Symfony was made possible by PSR-11 (the fact that the Container->get() method has a standard interface).

    In general, yea, I think using a lazy service proxy is a simpler solution - you just use DI like every other places, so there's nothing extra to learn. But, there's one problem: you can currently only make your *own* services lazy - not core services. We could totally make MarkdownHelper lazy here. But, if we also needed the entity manager, we cannot make that lazy (well, technically we could with a compiler pass, but gets really complex). The service locator is a way around this. It's not as elegant as normal DI for sure. But, at least we're not passing in the *entire* container: we're passing in a "mini" container that only has the stuff we need in it. It's an unfortunate, necessary evil in some situations.

    Cheers!

  • 2018-08-19 Qcho

    I think a more elegant solution instead of following the service subscriber pattern is to use (as commented) the Lazy Service proxy:
    https://symfony.com/doc/cur... and use standard Dependency Injection.

    You don't need to follow my advice, who am I right?, but I think no one will discourage this:
    https://www.php-fig.org/psr...

    1.3 Recommended usage

    Users SHOULD NOT pass a container into an object so that the object can retrieve its own dependencies.

    This means the container is used as a Service Locator

    which is a pattern that is generally discouraged.

    Please refer to section 4 of the META document for more details.

  • 2018-06-27 Diego Aguiar

    Oh, in that case you can just fetch it from the container, that's how things was done in Symfony2

  • 2018-06-26 bartek1234321

    I meant Lazy Performance solution in twig.

  • 2018-06-26 Diego Aguiar

    Hey bartek1234321

    Are you talking about the "autoconfigure" functionality? In that case, I'm fraid not. You have to define your Twig extension as a service and give it its tag

    Cheers!

  • 2018-06-26 bartek1234321

    Does exist similar solution for symfony 2.8 ?

  • 2018-05-09 Diego Aguiar

    Śpiechu

    That could be another solution, the only problem I can think of is that you would be passing a proxy to any service that make use of "MarkdownHelper", and of course an extra line of configuration, but if that's not a problem for you, then go ahead :)

    Cheers!

  • 2018-05-08 Śpiechu

    Can we just mark MarkdownHelper as lazy service? It should pass proxy object to Twig extension's constructor method.

  • 2018-04-30 Diego Aguiar

    Haha, no worries Greg. Actually, that happened to me once :D

  • 2018-04-29 Greg

    Hey Diego Aguiar

    I need to think before I speak ;)
    This is exactly it, I'm importing the Container from Symfony.

    Cheers!

  • 2018-04-27 Diego Aguiar

    Hey Greg

    That error comes from when you try to fetch a private service from the container. So I'm guessing that you injected the wrong container into your Twig extension, make sure that you are importing the one from "Psr\Container"

    Cheers!

  • 2018-04-27 Greg

    Hey

    It's always me ;)
    Just a little question is it normal when I used the ServiceSubscriberInterface I have this error ?:

    An exception has been thrown during the rendering of a template ("The "App\Service\MarkdownHelper" service or alias has been removed or inlined when the container was compiled. You should either make it public, or stop using the container directly and use dependency injection instead.").

    Cheers!