Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

The Magic `_controller` Attribute

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

Now that we've been stuffed full of knowledge about the request-response process, let's see what kind of trouble we can get into. Uh, I mean, let's do some cool and productive things with all this new information!

Overriding the Controller from a Listener?

Close all the files except for index.php and HttpKernel. Here's our first challenge: could we - from some event listener - change the controller for the page?

Hint: in Symfony, the answer to "can I do X" is always yes. In this case, it's not only possible, there are multiple ways to do it.

For example, when HttpKernel dispatches the kernel.controller event, it passes each listener a ControllerEvent object. And that class has a setController() method. Easy peasy! We can override the controller by adding a listener to that event. Heck, you can do the same thing down here with the kernel.controller_arguments event.

Overriding the Controller on kernel.request?

So... that was too easy. I'll close up my directory tree... and then open our UserAgentSubscriber. Here's my harder challenge: how can we override the controller from here: from a listener to the kernel.request event. In this case, there is no setController() method.

Callback Controller with _controller

The trick is to remember how the controller resolver works: it starts by fetching the _controller value from the $request->attributes. So if, for some reason, we wanted to completely replace the controller, we can do it right here: $request->attributes->set('_controller', .... For fun, let's set this to an anonymous function... cause yea! That's allowed! Inside, return a new Response() with:

I just took over the controller

... lines 1 - 9
class UserAgentSubscriber implements EventSubscriberInterface
{
... lines 12 - 18
public function onKernelRequest(RequestEvent $event)
{
... lines 21 - 22
$request->attributes->set('_controller', function() {
return new Response('I just took over the controller!');
});
... lines 26 - 28
}
... lines 30 - 36
}

Will it work? Refresh any page. Yep! We see our message here, on the homepage and everywhere. And our normal controller tricks work just fine: add a $slug argument... but give it a default value and then dd($slug). On the article show page... this works thanks to the {slug} wildcard. On the homepage, it's null because that wildcard doesn't exist.

... lines 1 - 23
$request->attributes->set('_controller', function($slug = null) {
dd($slug);
... lines 26 - 27
});
... lines 29 - 41

RouterListener Skips when _controller is Set

Open up RouterListener.php one more time and find its onKernelRequest() method. This method is normally responsible for executing the router and setting the _controller key onto the request attributes. But back down at getSubscribedEvents(), ah! The kernel.request listener has a priority of 32. We didn't give our subscriber a priority, so it has a priority of 0. This means that RouterListener is called before our subscriber.

Ok, so then here is what's happening: RouterListener is called first and it is executing the router and setting an _controller key on the request attributes. Then our listener is called and we override that value.

So... if we reversed the order - if we made our listener be called first - our little _controller hack would not work, right? Because RouterListener would override our value.

Actually... no! At the top of onKernelRequest(), one of the first things it does is check to see if something has already set the _controller attribute. If it has, it does nothing: someone else has decided to be responsible for figuring out which controller to call. In reality, no matter how early the _controller attribute is set, it will always win over RouterListener.

Peaking at our First Sub-Request

Why is that important? Because this explains how ErrorListener was able to execute ErrorController. Open up ErrorListener.php. Remember: to execute ErrorController this duplicated the request. But it didn't create an exact copy: it overrode the attributes in order to set _controller to error_controller. Then it sent that new Request back through the entire $kernel->handle() process! This means that before any listeners were executed during that second trip through HttpKernel::handle(), the _controller attribute was already set.

So in reality, on an error page, RouterListener is called two times: once for the main request... when it does its job normally... and again for the "sub request". That second time, because the _controller attribute is already set, RouterListener does nothing.

In fact, let's see this. Before the if, dump($request->attributes->has('_controller')). Then, in your browser, go back to a 404 and try it.

... lines 1 - 42
class RouterListener implements EventSubscriberInterface
{
... lines 45 - 96
public function onKernelRequest(RequestEvent $event)
{
... lines 99 - 102
dump($request->attributes->has('_controller'));
... lines 104 - 142
}
... lines 144 - 175
}

Ah, boo! This hit the die statement in our fake controller. I didn't mean to do that. In UserAgentSubscriber, comment-out our controller hack so we can see the whole process.

... lines 1 - 10
class UserAgentSubscriber implements EventSubscriberInterface
{
... lines 13 - 19
public function onKernelRequest(RequestEvent $event)
{
... lines 22 - 23
/*
$request->attributes->set('_controller', function($slug = null) {
... lines 26 - 28
});
*/
... lines 31 - 33
}
... lines 35 - 41
}

Ok, try it again. Hello 404 page! Hover over the target icon on the web debug toolbar. Yes! 2 dumps from RouterListener: false the first time it's called and, the second time it's called - which is due to the code in ErrorListener - it dumps true because that Request does already have the _controller attribute.

This second request is called a sub-request... but more on that topic later. Remove the dump() call.

Let's see what other ways we can hack into Symfony, like by adding new things that can be used as controller arguments. That's next.

Leave a comment!

0
Login or Register to join the conversation
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial also works well for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-iconv": "*",
        "antishov/doctrine-extensions-bundle": "^1.4", // v1.4.3
        "aws/aws-sdk-php": "^3.87", // 3.133.20
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2.0", // 2.2.3
        "doctrine/doctrine-migrations-bundle": "^1.3|^2.0", // 2.2.2
        "doctrine/orm": "^2.5.11", // 2.8.2
        "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.9.0
        "knplabs/knp-paginator-bundle": "^5.0", // v5.4.2
        "knplabs/knp-snappy-bundle": "^1.6", // v1.7.1
        "knplabs/knp-time-bundle": "^1.8", // v1.16.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.5.0
        "oneup/flysystem-bundle": "^3.0", // 3.7.0
        "php-http/guzzle6-adapter": "^2.0", // v2.0.2
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^5.1", // v5.6.1
        "symfony/asset": "5.0.*", // v5.0.11
        "symfony/console": "5.0.*", // v5.0.11
        "symfony/dotenv": "5.0.*", // v5.0.11
        "symfony/flex": "^1.9", // v1.17.5
        "symfony/form": "5.0.*", // v5.0.11
        "symfony/framework-bundle": "5.0.*", // v5.0.11
        "symfony/mailer": "5.0.*", // v5.0.11
        "symfony/messenger": "5.0.*", // v5.0.11
        "symfony/monolog-bundle": "^3.5", // v3.6.0
        "symfony/property-access": "5.0.*|| 5.1.*", // v5.1.11
        "symfony/property-info": "5.0.*|| 5.1.*", // v5.1.10
        "symfony/routing": "5.1.*", // v5.1.11
        "symfony/security-bundle": "5.0.*", // v5.0.11
        "symfony/sendgrid-mailer": "5.0.*", // v5.0.11
        "symfony/serializer": "5.0.*|| 5.1.*", // v5.1.10
        "symfony/twig-bundle": "5.0.*", // v5.0.11
        "symfony/validator": "5.0.*", // v5.0.11
        "symfony/webpack-encore-bundle": "^1.4", // v1.11.1
        "symfony/yaml": "5.0.*", // v5.0.11
        "twig/cssinliner-extra": "^2.12", // v2.14.3
        "twig/extensions": "^1.5", // v1.5.4
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.0
        "twig/inky-extra": "^2.12", // v2.14.3
        "twig/twig": "^2.12|^3.0" // v2.14.4
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.4.0
        "fakerphp/faker": "^1.13", // v1.13.0
        "symfony/browser-kit": "5.0.*", // v5.0.11
        "symfony/debug-bundle": "5.0.*", // v5.0.11
        "symfony/maker-bundle": "^1.0", // v1.29.1
        "symfony/phpunit-bridge": "5.0.*", // v5.0.11
        "symfony/stopwatch": "^5.1", // v5.1.11
        "symfony/var-dumper": "5.0.*", // v5.0.11
        "symfony/web-profiler-bundle": "^5.0" // v5.0.11
    }
}
userVoice