À propos du bon vieux contrôleur de base Symfony

Publié le 31/12/2019 • Actualisé le 31/12/2019

Dans cet article, nous allons voir comment utiliser le contrôleur abstrait Symfony qui a été introduit dans Symfony 3.3 / 4.1. Nous allons passer en revue ce qui ce faisait par le passé et les évolutions apportées depuis symfony 1 à Symfony 5, spécialement sur la manière dont sont déclarés les services et comment ils sont utilisés. Finalement, nous essaierons de comprendre pourquoi ce nouveau contrôleur abstrait a été introduit. C'est parti ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧 Close

» Publié dans "Une semaine Symfonique 679" (du 30 décembre 2019 au 5 janvier 2020).

Retour en arrière, avant le contrôleur abstrait...

Tout d'abord. Jetons un coup d'œil à ce que nous faisions par le passé. J'ai laissé ces vieux bouts de code intacts même s'ils contiennent des erreurs. Pour les deux premières sections, un snippet succinct sera présenté et vous pourrez cliquer dessus pour voir le fichier en entier. Préparez-vous, ça peut piquer les yeux ! 😲

symfony 1 (avec un "s" minuscule)

Ce code provient provient d'un exercice que j'avais fait pour l'entreprise pour laquelle je travaillais à l'époque. Le but était de développer une petite application afin de vérifier si un candidat connaissait effectivement Symfony. Donc, qu'y a t-il dedans ? (j'ai uniquement mis le code du contrôleur principal).
La première chose choquante est qu'il n'y a pas d'espace de nom. Ce "module" était dans un fichier actions.class.php qui étend la classe Symfony sfActions contenant quelque méthodes utiles comme forward404Unless. Les "actions" étaient préfixées par execute. Le premier paramètre était une instance de la classe sfWebRequest équivalente à l'objet valeur Request que l'on peut trouver dans Sf4. Ce qui était bizarre c'est qu'on passait des données au template (je crois !) en créant directement de nouvelles propriétés comme $this->form = $this->getForm($this->question); directement dans l'action ! Oui, c'était "magique" ! Et la vue était automatiquement rendue à partir des noms de module et d'action.

Cliquez ici pour voir le code complet.
<?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;
    }
}

Mon dieux, ça semble si vieux ! L'époque, nous (je) le considérions comme du code correct. Quand on regarde ça, on peut même comprendre les blagues récurrentes à propos de PHP. A l'époque, sans espace de nom, sans composer, sans typage, c'était difficile d'avoir du code robuste et professionnel. PHP était loin d'être mature. Quoi qu'il en soit, il faisait quand même le "taf" plutôt bien. Est-ce que vous aimez le chocolat ? Ce site a été développé avec symfony 1 en 2007. Il y a une partie publique, un intranet, une interface d'administration personnalisée (à partir de l'admin generator) pour gérer tout le SEO et la partie e-commerce. Il est toujours là. 🙂

Symfony2 (avec un "s" majuscule)

Le conteneur de services était un nouveau concept, totalement différent de ce que nous avions l'habitude de faire avec symfony 1. Voici un contrôleur pour la même application mais ré-écrite avec Symfony2 :

Cliquez ici pour voir le code complet.
<?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 [];
    }
}

Enfin, Symfony utilise les espaces de nom. Ce fichier est un contrôleur standard qui étend la classe Symfony Symfony\Bundle\FrameworkBundle\Controller\Controller. Comme vous pouvez le voir, ce contrôleur de base Symfony existe depuis très longtemps et il est toujours présent dans Symfony 4.4 même s'il a été déprécié comme nous le verrons par la suite. Que pouvons-nous remarquer dans ce contrôleur ?

  • La requête n'est pas injectée dans l'action mais est est récupérée avec la fonction getRequest() qui est en fait un raccourci pour $this->container->get('request');. Cette méthode était disponible à l'époque mais a été supprimée plus tard.
  • L'annotation @template est utilisée. Comme nous étions habitués à avoir ce genre de magie avec Symfony 1, ce n'était pas choquant d'utiliser ceci dans Symfony2. Même si on peut toujours utiliser cette annotation à l'heure actuelle, ce n'est plus considéré comme une bonne pratique.
  • Il y a une méthode protégée loadFixtures, bien sûr, maintenant, on créerait un service ou une commande pour cela car c'est une très mauvaise pratique d'ajouter du code ne pouvant pas être testé ou réutilisé dans des contrôleurs.
  • Il y a une action errorAction, elle était utilisée pour afficher une page d'erreur si une question n'était pas trouvée.

C'était la toute première petite application que j'ai développé avec Symfony. Donc, excusez-moi si elle n'est pas parfaite ! Par exemple, j'aurais du utiliser la fonction createNotFoundException pour lever une erreur 404 au lieu de créer une action pour cela. De plus, aucun service n'a été ajouté.

Symfony 2.1 -> 2.7

Ce n'est pas directement liée à ces versions, mais quelque chose que l'on rencontre sur certains de ces projets qui n'utilisaient pas encore PHP 5.5. Cette pratique est de définir et d'accéder à un service avec un identifiant arbitraire. Comme cela :

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);
    // ...
}

Dans ce snippet, on récupère un service avec la clé app.cache.manager. Le problème est cette stratégie de nommage. Doit-on utiliser un underscore ? Des points ? Est-ce que ça doit être préfixé par quelque chose ? Il n'y avait pas de standard clair et on pouvait trouver des choses différentes au sein d'un même projet. C'était frustrant. Ce type de service était déclaré comme ceci :

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

Avec l'introduction des constantes de classe en PHP 5.5, enfin, nous avions quelque chose que nous pouvions utiliser comme un standard à savoir la FQCN (nom de classe pleinement qualifié). Le code est plus clair puisque qu'on fait fi de ces identifiants arbitraires.

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']

Mais nous avions toujours à déclarer le service manuellement. Quelque chose semble bizarre dans cette déclaration, non ? Le nom de classe est répété à deux reprises. Et quand on code, répéter des choses n'est jamais une bonne chose. Ces noms de classe pleinement qualifiés ont été l'un des prérequis pour pouvoir disposer de l'autowiring.

Symfony 2.8 : introduction de l'autowiring

Waouh ! L'autowiring n'est pas nouveau. Je ne m'en souvenais pas, mais il a été introduit il y a un bon moment dans Symfony 2.8 (2015). C'est marrant de lire les commentaires de l'article de présentation et des voir comment les gens ont été septiques au début (je l'étais aussi !). Est-ce pour du prototypage ? Pourquoi ré-introduire de la magie ? On avait toujours à déclarer les services mais on pouvait utiliser le paramètre autowire, bien plus simple.

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

C'est mieux mais notre nom de classe est toujours dupliqué, ne pouvons nous pas éviter ceci ?

Symfony 3.3 : auto-configuration des services

Symfony 3.3 introduit plusieurs fonctionnalités intéressantes concernant les services. Configuration des services simplifiée et auto-configuration des services : Avec ces deux améliorations, enfin nous pouvons tout simplement omettre de déclarer notre service. On peut supprimer la définition correspondante dans le fichier services.yaml et injecter le service directement comme paramètre de méthode. Le code de notre contrôleur peut maintenant être écrit comme ceci :

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);
    // ...
}

Et voilà, notre définition de service avec le nom de classe a disparu. Profiter de l'auto-wiring nous permet de garder le fichier services.yaml succinct et lisible. On ne définit des services que pour des cas non triviaux.

L'auto-configuration permet aussi l'injection automatique de tags. Ainsi, si un de vos services étend Symfony\Component\Console\Command\Command, il recevra la tag console.command et sera donc enregistré en tant que commande Symfony.

Symfony 4.1 et au delà

Parce que le contrôleur de base était trop permissif. Un nouveau contrôleur abstrait a été introduit dans Symfony 3.3 / 4.1. Il fournit les même fonctionnalités qu'auparavant à part qu'il empêche de récupérer des services à la volée à partir de n'importe ou. Les services doivent être privés et être injectés grâce à l'injection de dépendance dans les constructeurs ou dans les méthodes des actions. Jetons un coup d'œil à l'un des contrôleurs de ce blog, quelques explications :

<?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);
  • Il étend le contrôleur Symfony abstrait.
  • L'action root utilise deux fonctions qui sont fournies par cette classe : getParameter() et redirectToRoute().
  • Dans l'action homepage, le service ArticleRepository est injecté via un paramètre de méthode.
  • Il y a aussi un paramètre goal qui est injecté. Pour ce faire, il faut définir un "paramètre nommé" dans le fichier services.yaml. (on a pas besoin de tous les paramètres).
        bind:
            string $appServer: '%app_server%'
            string $appEnv: '%kernel.environment%'
            string $appVersion: '%app_version%'
            array $activatedLocales: '%activated_locales%'
            array $goals: '%goals%'
            bool $emptySnippets: '%empty_snippets%'

Veuillez noter qu'il y du "code" dans ce contrôleur, c'est maaaaaaal ! Ce n'est pas une bonne pratique. Je garde le code comme ceci pour l'exemple (pour l'instant). Idéalement, je devrais créer un service HomepageData dans lequel j'injecterai le dépôt Doctrine article ainsi que le paramètre goals. Ainsi, le contrôleur principal n'aurait besoin de récupérer que ce nouveau service HomePageData.

Quid de Symfony 5 ?

Symfony est sorti depuis la SymfonyCon d'Amsterdam ou Fabpot l'a publiée en live pendant la note d'ouverture. C'était fou, mais tout à bien fonctionné. Donc, quelles sont les nouvelles meilleures pratiques concernant les contrôleurs ? Et bien, si nous trichions et regardions ce que nous propose...

Symfony5: The Fast Track

Page 53, nous avons un chapitre à propos des contrôleurs. Étape 6: Créer un contrôleur : Quoi de neuf docteur ?? 🐰 Rien. Rappelez-vous que Symfony 5 est "juste" un Symfony 4.4 auquel on a enlevé tout le code déprécié. Donc, ce n'est pas étonnant que fabpot nous conseille d'utiliser le bundle Maker. Quand on utilise la commande relative, le nouveau fichier étend le contrôleur abstrait Symfony. Voici ce qui est généré quand on lance la commande php bin/console make:controller :

<?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',
        ]);
    }
}

Le code généré est correct mais améliorons le un peu :

  • Ajoutez la déclaration PHP strict_types afin d'éviter les conversions automatiques de types.
  • Marquez la classe comme finale : nous n'allons pas l'étendre.
  • Ajoutez Response comme type de retour à la fonction index.

Voici la nouvelle classe. J'ai laissé la route, vous pouvez y accéder à cette URL (les liens ne fonctionneront pas).

<?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

Comme vous pouvez le voir, le code évolue. Et il évolue rapidement. Ce qui pouvait être une meilleure pratique il y a dix ans peut paraître ridicule désormais. C'est comme ça, acceptez le changement, testez et faites des erreurs pour apprendre le maximum de choses ! Finissons par une citation inspirante :

“Le succès n'est pas permanent; l'échec n'est pas fatal : c'est le courage de continuer qui compte. ”
- Winston Churchill

Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À tantôt ! COil. 😊

  Lire la doc

Ils m'ont donné leurs retours et m'ont aidé à corriger des erreurs et typos dans cet article, un grand merci à : Laurent Q. 👍

  Travaillez avec moi !


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.
  • Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil