Twig Components : comment Symfony a piqué les super-pouvoirs de React
Découvrez comment les Twig Components transforment vos vues Twig en composants réutilisables et typés.
Publié le
Temps de lecture 10 min
Les Twig Components, c’est la preuve que Symfony a écouté vos prières après des années à mélanger logique et affichage comme si vous faisiez cuire vos steaks dans le lave-vaisselle. Enfin, des composants réutilisables, typés et testables – le tout sans quitter notre framework préféré.
Parce que non, on ne va pas se mettre à React juste pour avoir des boutons qui clignotent. On est des développeurs PHP, on vaut mieux que ça (et on a déjà assez de console.log dans nos vies).
Ce que symfony/ux-twig-component apporte, c'est la puissance du modèle composant des frameworks front-end, mais directement dans Twig, avec la robustesse de PHP.
Des composants front-end comme React/Vue, mais en PHP natif, avec du typage.
- Une classe PHP pour la logique
- Un template Twig pour l’affichage
- Des props typées exposées automatiquement
Installation et configuration : Le B.A.-BA
Avant de sauver le monde, il faut assembler son costume. Heureusement, avec Symfony Flex, c'est trivial.
Installation
Ouvrez votre console et tapez la ligne magique :
composer require symfony/ux-twig-component
Flex s'occupe de tout. Il active le bundle et crée un fichier de configuration initial.
Configuration (ce que Flex fait pour vous)
Le composant fonctionne grâce à une configuration simple qui mappe un namespace PHP à un répertoire de templates Twig. Jetez un œil à config/packages/twig_component.yaml :
# config/packages/twig_component.yaml
twig_component:
defaults:
# Map le namespace de vos classes de composants...
App\Twig\Components\:
# ...au préfixe de template 'components/'
# (donc templates/components/)
template_directory: 'components/'
anonymous_template_directory: 'components/'
Cela signifie que votre classe App\Twig\Components\Layout\Hero (en respectant votre arborescence) sera automatiquement liée au template templates/components/Layout/Hero.html.twig. Simple, efficace.
3. Créer votre premier composant (la voie royale)
Symfony vous mâche le travail avec le MakerBundle.
bin/console make:twig-component Layout/Hero
Cette commande vous crée deux fichiers, respectant l'arborescence :
src/Twig/Components/Layout/Hero.php(la classe)templates/components/Layout/Hero.html.twig(le template)
Les concepts fondamentaux : de la classe au HTML
Un composant, c'est l'union sacrée d'une classe PHP et d'un template Twig. C'est là que la magie opère : la logique métier est encapsulée dans le PHP, laissant au template la seule responsabilité de l'affichage.
Prenons l'exemple du composant Hero.
La Classe PHP : le Cerveau (Hero.php)
C'est ici que réside la logique. L'attribut #[AsTwigComponent] déclare cette classe comme un composant.
// src/Twig/Components/Layout/Hero.php
namespace App\Twig\Components\Layout;
// ... (tous vos 'use' statements)
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
use App\Repository\PunchlineRepository;
use App\Repository\PageRepository;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
// ... etc
#[AsTwigComponent(name: 'Hero', template: 'components/Layout/Hero.html.twig')]
final readonly class Hero // "readonly" parce qu’un héros ne change pas d’avis
{
// 1. Injection de dépendances (c'est un service !)
public function __construct(
private PunchlineRepository $punchlineRepository,
private PageRepository $pageRepository,
private UrlGeneratorInterface $urlGenerator,
) {}
// 2. Logique métier exposée au template
#[ExposeInTemplate('punchline')]
public function getPunchline(): ?PunchlineEntity
{
return $this->punchlineRepository->findRandom();
}
#[ExposeInTemplate('aboutPage')]
public function getAboutPage(): array
{
$page = $this->pageRepository->findByTemplate(PageTemplateEnum::ABOUT);
if ($page->status === ContentStatusEnum::ONLINE) {
return [
'title' => $page->title,
'url' => $this->urlGenerator->generate(PageAction::class, ['slug' => $page->slug]),
];
}
return []; // Comme vos compétences en CSS ;)
}
}
Ce que nous voyons ici :
#[AsTwigComponent]: C'est le sésame. Vous nommez votre composant (Hero) et vous le liez explicitement à son template.- Injection de services : Le constructeur reçoit des services (Repositories, UrlGenerator...) comme n'importe quel service Symfony. Fini les
{{ render(controller(...)) }}pour aller chercher des données. #[ExposeInTemplate]: C'est l'attribut clé. Il rend le résultat de vos méthodes (getPunchline,getAboutPage) directement accessible dans Twig.#[ExposeInTemplate('punchline')]rend la méthode accessible via{{ punchline }}.- Sans argument,
#[ExposeInTemplate]rendraitgetAboutPageaccessible via{{ about_page }}(basé sur le nom de la méthode).
Le template Twig : le visage (Hero.html.twig)
C'est le fichier .html.twig lié. Il a accès aux méthodes exposées (punchline, aboutPage) comme si c'étaient de simples variables.
{# templates/components/Layout/Hero.html.twig #}
{# Bonne pratique : le cache est totalement compatible ! #}
{% cache 'hero' ttl(300) tags(['hero']) %}
<section class="pt-28 lg:pt-36 pb-6 sm:pb-10" data-component="hero">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="max-w-4xl mx-auto text-center">
<p class="heading text-3xl tracking-tight text-balance text-gray-900 sm:text-5xl mb-6">
{# Utilisation directe de la variable exposée #}
{{ punchline.content }}
</p>
<p class="text-lg text-[var(--color-ink-soft)] mb-10 max-w-2xl mx-auto leading-relaxed">
Un blog sur mes aventures (et mésaventures) avec Symfony, PHP...
</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="{{ path(ctrl_fqcn('PostsAction')) }}" ...>
Tous les billets
</a>
{# Utilisation de la seconde variable exposée #}
<a href="{{ aboutPage.url }}" title="{{ aboutPage.title }}">
{{ aboutPage.title }}
</a>
</div>
</div>
</div>
</section>
{% endcache %}
L'appel : la magie en action
Maintenant, depuis n'importe quel autre template (la page d'accueil, par exemple), l'appel devient trivial et déclaratif :
{# templates/home/index.html.twig #}
{# ... votre <head> ... #}
<twig:Seo /> {# Tiens, un autre composant ! #}
{# ... votre <header> ... #}
<twig:Header />
{# Et voici le nôtre ! Il exécute sa logique tout seul. #}
<twig:Hero />
{# ... la suite ... #}
<twig:LatestPosts />
Vous venez d'appeler un composant complexe, qui injecte 3 services et exécute 2 logiques métier, en une seule ligne HTML.
Passer des données (props) : maîtriser le Jeu
Un composant est vraiment réutilisable s'il peut recevoir des données. C'est le rôle des props. Une "prop" est simplement une propriété publique de votre classe composant.
Prenons le composant Post : il est conçu pour recevoir un objet PostEntity et l'afficher.
Déclarer la prop (Post.php)
La classe est minimale : elle déclare juste une propriété publique post qui attendra de recevoir une donnée.
// src/Twig/Components/Content/Post.php
namespace App\Twig\Components\Content;
use App\Entity\Post as PostEntity; // Renommage pour éviter conflit
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent(name: 'Post', template: 'components/Content/Post.html.twig')]
final class Post
{
// C'est la "prop" !
public ?PostEntity $post = null;
// On peut aussi déclarer d'autres props, comme 'label'
public ?string $label = null;
}
Utiliser la Prop (Post.html.twig)
Dans le template, ces propriétés publiques sont automatiquement disponibles. Vous pouvez y accéder directement (post, label) ou via this.post.
{# templates/components/Content/Post.html.twig #}
<article class="group relative focus:outline-none ...">
{# 'label' est une prop optionnelle, vérifiée ici #}
{% if label is defined and label %}
<div class="mb-1">
<p class="heading ...">
... {{ label }} ...
</p>
</div>
{% endif %}
<div class="relative max-w-screen-sm ...">
<h3 class="heading text-2xl ...">
{# On utilise la prop 'post' passée en paramètre #}
<a title="{{ post.title|raw }}" href="{{ path(ctrl_fqcn('PostAction'), {'slug': post.slug, 'category': post.categorySlug}) }}">
{{ post.title|raw }}
</a>
</h3>
<div class="flex flex-col ...">
<time datetime="{{ post.createdAt|date('Y-m-d') }}" class="heading">
{{ post.createdAt|format_datetime(locale='fr', pattern='d MMMM y') }}
</time>
...
<span class="...">
{{ post.category ? post.category.title : 'non-catégorisé' }}
</span>
...
</div>
{% if post.excerpt %}
<p>{{ post.excerpt|raw }}</p>
{% endif %}
</div>
</article>
Passer la prop (l'appel)
Le composant LatestPosts illustre parfaitement cet appel. Il boucle sur sa propre logique et passe chaque objet post à un composant enfant twig:Post.
{# templates/components/Content/LatestPosts.html.twig #}
...
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 pt-4 pb-8">
{# 'this.posts' vient de la logique de LatestPosts.php #}
{% for post in this.posts %}
{#
Ici, on passe la variable 'post' de la boucle
à la prop 'post' du composant 'twig:Post'
Le préfixe ':' indique que c'est une donnée dynamique
#}
<twig:Post :post="post" />
{% endfor %}
</div>
...
Le : devant :post="post" est crucial. Il dit à Twig : "N'interprète pas 'post' comme une chaîne de caractères, mais comme la variable Twig nommée post."
Logique métier et bonnes pratiques
Niveau 1 : prop simple (Post)
Le composant est "bête". Il reçoit une donnée (post) et l'affiche. Idéal pour la réutilisabilité pure.
Niveau 2 : logique interne (LatestPosts)
Le composant est "intelligent". Il n'attend aucune prop, mais utilise l'injection de dépendances (PostRepository) pour aller chercher ses propres données (getPosts).
// src/Twig/Components/Content/LatestPosts.php
namespace App\Twig\Components\Content;
use App\Repository\PostRepository;
use App\Entity\Post;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
#[AsTwigComponent(name: 'LatestPosts', template: 'components/Content/LatestPosts.html.twig')]
final class LatestPosts
{
public function __construct(
private readonly PostRepository $postRepository
) {}
/** @return list<Post> */
#[ExposeInTemplate('posts')] // On expose la méthode pour le template
public function getPosts(): array
{
// 6 posts, comme les balles d’un revolver (mais en moins dangereux)
$posts = $this->postRepository->findPublished(limit: 6);
return array_values($posts); // Parce que PHP aime les tableaux qui commencent à 0
}
}
Niveau 3 : mixte et complexe (Hero, Seo, Breadcrumbs)
Ces composants combinent tout :
- Injection de dépendances (
SeoinjecteRequestStack,UrlGeneratorInterface...). - Logique métier exposée (
Seoa sa méthodegetSeo()). - Props publiques (
Breadcrumbsa$post,$page,$category... pour conditionner son affichage).
// src/Twig/Components/Layout/Breadcrumbs.php
namespace App\Twig\Components\Layout;
// ...
#[AsTwigComponent(name: 'Breadcrumbs', template: 'components/Layout/Breadcrumbs.html.twig')]
class Breadcrumbs
{
// Une tonne de props pour gérer tous les cas
public ?Post $post = null;
public ?Page $page = null;
public ?Category $category = null;
public bool $showPost = true;
public bool $isPostsPage = false;
public bool $isDefaultPage = false;
// Logique interne pour simplifier le template
// Celle-ci n'est PAS exposée, elle sera appelée depuis le template
// via {{ this.categoryName }} (Twig est malin)
public function getCategoryName(): ?string
{
return $this->category instanceof Category
? $this->category->title
: ($this->post?->category->title ?? null); // Un ternaire dans un ternaire : la prog extrême
}
}
Son template peut alors utiliser à la fois les props (is_posts_page) et les méthodes (this.categoryName) pour construire son affichage complexe.
{# templates/components/Layout/Breadcrumbs.html.twig #}
{# On peut "déstructurer" les props au début pour plus de clarté #}
{% props
category_name = this.categoryName,
show_post = this.showPost,
is_posts_page = this.isPostsPage
%}
<nav class="pt-28 overflow-x-auto">
<ol class="flex items-center text-md whitespace-nowrap min-w-max">
<li><a href="{{ path(ctrl_fqcn('Content\\HomeAction')) }}" ...>Accueil</a></li>
{# Logique complexe basée sur les props et méthodes #}
{% if this.post or is_posts_page or category_name != null %}
<li class="mx-1 sm:mx-2">
<svg ...><path .../></svg>
</li>
{% endif %}
{# ... suite de la logique ... #}
</ol>
</nav>
Fonctionnalités avancées (pour aller plus loin)
1. Gestion des Attributs HTML (attributes)
Que faire si vous voulez ajouter un id ou une classe CSS depuis l'appelant ?
{# On veut ajouter une classe CSS 'mt-5' au composant Post #}
<twig:Post :post="post" class="mt-5" id="post-{{ post.id }}" />
class et id ne sont pas des props de Post.php. Ils sont collectés dans une variable magique : attributes.
Je pourrais modifier Post.html.twig pour les "imprimer" sur l'élément racine :
{# templates/components/Content/Post.html.twig #}
{#
'attributes.defaults' permet de fusionner les classes/attributs
passés à l'appel avec ceux par défaut du composant.
#}
<article {{ attributes.defaults({
class: 'group relative focus:outline-none py-8 lg:py-10 px-10 lg:px-14 bg-white transform transition hover:scale-[1.01] h-full will-change-transform rounded-3xl flex flex-col shadow-xl hover:shadow-2xl border border-gray-100'
}) }} data-seo-container>
{# ... reste de votre template ... #}
</article>
{#
Résultat généré :
<article id="post-123" class="mt-5 group relative focus:outline-none [...]" data-seo-container>
#}
2. Le cycle de vie : la méthode mount()
Parfois, vous avez besoin d'initialiser des données avant le rendu, en vous basant sur les props qui ont été passées. C'est le rôle de mount(). Elle est appelée juste après l'instanciation, et les props y sont déjà disponibles.
Mon composant Seo pourrait bénéficier d'une modification
// src/Twig/Components/Utils/Seo.php
// ...
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
#[AsTwigComponent(name: 'Seo', template: 'components/Utils/Seo.html.twig')]
final class Seo
{
public ?AbstractContent $content = null;
// On rend cette prop privée, elle n'a pas à être vue
private ?SeoDto $seoDto = null;
private ?array $jsonLdSchema = null;
public function __construct(
private readonly RequestStack $requestStack,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly JsonLdSchemaGenerator $jsonLdSchemaGenerator,
) {}
// Mount est appelé automatiquement après que la prop 'content' soit définie
public function mount(): void
{
if ($this->content instanceof AbstractContent) {
$baseSeo = $this->content->seo;
$title = in_array($baseSeo->title, [null, '', '0'], true)
? $this->content->title
: $baseSeo->title;
// ... (toute votre logique de création de SeoDto) ...
// On initialise la variable privée
$this->seoDto = new SeoDto(/* ... */);
} else {
$this->seoDto = SeoDto::createEmpty();
}
// On peut aussi initialiser le JSON-LD ici
$this->jsonLdSchema = $this->jsonLdSchemaGenerator->generate($this->seoDto, $this->content);
}
#[ExposeInTemplate('seo')]
public function getSeo(): SeoDto
{
// Plus besoin de if, c'est déjà initialisé !
return $this->seoDto;
}
#[ExposeInTemplate('jsonLdSchema')]
public function getJsonLdSchema(): array
{
// Déjà initialisé aussi !
return $this->jsonLdSchema;
}
}
Ce serait plus propre : mount s'occupe de l'initialisation, les getters ne font que retourner les résultats. Votre template Seo.html.twig n'a plus qu'à appeler {{ seo.* }} et {{ jsonLdSchema|json_encode(...) }}.
Le mot de la fin
Les Twig Components, c’est la preuve que Symfony peut être moderne sans sacrifier la simplicité. Vos exemples montrent parfaitement comment vous avez encapsulé la logique (SEO, fil d'Ariane, requêtes BDD) là où elle doit être : dans des classes PHP testables.