On using the good old Symfony BaseController

Published on 2019-12-31 • Modified on 2019-12-31

In this post, we will see how to use the Symfony AbstractController that was introduced in Symfony 3.3/4.1. We will review what we used to do before and the evolutions that were done from symfony 1 to Symfony 5, especially how services were declared and used. Eventually, we will try to understand why this new "Base controller" was introduced. Let's go! ๐Ÿ˜Ž

» Published in "A week of Symfony 679" (December 30, 2019 - January 5, 2020).

Back in time, before the abstract controller

First, let's have a look at we used to do before. I left these "old" code snippets untouched even they contain errors. For each section, a small snippet is shown, but you have a link to view to full file (it works with a summary HTML tag). Prepare yourself (and your eyes)! ๐Ÿ˜ฒ

symfony 1 (with a lowercase "s")

This code comes from a coding test I have done for the company I was working for fourteen years ago. The goal was to develop a small application to see if a candidate knew Symfony 1. So, what's inside?
The first shocking thing is that there is no namespace. This "module" stored in actions.class.php was extending the Symfony sfActions class which contained several useful methods like forward404Unless. The "actions" were prefixed by execute. The first parameter was an instance of the sfWebRequest class, which is the equivalent of the Sf4 Request value/object. What is weird is that we were not returning a Response object like we are doing now. We were passing (I guess!) data to the view by creating new properties like $this->form = $this->getForm($this->question); directly in the action! Yes, that was "magic"! Then the view was automatically rendered from the module and action name.

Click here to see the full code.
<?php

declare(strict_types=1);

/**
 * questionnaire actions.
 *
 * @author  Vernet Loรฏc
 */
class QuestionnaireActions extends sfActions
{
    /**
     * Executes index action.
     *
     * @param sfRequest $request A request object
     */
    public function executeIndex(sfWebRequest $request)
    {
        $this->getQuestion($request);
        $this->question = QuestionTable::getInstance()->find($request->getParameter('question_id', 1));
        $this->forward404Unless($this->question);

    /**
     * Executes index action.
     *
     * @param sfRequest $request A request object
     */
    public function executeAnswer(sfWebRequest $request)
    {
        $this->getQuestion($request);
        $this->form = $this->getForm($this->question);
        $this->processForm($request);
    }

    /**
     * Get answer object from current request.
     */
    protected function getQuestion($request)
    {
        $this->question = QuestionTable::getInstance()->find($request->getParameter('question_id', 1));
        $this->forward404Unless($this->question);
    }

    /**
     * Process the form if it was submitted.
     *
     * @param sfWebRequest $request
     */
    protected function processForm($request)
    {
        if ($request->isMethod('post')) {
            $this->form->bind($request->getParameter($this->form->getName()));
            if ($this->form->isValid()) {
                $user_answer = $this->form->save();
                $this->getUser()->addUserAnswer($user_answer);
                $this->getUser()->setFlash('notice', 'Votre rรฉponse a bien รฉtรฉ enregistrรฉe');
                $this->redirect($this->generateUrl('questionnaire', ['question_id' => $this->question->getId()]));
            }
        }
    }

    /**
     * Build the form for the current question.
     */
    protected function getForm($question)
    {
        $user_answer = new UserAnswers();
        $form = new UserAnswersForm($user_answer, ['question' => $this->question]);

        return $form;
    }
}

OMG, this feels so old! But a the time, we (I) considered it good code. When looking at this, you can even understand people regularly making jokes at PHP. At that time, without namespace, without composer, without type hint, it was hard to have robust and professional code. PHP was far from being mature. But, you know, even with such a code, it "did the job" pretty well. Here is an example. Do you like chocolate? This website was developed with symfony 1 in 2007. It has a frontend, an intranet, a full customized admin generator to handle SEO and all the e-commerce stuff. It is still there. ๐Ÿ™‚

Symfony2 (with an uppercase "s")

The service container was a new concept, and it was something totally different from what we used to do with symfony 1. Here is a part of the same application but rewritten with Symfony2:

Click here to see the full code.
<?php

declare(strict_types=1);

namespace SQLTech\QuestBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use SQLTech\QuestBundle\Entity\Answer;
// Entities
use SQLTech\QuestBundle\Entity\Question;
use SQLTech\QuestBundle\Entity\UserAnswer;
use SQLTech\QuestBundle\Form\UserAnswerType;
// Forms
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class DefaultController extends Controller
{
    /**
     * @Route("/", name="home")
     *
     * @Template()
     */
    public function homeAction()
    {
        $questions = $this->getDoctrine()
            ->getRepository('SQLTechQuestBundle:Question')
            ->findAll()
        ;

        // Test if we must load fixtures
        if (0 === (int) \count($questions)) {
            $this->loadFixtures();
            // Reload questions
            $questions = $this->getDoctrine()
                ->getRepository('SQLTechQuestBundle:Question')
                ->findAll()
            ;
        }

        return ['question_id' => $questions[0]->getId()];
    }

    /**
     * Get the current question Doctrine object we are working with.
     *
     * @return SQLTech\QuestBundle\Entity\Question
     */
    protected function getQuestion()
    {
        $questionId = $this->getRequest()->get('question_id');
        $question = $this->getDoctrine()->getRepository('SQLTechQuestBundle:Question')->find($questionId);

        return $question;
    }
    /**
     * @Route("/my-questionnaire", name="questionnaire")
     *
     * @Template()
     */
    public function indexAction()
    {
        $request = $this->getRequest();
        $question = $this->getQuestion();

        if (!($question instanceof Question)) {
            return $this->forward('SQLTechQuestBundle:Default:error');
        }

        // Create the form
        $type = new UserAnswerType($question);
        $userAnswer = new UserAnswer();
        $form = $this->createForm($type, $userAnswer);

        if ($request->getMethod() == 'POST') {
            $form->bindRequest($request);

            if ($form->isValid()) {
                // Save in database
                $em = $this->getDoctrine()->getEntityManager();
                $em->persist($userAnswer);
                $em->flush();

                // Add the answer in the session
                $request->getSession()->addAnswer($userAnswer);

                // Confirmation message
                $request->getSession()->setNotice('Congratulations ! Your answer was correctly saved both in session and database.');

                // perform some action, such as saving the task to the database
                return $this->redirect($this->generateUrl('questionnaire', ['question_id' => $question->getId()]));
            }
        }

        return [
            'form' => $form->createView(),
            'question' => $question,
            'answers' => $this->getRequest()->getSession()->getAnswers(),
        ];
    }

    /**
     * Load some fixtures in the database.
     */
    protected function loadFixtures()
    {
        $question = new Question();
        $question->setTitle('Comment รงa va ?');
        $answers = ['Oui', 'Non', 'Pas vraiment'];

        $em = $this->getDoctrine()->getEntityManager();
        $em->persist($question);

        foreach ($answers as $answerTitle) {
            $answer = new Answer();
            $answer->setTitle($answerTitle);
            $answer->setQuestion($question);
            $em->persist($answer);
        }

        $em->flush();
    }

    /**
     * Generic error template.
     *
     * @Route("/erreur", name="error")
     *
     * @Template()
     */
    public function errorAction()
    {
        return [];
    }
}

Finally, Symfony uses namespaces. This file is a standard controller, and it extends the Symfony\Bundle\FrameworkBundle\Controller\Controller. Yes, as you can see this "BaseController" is very old, and it's always in the Symfony 4.4 codebase, but it's deprecated as we will see later. What can we see in this controller?

  • The request is not injected in the action but is retrieved with getRequest() which was in fact only a shortcut for $this->container->get('request');. This method was available at the time but was removed later.
  • The @template annotation is used. As we were used to having this kind of magic with symfony 1, it was OK also to use it in Symfony2. Even if you can still use this annotation now, it's not considered as a best practice.
  • There is a protected method loadFixtures, of course now, you would create a service or a command for this as it's a terrible practice to add extra code inside controllers that can't be easily tested or reused.
  • There is an errorAction; it was used to render an error page when a question wasn't found (with a forward).

It was the very first small application I did with Symfony2. So, pardon me, as you can see, it's far from being perfect! For example, I should have used the createNotFoundException instead of creating a custom error action. Also, no custom service was created.

Symfony 2.1 -> 2.7

Not directly related to these versions, but it is something it was still encountered in these projects as PHP 5.5 wasn't the norm yet. This practice is to define and to get a public service with an arbitrary identifier, like this:

public function showAction(Request $r, string $id, string $_route): Response
{
    $userSerie = $r->cookies->get(RequestHelper::SERIE_COOKIE);
    $cacheKey = $this->get('app.cache.manager')->getArticleCacheKey($id, self::PAGE, $userSerie);
    // ...
}

In this snippet, we are getting a service with the app.cache.manager key. The problem is the naming strategy for this service identifier. Should we use underscores? Dots? Should we use an application prefix? There wasn't a clean standard, and you could end with very different things. That was frustrating. This kind of service was declared like this:

app.cache.manager:
    class: 'AppBundle\Cache\CacheManager'
    arguments: ['@snc_redis.default', '@request_stack', '@app.twig.text.extension']

With the introduction of the class name constant in PHP 5.5, finally, we had something we could use as a standard, the FQCN! (Full Qualified Class Name). The code looked much cleaner as we get rid of those arbitrary services identifiers:

use AppBundle\Cache\CacheManager;

public function showAction(Request $r, string $id, string $_route): Response
{
    $userSerie = $r->cookies->get(RequestHelper::SERIE_COOKIE);
    $cacheKey = $this->get(CacheManager::class)->getArticleCacheKey($id, self::PAGE, $userSerie);
    // ...
}
AppBundle\Cache\CacheManager:
    class: 'AppBundle\Cache\CacheManager'
    arguments: ['@snc_redis.default', '@request_stack', '@app.twig.text.extension']

But we still had to declare the service manually. Something looks weird in this declaration, no? The class is repeated two times. And when coding, repeating code is never a good thing. The FQCN was one of the prerequisite to have autowiring finally.

Symfony 2.8: autowiring introduction

Wow! Autowiring is not new. I didn't remember, but it was introduced quite a long time ago in Symfony 2.8 (2015). That's funny to read comments and to see that people were septical at first (I was too!). Is it for prototyping? Why would you reintroduce magic?. You still had to declare the services, but you could use activate the autowire parameter, much more comfortable.

AppBundle\Cache\CacheManager:
    class: AppBundle\Cache\CacheManager
    autowire: true

That's better, but our class name is still duplicated, can't we avoid this?

Symfony 3.3: service autoconfiguration

Symfony 3.3 introduces several new interesting features about services. Simpler service configuration and Service Autoconfiguration: With these new enhancements, finally we can simply avoid declaring our service. We can delete the definition in the services.yaml and inject the service directly as a method parameter.

use AppBundle\Cache\CacheManager;

public function showAction(Request $r, string $id, string $_route, CacheManager $cacheManager): Response
{
    $userSerie = $r->cookies->get(RequestHelper::SERIE_COOKIE);
    $cacheKey = $cacheManager->getArticleCacheKey($id, self::PAGE, $userSerie);
    // ...
}

That's it, our service definition with the duplicated class name is gone. We are taking advantage of autowiring, the services.yaml stays short and readable. You only define the configuration for non-trivial cases.

Autoconfiguration allows injecting tags automatically. For example, if one of your services extends Symfony\Component\Console\Command\Command, it will receive the console.command tag and be registered as a Symfony command.

Symfony 4.1 and above

Because the base controller was too permissive. The new AbstractController was introduced in Symfony 3.3/4.1. It provides the same features as we used to do before except than it prevents services to be retrieved from anywhere. Services should now be private and must be injected with the help of dependency injection in constructors or action method parameters. So let's have a look at a controller of this blog:

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

/**
 * App generic actions and locales root handling.
 */
final class AppController extends AbstractController
{
    public function __construct(
        public readonly ArticleRepository $articleRepository,
    ) {
    }

    #[Route(path: '/', name: 'root')]
    public function root(Request $request): Response
    {
        $locale = $request->getPreferredLanguage($this->getParameter('activated_locales'));

        return $this->redirectToRoute('homepage', ['_locale' => $locale]);
    }

    // L1+27 used as a live snippet in templates/blog/posts/_21.html.twig

    /**
     * @param array<string,int> $goals
     */
    #[Route(path: ['en' => '/en', 'fr' => '/fr'], name: 'homepage')]
    public function homepage(string $_locale, array $goals, EntityManagerInterface $entityManager): Response
    {
        $data = [];
        $date = new \DateTime();
        $data['goals'] = $goals;
        $data['article'] = $this->articleRepository->findLastArticleForLang($_locale);
        $data['snippet'] = $this->articleRepository->findLastSnippetForLang($_locale);
        $data['done'] = $this->articleRepository->getDoneGoals();
        $data['year'] = (int) $date->format('Y');
        $data['year_day'] = (int) $date->format('z') + 1;
        $data['year_percent'] = $data['year_day'] / 365 * 100;
        $data['week_number'] = (int) $date->format('W');

        return $this->render('app/homepage.html.twig', $data);
  • The controller extends the Symfony abstract controller.
  • The root action uses two functions provided by the abstract controller getParameter() and redirectToRoute().
  • In the homepage action, the ArticleRepository service is injected via a method parameter.
  • There is also a goal parameter that is injected. To be able to inject this parameter, I have defined a named parameter in the services.yaml (because we don't need the full parameter bag).
        bind:
            string $appServer: '%app_server%'
            string $appEnv: '%kernel.environment%'
            string $appVersion: '%app_version%'
            array $activatedLocales: '%activated_locales%'
            array $goals: '%goals%'
            bool $emptySnippets: '%empty_snippets%'

Note that there is "code" in this controller. It's not a good practice. I am keeping the code like this for the example. But ideally, I should create a HomepageData service where I would inject the article repository and the goals parameter. Therefore, the main controller would only require this new HomePage data service.

What about Symfony 5?

Symfony 5 is out since the Amsterdam SymfonyCon where Fabpot released it live during the keynote, that was crazy. But it worked. So, what are the new "best practices" regarding the controller? Well, let's cheat and have a look at:

Symfony5: The Fast Track

Page 53, we have a chapter about controllers: Step 6: Creating a controller. So what's new? Nothing. Remember that Symfony 5 is "just" Symfony 4.4 with all the deprecated code removed. So, it's not astonishing that fabpot advises us to create a controller with the Maker bundle. And when using this command, the newly created class will extend the Symfony AbstractController. Here is what is generated when running the php bin/console make:controller command:

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;

class TinyElephantController extends AbstractController
{
    /**
     * @Route("/tiny/elephant", name="tiny_elephant")
     */
    public function index()
    {
        return $this->render('tiny_elephant/index.html.twig', [
            'controller_name' => 'TinyElephantController',
        ]);
    }
}

The generated code is OK but let's improve it:

  • Add the strict_types PHP declaration to avoid automatic types conversion problems.
  • Mark the class as final: we won't extend it.
  • Add Response as the return type hint to the index function.

Here is the new class. I left the route; you can access it at this URL (however the links won't work).

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class TinyElephantController extends AbstractController
{
    #[Route('/tiny/elephant', name: 'tiny_elephant')]
    public function index(): Response
    {
        return $this->render('tiny_elephant/index.html.twig', [
            'controller_name' => 'TinyElephantController',
        ]);
    }
}

Conclusion

As you can see, the code evolves. And it evolves fast. What looked to be a best practice ten years ago can seem ridiculous now. But, that's OK, embrace change, test things, make errors and learn from it! Let's finish with an inspiring quote:

โ€œSuccess is not final; failure is not fatal: it is the courage to continue that counts. โ€
- Winston Churchill

That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. ๐Ÿ˜Š

  Read the doc

They gave feedback and helped me to fix errors and typos in this article; many thanks to Laurent Q. ๐Ÿ‘

  Work with me!


Call to action

Did you like this post? You can help me back in several ways: (use the Tweet on the right to comment or to contact me )

Thank you for reading! And see you soon on Strangebuzz! ๐Ÿ˜‰

COil