If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeIn a Symfony app, this $controller
variable is the string format that comes from the router - something like App\Controller\ArticleController::homepage
. This function - the getController()
method of the ControllerResolver
- has one simple job: it needs to transform that string into a PHP callable. To do that, it calls createController()
.
Let's scroll down to find this method. Here it is: protected function createController()
with a string $controller
argument. The first thing it does is check to see if the controller does not have ::
in the middle. If it does not contain ::
, the controller is actually an invokable class. This is a strategy for controllers that some people in the Symfony world are using - it's especially popular in ApiPlatform. The idea is that each controller class has only one controller method - called __invoke()
. When a class has an __invoke()
method, objects of that class are invokable: you can execute the object like a function. Anyways, if you use invokable controllers, then your $controller
string is just the class name: no method name is needed.
How Symfony handles invokable controllers is actually pretty similar to how it will handle our situation: we'll see this instantiateController()
method in a moment.
Because our controller does have a ::
in the middle, it explodes the two parts: everything before the ::
is assigned to a $class
variable and everything after is set to a $method
variable. Then, inside the try-catch, it puts this into a callable syntax: an array where the 0
index is an object and 1
index is the string method name. I know, PHP is weird: but this type of syntax is callable.
Of course, on this line, $class
is still just a string. To instantiate our controller, it calls - surprise - instantiateController()
!
This method is overridden in the child class. Go over to ContainerControllerResolver
and find instantiateController()
. Awesome! It checks to see if the class is in the container. And if it is, it doesn't instantiate the controller itself: it fetches it from the container and returns it.
This is what's happening in our case: our controller is a service. In fact, pretty much everything in the src/
directory is a service... or at least, is eligible to be a service - we'll go deeper into that in the next deep-dive tutorial. That's thanks to the config/services.yaml
file. This section auto-registers everything in the src/Controller
directory as a service.
So... our controller is a service... and ContainerControllerResolver
fetches it from the container. But this only works because the class name of our controller matches its service id. What I mean is: there is a service in the container whose id
is literally App\Controller\ArticleController
.
This is teamwork in action! The annotation route automatically set the controller string to the class name... and because that's also the id of the service in the container, we can fetch it out without any extra config.
So the truth is, your controller syntax isn't really ClassName::methodName
. It's ServiceId::methodName
. If your controller service had a different id for some reason, that's ok! In that case, you would set your controller to your service id ::
then method name in YAML. There's also a way to do this in annotations.
Fetching your controller from the container also works because controller services are public. Really, they're the only services that we routinely make public. If you look back at services.yaml
, it's not immediately obvious why they're public - I don't see a public: true
anywhere. I'll save the details for the next deep-dive tutorial, but the controller services are public thanks to this tag. One of the things it does is make all of the services public so that the ContainerControllerResolver
can fetch them directly.
If, for some reason, your controller is not registered as a service, then it calls parent::instantiateController()
, which... could not be simpler. It says new $class()
and passes it no arguments. That's basically legacy at this point: it's how controllers we created prior to Symfony 4.
Scroll back up in ControllerResolver
to getController()
. This is all a long way of saying that our controller string - this App\Controller\ArticleController::homepage
- is split into two pieces, the service is fetched from the container, and it's returned from here in a callable format.
Close both of the controller resolver classes and head back to HttpKernel
. Let's see what this final $controller looks like. After the if
, dd($controller)
.
... lines 1 - 11 | |
namespace Symfony\Component\HttpKernel; | |
... lines 13 - 39 | |
class HttpKernel implements HttpKernelInterface, TerminableInterface | |
{ | |
... lines 42 - 114 | |
private function handleRaw(Request $request, int $type = self::MASTER_REQUEST): Response | |
{ | |
... lines 117 - 130 | |
dd($controller); | |
... lines 132 - 167 | |
} | |
... lines 169 - 282 | |
} |
Ok, move over... and refresh. That's it! The weird PHP callable syntax: an array where the 0
index is an ArticleController
object, and the 1
index is the string homepage
.
Go ahead and remove that dd()
. So... this is beautiful. Our controller is a boring service object: there's nothing special about it at all. Need to use a service like the logger? No problem! In ArticleController
, add another argument to the constructor: LoggerInterface $logger
. I'll hit Alt + Enter and go to "Initialize Fields" to create that property and set it. To prove it's working, let's say $this->logger->info('Controller instantiated!')
.
... lines 1 - 8 | |
use Psr\Log\LoggerInterface; | |
... lines 10 - 13 | |
class ArticleController extends AbstractController | |
{ | |
... lines 16 - 19 | |
private $logger; | |
... line 21 | |
public function __construct(bool $isDebug, LoggerInterface $logger) | |
{ | |
... line 24 | |
$this->logger = $logger; | |
$this->logger->info('Controller instantiated!'); | |
} | |
... lines 29 - 74 | |
} |
Move over, refresh, click a link to open the profiler and go to the Logs section. Cool. The first log is from our listener to kernel.request
, then our controller is instantiated and then it's executed.
So yea! Our controller is a boring service. Well, it does have that superpower where you can autowire services into controller methods - but we'll learn how that works in a few minutes.
I do have one more question, though. The controller is full of shortcut methods like $this->render()
. How does that work? We never injected the twig
service... so how is our "boring, normal service" using something that we didn't inject? How is it getting the twig
service?
Let's dig into that mystery next!
"Houston: no signs of life"
Start the conversation!
// 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
}
}