How Service Autowiring Works in a Controller Method

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

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

Login Subscribe

The one common type of controller argument that we haven't explained yet are for arguments that are type-hinted with an autowireable service. How does that work? This class - ServiceValueResolver - is responsible. It's, honestly, a piece of genius from Nicolas Grekas - the person who wrote it. And it's one of my favorite things to look at.

Inside supports(), before we even look at what it's doing, let's dd($argument). When we refresh... there we go. The first time this is called is actually for the second argument to our controller. Why? Because the first argument was handled by RequestAttributeValueResolver before this was ever called. It's not usually important, but these value resolvers can be given a priority.

... lines 1 - 24
final class ServiceValueResolver implements ArgumentValueResolverInterface
{
... lines 27 - 36
public function supports(Request $request, ArgumentMetadata $argument): bool
{
dd($argument);
$controller = $request->attributes->get('_controller');
... lines 41 - 56
}
... lines 58 - 93
}

Anyways, the $slack argument is the first one that hits our supports() method. And the key thing is that this argument has a type of App\Service\SlackClient.

ServiceValueResolver::supports()

Let's look at the logic. Hey! It's our old friend request attributes! We just can't seem to get away from you. The first thing this method does it get the controller with $request->attributes->get('_controller'). For us, that's the now-familiar ClassName::methodName string format.

Next, it does some normalization of the format... which isn't relevant to us: it's just trying to make sure that the $controller variable is ultimately a string.

Finally, we hit an if statement that says: if not $this->container->has($controller). Hmm. It seems like it's... checking to see if our controller is a service?

The Controller Argument ServiceLocator

Actually, no: it's doing something totally different. To see what's going on, before the return, dd($controller) and also $this->container so we can see what it looks like.

... lines 1 - 36
public function supports(Request $request, ArgumentMetadata $argument): bool
{
... lines 39 - 50
if (!$this->container->has($controller) && false !== $i = strrpos($controller, ':')) {
$controller = substr($controller, 0, $i).strtolower(substr($controller, $i));
}
dd($controller, $this->container);
... lines 56 - 57
}
... lines 59 - 96

Now... refresh! Ok, the controller is no surprise: it's the ClassName::methodName string syntax. But check out $this->container. This is not the main Symfony container. This is - once again - one of those small containers, called a service locator.

The details about how this class works aren't too important... but you can browse the $serviceMap property to see what's inside of this container. Apparently it holds 34 services... and weird, it has one service for every single controller method in our system. The id is the full controller string, including the ::methodName part.

So... this is weird. What is this thing? To make sense of it, let's also dd() $this->container->get($controller). That's eventually what the last line of supports() calls.

... lines 1 - 36
public function supports(Request $request, ArgumentMetadata $argument): bool
{
... lines 39 - 54
dd($controller, $this->container, $this->container->get($controller));
... lines 56 - 57
}
... lines 59 - 96

Refresh now. This last dump for $this->container->get($controller) gives us... another mini-container! And if we look at the $serviceMap property, it has two things: articleRepository and slack... which exactly match the two argument names that are type-hinted with services in our controller!

So... in reality, the $this->container property is basically a big array of mini "containers". More technically, it's a service locator for every controller function in our system. And each of those locators contain all the services for all of the arguments that are type-hinted with a service.

Thanks to that, if we look down in resolve()... and skip passed some normalization, it ultimately yields $this->container->get($controller) - to get the mini-container - then ->get($argument->getName()) to get the specific service for the $slack or articleRepository argument.

So we have a big container, full of containers... which are full of services. How crazy is that?

How the Controller Containers are Built

The truly amazing part is how Symfony figures all of this out. All of the logic for building this container of containers is done when your cache is built: there is zero runtime overhead.

The key behind this working is hiding inside of your config/services.yaml file. Have you ever wondered why the src/Controller directory has its own import section? It's not strictly needed... because classes in this directory are already registered as services thanks to the import above.

The reason is this tag... which does two things. First, it makes these services public. We talked about that earlier: controller services must be public so that the controller resolver can fetch that service out of the main container at runtime.

The second thing it does is more interesting. When Symfony is building the container cache, it looks for all of the services that have this tag. It then finds all the public methods on the classes and uses autowiring to figure out all the arguments that are type-hinted with an autowireable service. It uses that info to create this final container of containers.

This was a key innovation in Symfony 3.4 that allowed us to use autowiring in our controller methods.

Head back to ServiceValueResolver and, back up here, let's remove the dd().

Route Defaults can be Arguments

So... that's basically it for the main argument value resolvers. If your argument name matches a route wildcard, then that's allowed as an argument. And actually, now that we understand that these wildcard values go into the request attributes and that any route defaults are added to the same place, open config/routes.yaml. Thanks to this totally_inventing_this_default key that we added to defaults, this will be put into the request attributes and we could add an argument to our controller with this name.

One Missing Piece: Auto-querying Entity Arguments

But we still haven't explained one thing. Open ArticleAdminController and find the edit() action. It doesn't explain why I can type-hint an entity class and... something queries for it automatically and passes us the object.

We'll learn how that works later: it uses a bit of an older system inside of Symfony.

Next, let's go back to HttpKernel and continue our journey. We now have the controller and the arguments. Yep! It's time to actually execute the controller.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.3.0",
        "ext-iconv": "*",
        "antishov/doctrine-extensions-bundle": "^1.4", // v1.4.2
        "aws/aws-sdk-php": "^3.87", // 3.133.20
        "doctrine/doctrine-bundle": "^2.0", // 2.0.7
        "doctrine/doctrine-migrations-bundle": "^1.3|^2.0", // 2.1.2
        "doctrine/orm": "^2.5.11", // v2.7.1
        "easycorp/easy-log-handler": "^1.0", // v1.0.9
        "http-interop/http-factory-guzzle": "^1.0", // 1.0.0
        "knplabs/knp-markdown-bundle": "^1.7", // 1.8.1
        "knplabs/knp-paginator-bundle": "^5.0", // v5.1.1
        "knplabs/knp-snappy-bundle": "^1.6", // v1.7.0
        "knplabs/knp-time-bundle": "^1.8", // v1.11.0
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.24
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "league/html-to-markdown": "^4.8", // 4.9.1
        "liip/imagine-bundle": "^2.1", // 2.3.0
        "nexylan/slack-bundle": "^2.1", // v2.2.2
        "oneup/flysystem-bundle": "^3.0", // 3.4.0
        "php-http/guzzle6-adapter": "^2.0", // v2.0.1
        "sensio/framework-extra-bundle": "^5.1", // v5.5.3
        "symfony/asset": "5.0.*", // v5.0.4
        "symfony/console": "5.0.*", // v5.0.4
        "symfony/dotenv": "5.0.*", // v5.0.4
        "symfony/flex": "^1.0", // v1.6.2
        "symfony/form": "5.0.*", // v5.0.4
        "symfony/framework-bundle": "5.0.*", // v5.0.4
        "symfony/mailer": "5.0.*", // v5.0.4
        "symfony/messenger": "5.0.*", // v5.0.4
        "symfony/monolog-bundle": "^3.5", // v3.5.0
        "symfony/security-bundle": "5.0.*", // v5.0.4
        "symfony/sendgrid-mailer": "5.0.*", // v5.0.4
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "5.0.*", // v5.0.4
        "symfony/twig-pack": "^1.0", // v1.0.0
        "symfony/validator": "5.0.*", // v5.0.4
        "symfony/webpack-encore-bundle": "^1.4", // v1.7.3
        "symfony/yaml": "5.0.*", // v5.0.4
        "twig/cssinliner-extra": "^2.12", // v2.12.5
        "twig/extensions": "^1.5", // v1.5.4
        "twig/inky-extra": "^2.12" // v2.12.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.3.0
        "fzaninotto/faker": "^1.7", // v1.9.1
        "symfony/browser-kit": "5.0.*", // v5.0.4
        "symfony/debug-bundle": "5.0.*", // v5.0.4
        "symfony/maker-bundle": "^1.0", // v1.14.3
        "symfony/phpunit-bridge": "5.0.*", // v5.0.4
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "5.0.*" // v5.0.4
    }
}