Standard Symfony Architecture

If you're working with Symfony, you'll likely be using the standard Controllers / Service Layer / Twig Template pattern (not to be confused with 'MVC' which for all intents and purposes does not exist in PHP, but that discussion is beyond the scope of this article).

Being able to place controllers in other places than the Controllers/ directory is one of the first things I was looking for when I started using Symfony 5, but having this working automatically, with automatic dependency injection out of the box, isn't a simple as it should be.

I'm a big proponent of functional cohesion over logical cohesion:

In computer programming, cohesion refers to the degree to which the elements inside a module belong together. In one sense, it is a measure of the strength of relationship between the methods and data of a class and some unifying purpose or concept served by that class.

As a user of Symfony, I should be able to get working with the framework as fast as possible and place my files where I like with as little configuration as possible as well.

For the record, I believe Symfony went completely the wrong direction with specifying "controllers as services". "Services" are something completely unrelated to Controllers and in fact what we actually want is "controllers that have automatic dependency injection capabilities".

The very second that I add a new route to routes.yaml, Symfony should recognise that the controller: value I'm providing is, in fact, a controller (weirdly enough?), and that it needs to work with automatic dependency injection right out of the box. But it doesn't work like that.

Functional vs Logical Cohesion

By default, controllers in Symfony must be placed in one directory called Controllers/, or otherwise they have to be manually specified as services in services.yaml with the controller.service_arguments tag:

But what if I want functional cohesion instead of logical cohesion?

Logical Cohesion
With logical cohesion, it's just a bunch of different functionality all thrown together and the developer will have to open files or read a README to understand what the application can do at a high level.

Screenshot-2020-05-31-at-16.08.23
With functional cohesion on the other hand, I can immediately see as a developer the different use-cases this application offers. There's not much room for ambiguity given good naming.

Note how the different 'things' we can do such as saving some News and Fetching the latest news are individual 'action' controllers here. Functional cohesion works really well with Action-Domain-Responder, which basically means one class per controller, per thing you are doing, using __invoke().

To achieve this automatically in Symfony, there's a bit of wizardry required.

The easy 'hack'

Others have already figured out that you can automatically register controllers by having them implement an empty interface, and simply listing this as a requirement to be considered as a service:

This is, effectively, a marker interface, also widely considered an anti-pattern. And why wouldn't it be if it doesn't itself provide any functionality it's only reason for existence is a shortcut to something that should happen out of the box, or elsewhere, anyway.

Compiler Passes

Compiler passes give you an opportunity to manipulate other service definitions that have been registered with the service container.

Looking in the Symfony source code, there's a place which registers 'controllers as services' for you, but there's a check in there:

This is the place in Symfony where the code performs a check for the controller.service_arguments tag, and if it exists, to register the controller as a service. Elsewhere in the codebase, in the framework bundle, this is set with a priority of zero, the default priority for compiler passes, so we need to make sure that our compiler pass has a higher priority.

Let's create our own compiler pass that on boot, when it encounters a file with Controller in the name, adds the above tag required (and also makes it 'Public', which is another thing Symfony added which makes no sense). Note, you can also make it look for files that END in Controller, or any other criteria you like.

ControllersAsServices.php

Then load that with a -1 priority in Kernel.php by overriding the build() protected function made available to you with your new ControllersAsServices compiler pass:

Kernel.php

Then clear your dev cache and refresh, and any Controller in any location within your codebase will automatically be registered as a service and available to use without any other configuration or changes to config.yaml.

Drawbacks

Right now, this is going to consider any file with "Controller" in the name and then add that tag. I don't know what the consequences of that are but there are probably some to consider.

The solution could probably look for "Controller" at the end of the class name instead of anywhere in the name but that might be a little slow if you have a lot of controllers and you would likely need to implement an efficient check to avoid slowing down the application. You can wait for PHP 8 for this RFC to provide non-userland str_ends_with().

Conclusion

If you want to be able to place controllers anywhere ala functional cohesion, without extra additional configuration and an unecessary duplication between the controller and config lines, a compiler pass is a good method to achieve this.