Symfony 4: Creating Smart Controller

5 min February 12, 2018

What if the Symfony controller would be able to find correct template for action without writing its path over and over again? What if we would be able to setTemplateParameters from multiple places or even before the render method? Symfony 4 is a great framework but after some time of working with controllers I have started to miss these features which I was used to from other frameworks like Nette Framework. I have decided to implement them in my recent project that uses Symfony 4 and I will show you how did I did that in this article.

Symfony 4: Creating Smart Controller

Let's say we have some HomepageController with renderDefault method placed in the src/Controller directory


namespace App\Controller;

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


final class HomepageController extends Controller
{

	/**
	 * @Route("/", name="homepage")
	 */
	public function renderDefault()
	{
		$number = mt_rand(0, 100);

		return $this->render('default.twig', array(
			'number' => $number,
		));
	}

}

and the default.twig template for renderDefault action is in the templates directory.


Number: {{ number }}

Everything is fine right? But what if I would want to set the number from different method like setNumber? That is not possible. Unless... we create an AbstractController that will allow us to do so.


namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;


abstract class AbstractController extends Controller
{

	/**
	 * @var array
	 */
	private $templateParameters = [];


	protected function setTemplateParameters(array $parameters): AbstractController
	{
		$this->templateParameters = array_merge($this->templateParameters, $parameters);
		return $this;
	}


	protected function renderTemplate(string $template, array $parameters = [], Response $response = null): Response
	{
		$this->setTemplateParameters($parameters);
		return $this->render($template, $this->templateParameters, $response);
	}

}

Now all we need to do is extend it in the HomepageController, create the setter method and call it in the renderDefault method.


namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;


final class HomepageController extends AbstractController
{

	/**
	 * @Route("/", name="homepage")
	 */
	public function renderDefault(): Response
	{
		$this->setRandomNumberIntoTemplate();
		return $this->renderTemplate('default.twig');
	}


	private function setRandomNumberIntoTemplate(): void
	{
		$number = mt_rand(0, 100);
		$this->setTemplateParameters([
			'number' => $number
		]);
	}

}

It is nice to create a custom setter method. But what if we would need to set the number into the all templates (views) without calling it multiple times? This is the time where the beforeRender method comes in handy so lets add it into the AbstractController...


// ...
protected function beforeRender(): void {}
// ...


protected function renderTemplate(string $template, array $parameters = [], Response $response = null): Response
{
    $this->beforeRender();
    $this->setTemplateParameters($parameters);
    return $this->render($template, $this->templateParameters, $response);
}

... and use it in the HomepageController. That is! Now, the number will by set into the template before every render method call so we don't need to think about that anymore.


// ...
public function beforeRender(): void
{
    $this->setRandomNumberIntoTemplate();
}


// ...
/**
 * @Route("/", name="homepage")
 */
public function renderDefault(): Response
{
    return $this->renderTemplate('default.twig');
}

There is a still too much code in the renderDefault method and it still requires to write the template path and its name. I usually prefer modular directory structure with templates placed in the directory with the actual controller name nested in the templates directory that is in the same directory as the actual Controller is. Weird right? That means, if the Controller path is src/Modules/HomepageModule/Controller/HomepageController.php then the actual action template is in the src/Modules/HomepageModule/Controller/templates/Homepage/default.twig. I usually split the module into the front and admin module but for this article this example is enough.

I will now move the Homepage module and its template into those directories and the AbstractModule into the src/Modules/CoreModule/Controller/AbstractController.php.

To make it all works we need to change a few things. First the AbstractController, because there is the biggest change.

namespace App\Modules\CoreModule\Controller;

// ...

abstract class AbstractController extends Controller
{

	// ...

	protected function renderTemplate(array $parameters = [], Response $response = null): Response
	{
		preg_match(
			'/\:\:render(?<template>\S+)/',
			$this->get('request_stack')->getCurrentRequest()->attributes->get('_controller'),
			$matches
		);

		// ...

		return $this->render(
			$this->getTemplatePath(strtolower($matches['template'])),
			// ...
		);
	}


	public function getTemplatePath(string $view): string
	{
		$reflector = new \ReflectionClass(get_called_class());
		$templatesDirectoryName = str_replace('Controller', '', basename($reflector->getFileName(), '.php'));
		$moduleTemplatesDirectoryPath
			= str_replace($this->getParameter('kernel.root_dir') . '/', '', dirname($reflector->getFileName()))
			. '/templates/' . $templatesDirectoryName;

		return $moduleTemplatesDirectoryPath . '/' . $view . '.twig';
	}

}

There is now a preg_match function called in the render template method and the getTemplatePath method was added. This method simply tokenize the actual controller name and route method and returns path for the template that will be rendered.

Next the HomepageController. The template path/name have been removed because we don't need it anymore.


/**
 * @Route("/", name="homepage")
 */
public function renderDefault(): Response
{
    return $this->renderTemplate();
}

Twig and annotations needs to be configured too. There is a change in the paths according to the new directory structure.


// twig.yml
twig:
    paths: ['%kernel.project_dir%/src']
    debug: '%kernel.debug%'
    strict_variables: '%kernel.debug%'

// annotations.yml
controllers:
    resource: ../../src/Modules/
    type: annotation

The last thing that needs to be modified is the controllers mapping path in services.yml which also needs to be changed.


App\Modules\:
    resource: '../src/Modules'
    tags: ['controller.service_arguments']

And that's all! Now you can call render methods without setting template path, set parameters from multiple places and set parameters into the template automatically before it is rendered.

One of the negatives is that you must adhere the directory structure as you have configured in the getTemplatePath in the AbstractController. I will be glad for your feedback (even negative)!