How to replace old boring Controllers by your shiny Twig Components
Recently I saw someone online complaining about the impossibility to render Live Components directly from controllers, mentionning that it forces you to create useless templates which often are nothing more than a wrapper around your components…
This is a problem I also encoutered while working with Twig Components and Live Components. Yet there is a simple solution to it. A solution that can allow you to remove not only those wrapper templates, but also the corresponding controllers too.
After all, we already know that components can extend the AbstractController
from Symfony. The Live Components documentation actually recommends it! So why not use them as real controllers?
Starting from scratch
Let’s create a new project using symfony CLI:
symfony new myproject --webapp
cd myproject
Install Twig Components:
composer require symfony/ux-twig-component
Create a Controller:
php bin/console make:controller 'App\Home'
Modify your controller (we change the url to /
and we remove the controller_name
parameter because our template won’t use it):
<?php // src/Controller/App/HomeController.php
namespace App\Controller\App;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class HomeController extends AbstractController
{
#[Route('/', name: 'app_home')]
public function index(): Response
{
return $this->render('app/home/index.html.twig');
}
}
Let’s clean the generated template:
<!-- /templates/app/home/index.html -->
{% extends 'base.html.twig' %}
{% block title %}Hello HomeController!{% endblock %}
{% block body %}
<h1>Hello from regular Controller</h1>
{% endblock %}
We can now test our home page. Start the development server:
symfony server:start
You should see your home page by browsing http://127.0.0.1:8000/
Observing the problem
If our homepage only job is to render a unique component, all we have to do is create it and include it from the page template.
Let’s start by creating the component. I suggest you to name it Controller\App\Home
so it matches our controller. And if you have a lot of components, it’s always good to use proper namespaces so you can distinguish them by their purpose. Some are UI Components, some are Business Components, and the ones we’re interested in today are Controller Components.
php bin/console make:twig-component 'Controller\App\Home'
You can customize the component template:
<!-- /templates/components/Controller/App/Home.html.twig -->
<div {{ attributes }}>
<h1>Hello from Home Component!</h1>
</div>
Now let’s include this component on our home page. Replace the h1
tag by it in the template of your controller:
<!-- /templates/app/home/index.html -->
{% extends 'base.html.twig' %}
{% block title %}Hello HomeController!{% endblock %}
{% block body %}
<twig:Controller:App:Home />
{% endblock %}
You should now see the component displayed on the home page:
Everything works fine. But we had to create four different files for this:
- src/Controller/App/Home.php
- templates/app/home/index.html.twig
- src/Twig/Components/Controller/App/Home.php
- templates/components/controller/app/home.html.twig
Now suppose you want to create more pages on your application, each displaying only a specific Twig Component, like our home page. For each of those new pages, you’ll need to create two PHP files and two Twig files:
- The Controller + its own template
- The Component + its own template.
Most of the time, that kind of controllers won’t do much apart from rendering their own template. And these templates won’t do much apart from extending your base template and including the main component you want to see on that page. That’s a lot of layers to wrap a single component…
So, what if we could get rid of those useless files by using only components and their templates? 2 files instead or 4? Sounds nice.
This is actually very easy to do, let’s see how.
From Components to ControllerComponents
First, create a new abstract class in src/Twig/AbstractComponentController.php
and make it extend the AbstractController
from Symfony. The purpose of this class is to provide a new renderComponent()
method, which will return a standard Response
instance. You can leave the method empty for the moment, we’ll write it just a couple of steps after.
<?php // src/Twig/AbstractComponentController.php
namespace App\Twig;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
abstract class AbstractControllerComponent extends AbstractController
{
protected function renderComponent(): Response
{
// ...
}
}
The renderComponent()
method will use a dedicated template, which will be the same for all of your ControllerComponents. We can name it component_base.html.twig
. This template is pretty much the same as our current home template, meaning it’s a simple wrapper around a unique component. The main difference is that now the component is included dynamically by using two variables controller_component
and controller_attributes
. Of course, feel free to rename these variables if you want.
<!-- /templates/component_base.html -->
{% extends 'base.html.twig' %}
{% block title %}{% if title is defined %}{{ title }}{% endif %}{% endblock %}
{% block body %}
{{ component(controller_component, controller_attributes) }}
{% endblock %}
We can now modify the renderComponent()
method so it renders the template we just created:
<?php // src/Twig/AbstractControllerComponent.php
namespace App\Twig;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
abstract class AbstractControllerComponent extends AbstractController
{
/**
* @param array<string, mixed> $attributes
* @param array<string, mixed> $parameters
*/
protected function renderComponent(
array $attributes = [],
array $parameters = [],
Response|null $response = null,
): Response {
$parameters['controller_attributes'] = $attributes;
$parameters['controller_component'] = \str_replace(
search : [ 'App\\Twig\\Components\\', '\\' ],
replace : [ '', ':' ],
subject : $this::class,
);
return $this->render(
view : 'controller_base.html.twig',
parameters : $parameters,
response : $response,
);
}
}
You can observe that we use two array of values :
$parameters
is a regular array of variables passed to the$this->render()
method. We pushcontroller_component
andcontroller_attributes
into it. It could also includetitle
or any other variable you want to use directly insidecomponent_base.html.twig
$attributes
is an array of component attributes, in case our component requires some.
Of course the names $parameters
and $attributes
used together can be a bit confusing, so here again feel free to rename those as you want.
We’re ready to use our AbstractControllerComponent
class! You can extend it from the Home
component:
<?php // src/Twig/Components/Controller/App/Home.php
namespace App\Twig\Components\Controller\App;
use App\Twig\AbstractComponentController;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class Home extends AbstractControllerComponent
{
}
Then let’s update our component so it acts like a real controller. You can copy the index()
method from src/Controller/App/HomeController.php
into src/Twig/Components/Controller/App/Home.php
. Don’t forget to replace the call to $this->render()
by $this->renderComponent()
:
<?php // src/Twig/Components/Controller/App/Home.php
namespace App\Twig\Components\Controller\App;
use App\Twig\AbstractControllerComponent;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class Home extends AbstractControllerComponent
{
#[Route('/', name: 'app_home')]
public function index(): Response
{
return $this->renderComponent();
}
}
Now our component declares the same Route
attribute than the regular HomeController
. In order to avoid conflicts we’ll rename those. Our ultimate goal is to get rid of that old controller entirely, but for the moment we can just use /old
instead of /
and app_home_old
instead of app_home
:
<?php // src/Controller/App/HomeController.php
namespace App\Controller\App;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class HomeController extends AbstractController
{
#[Route('/old', name: 'app_home_old')]
public function index(): Response
{
return $this->render('app/home/index.html.twig');
}
}
Quick recap of what we’ve done:
- We created an abstract class
AbstractControllerComponent
which can be extended by our components. - It contains a
renderComponent()
method that always renders the same templatecomponent_base.html.twig
, while passing it the name of the component to include. - This template extends
base.html.twig
and includes the asked component. So this unique template can be used to include any component we want. - We added a standard controller method and its
Route
attribute in ourHome
component. It now behaves like any regular controller.
This should work pretty well. Let’s try to refresh our browser…
Instead of seeing our home page, we see the default Symfony page. This is because the framework doesn’t know that it has to look for routes inside Twig Components.
We have to edit the configuration to tell Symfony it can find Route
attributes in the src/Twig/Components/Controller
folder:
# config/routes.yaml
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute
components:
resource:
path: ../src/Twig/Components/Controller/
namespace: App\Twig\Components\Controller
type: attribute
Refresh you browser again and…
It works! 😊
The page is rendered directly by our Home
component, which now acts as a real Symfony Controller.
We can finally remove src/Controller/App/Home.php
and templates/app/home/index.html.twig
. Actually, we can delete the src/Controller/App
and templates/app/home
folders too:
rm -rf ./src/Controller/App/
rm -rf ./templates/app
Our page is still working as expected, and we successfully removed the useless wrapper files!
Observations and comments
- Live Components are built on top of Twig Components, so what we did works well with both. Simple.
- If you’re sure you’ll never need to extend
base.html.twig
from another template thancomponent_base.html.twig
, you could eventually merge these. One less file! - You also could remove the
src/Controller
folder entirely (and the dedicated configuration inconfig/routes.yaml)
, but I’d advise you to keep it. Because in a typical application you don’t actually want all of your routes to be components. Some will return json, some will just execute a few instructions and redirect… For all of these you don’t need to create a component, so it can be better to keep them in the usual folder.