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 the dependencies are. Indeed, we did not provide anything through the usual ways: __construct, a setter, or 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 itself defines what its dependencies are.

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’ve found that a lot of Symfony developers no longer know 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 the reason people don’t know that is because extending AbstractController with autoconfigure: true automatically adds it for you. I know it is supposed to help, and it does. But if you’re leveling up your team on Symfony’s core functionality so they become more and more productive, they might miss this.

The 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

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

Here is the explanation: normally, if you need to pass a form from a controller to a template (twig), you would have 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 AbstractController, it clearly shows that it iterates over all the parameters you gave it to do this for you. This means that if you move from this AbstractController to a lighter one, like I’ll show later, you have to change this yourself, otherwise it won’t work.

It may break your application

Maybe you’ve already 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 have the Serializer component installed, the encoding of your data would be handled by the JsonResponse class, which does json_encode. But as soon as you install the Serializer component, 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 proposes. Of course, this was a very simple case. I recommend always taking a look at 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 with using 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.