Use Twig Components as Controllers in Symfony

Masta
7 min readDec 7, 2024

--

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…

Example of a wrapper template around a Twig Component

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/

Our home page rendered by a regular Controller

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 h1tag 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:

Our home page including a Twig Component (but still rendered by a regular Controller)

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 push controller_component and controller_attributes into it. It could also include title or any other variable you want to use directly inside component_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 template component_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 our Home component. It now behaves like any regular controller.

This should work pretty well. Let’s try to refresh our browser…

Symfony doesn’t find the route for the home page

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…

Our home page rendered by a ControllerComponent

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 than component_base.html.twig, you could eventually merge these. One less file!
  • You also could remove the src/Controller folder entirely (and the dedicated configuration in config/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.

--

--

Masta
Masta

Written by Masta

Web developper and teacher with 20 years experience. Crate digger, web architect.

No responses yet