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);
<?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;
}
<?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 controllergetParameter()
andredirectToRoute()
. - 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 theservices.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:
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 theindex
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. ๐
They gave feedback and helped me to fix errors and typos in this article; many thanks to Laurent Q. ๐
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 )
- Report any error/typo.
- Report something that could be improved.
- Like and retweet!
- Follow me on Twitter Follow me on Twitter
- Subscribe to the RSS feed.
- Click on the More on Stackoverflow buttons to make me win "Announcer" badges ๐ .
Thank you for reading! And see you soon on Strangebuzz! ๐
[๐ฌ๐ง] It was my last 2019 blog post to reach my #blogging goal. This time, we check the evolution of the "BaseController". https://t.co/a68tv3roW6 Proofreading, comments, likes and retweets are welcome! ๐ Annual goal: 12/12 (100%) #php #strangebuzz #blog #blogging ๐
— [SB] COil (@C0il) January 8, 2020