Symfony : why I stopped using the AbstractController

Symfony : why I stopped using the AbstractController
Photo by Ben Griffiths / Unsplash

A typical Controller in Symfony may look like this :

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class HelloController extends AbstractController
{
    #[Route(
        path: '/hello/{name}',
        name: 'app_hello',
        requirements: ['name' => '[a-zA-Z-]+'],
        methods: ['GET']
    )]
    public function index(string $name = 'Adrien'): Response
    {
        $form = $this->createForm(SomeFormType::class);
        
        return $this->render('hello.html.twig', [
            'name' => $name, 
            'form' => $form,
        ]);
    }
}

It is perfectly fine of course but there are a few things we can improve.

What are the dependencies ?

By calling $this->render(...), it hides what are the dependencies. Indeed we did not provide anything through the usual ways : __construct, setter, properties (See documentation)

So what are we using and how do I have access to it ?

Taking a look at the AbstractController::render(...) method here we can see it is using the twig service. But it is not declared as a dependency so how does it work ?

It is another feature called "Service Subscriber", which AFAIK is only used by controllers. Basically it is a "Service Locator" where the class defines itself which are its dependencies.

Besides twig there are (ATM) 9 additional dependencies declared this way which are (source) :

public static function getSubscribedServices(): array
{
    return [
        'router' => '?'.RouterInterface::class,
        'request_stack' => '?'.RequestStack::class,
        'http_kernel' => '?'.HttpKernelInterface::class,
        'serializer' => '?'.SerializerInterface::class,
        'security.authorization_checker' => '?'.AuthorizationCheckerInterface::class,
        'twig' => '?'.Environment::class,
        'form.factory' => '?'.FormFactoryInterface::class,
        'security.token_storage' => '?'.TokenStorageInterface::class,
        'security.csrf.token_manager' => '?'.CsrfTokenManagerInterface::class,
        'parameter_bag' => '?'.ContainerBagInterface::class,
    ];
}

It makes the dependencies very blurry IMO as we have to dig into the class to discover what is already accessible from the $this->container->get(...).

How does the Request $request work ?

My argument here could seem a bit off, but I found that a lot of symfony developers doesn't know anymore that to be able to reference the Request class within a controller method, you need your controller class to be tagged with controller.service_arguments.

Part of why I think people don't know that is because by extending the AbstractController + using autoconfigure: true it will automatically add it. I know it is supposed to help and it does. But if you understand that by leveling up your team on symfony core functionnalities, they will become more and more productive then they might miss this.

Same argument could be applied to any service that is "autoconfigured" but

  1. In general it refers to an Interface instead of an Abstract class
  2. It doesn't alter your class with unwanted / unneeded dependencies.

It hides what it really does

⚡️ Quizz : what changed in the render method in 6.2 ?
➡️➡️➡️ The way Form is handled.

Here is the explanation : normally if you need to pass on a form from a controller to a template (twig), then you learned to do it this way (prior to 6.2) :

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class HelloController extends AbstractController
{
    #[Route(
        path: '/hello/{name}',
        name: 'app_hello',
        requirements: ['name' => '[a-zA-Z-]+'],
        methods: ['GET']
    )]
    public function index(string $name = 'Adrien'): Response
    {
        $form = $this->createForm(SomeFormType::class);
        
        return $this->render('hello.html.twig', [
            'name' => $name, 
            'form' => $form->createView(),
        ]);
    }
}

now you can change it like so :

-            'form' => $form->createView(),
+            'form' => $form,

Easier right ? But is it because twig changed the way it handles forms ? Not at all ! Taking a look at this code (source)

foreach ($parameters as $k => $v) {
    if ($v instanceof FormInterface) {
        $parameters[$k] = $v->createView();
    }
}

from the AbstractController it clearly shows that it iterates over all the parameters you gave to do it for you. Meaning that if you go from this AbstractController to a lighter one like I'll show later, then you have to change this yourself otherwise it won't work.

It may break your application

Maybe you already have built some simple HTTP API using return $this->json(...) ? But how does it work ? Take a look at the code (source)

protected function json(
    mixed $data,
    int $status = 200,
    array $headers = [],
    array $context = []
): JsonResponse {
    if ($this->container->has('serializer')) {
        $json = $this->container->get('serializer')->serialize($data, 'json', array_merge([
            'json_encode_options' => JsonResponse::DEFAULT_ENCODING_OPTIONS,
        ], $context));

        return new JsonResponse($json, $status, $headers, true);
    }

    return new JsonResponse($data, $status, $headers);
}

The issue is that if you didn't had the Serializer component installed then the encoding of your data will be handled by the JsonResponse class which does json_encode. But at the moment you install the Seralizer component then the behaviour changes and the output may differ as well.

What is the alternative ?

Here is what I propose :

-use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\Form\FormFactoryInterface;
+use Twig\Environment;

-class HelloController extends AbstractController
+class HelloController
{
+    public function __construct(
+        private readonly Environment $twig,
+        private readonly FormFactoryInterface $formFactory,
+    ) {
+    }

    #[Route(
        path: '/hello/{name}',
        name: 'app_hello',
        requirements: ['name' => '[a-zA-Z-]+'],
        methods: ['GET']
    )]
    public function index(string $name = 'Adrien'): Response
    {
-        $form = $this->createForm(SomeFormType::class);
+        $form = $this->formFactory->create(SomeFormType::class);
        
-        return $this->render('hello.html.twig', [
+        return $this->twig->render('hello.html.twig', [
            'name' => $name, 
-            'form' => $form,
+            'form' => $form->createView(),
        ]);
    }
}

and of course don't forget to update your services.yaml configuration accordingly :

services:
    _defaults:
        autowire: true
        autoconfigure: true
        
    App\Controller\:
-        resource: '../src/Controller/'
+        resource: '../src/Controller/**/*Controller.php'
+        tags: [{ name: 'controller.service_arguments' }]

I don't think this alternative is more complicated than what symfony propose. Of course this was a very simple case. I recommend to always have a look at the AbstractController because there are also very useful tips like this one which sets the 422 status code if a form has been submitted but is not valid.


Conclusion

There is nothing wrong using the AbstractController if you are building a very simple app that doesn't require much maintenance.
But if you are building something that requires improved readability (== improved maintenance), you should consider moving away from the AbstractController to have explicit configurations.

Mastodon