· 8 min read
Symfony : Twig et Live Components
Nous allons voir comment mettre en place des composants Twig réutilisable, et comment rendre des éléments réactifs sans écrire une seul ligne de JavaScript !
Objectifs
Dans cet article nous allons découvrir deux Bundles pour Symfony :
- Twig Component
- Live Component
Nous allons voir comment mettre en place des composants Twig réutilisable, et comment rendre des éléments réactifs sans écrire une seul ligne de JavaScript :sunglasses:
Initialisation de notre projet Symfony
Nous allons créer un faux projet bassé sur des Blogposts. Pour faire simple nous n’aurons que deux champs : Title et content !
symfony new live-component --webapp
cd live-component
composer require symfony/ux-twig-component
composer require symfony/ux-live-component
npm install --force && npm run build
docker compose up -d
symfony serve -d
symfony console make:controller Blog
symfony console make:entity Blog
> title (string, 255; not null)
> content (text, not null)
symfony console make:migration
symfony console d:m:m
Configuration de Live Component
Nous avons précédement installé la librairie, mais nous avons un bout de configuration à faire, trois fois rien !
- Modidifions le fichier
assets/bootstrap.js
import { startStimulusApp } from '@symfony/stimulus-bridge';
// Registers Stimulus controllers from controllers.json and in the controllers/ directory
export const app = startStimulusApp(require.context(
'@symfony/stimulus-bridge/lazy-controller-loader!./controllers',
true,
/\.[jt]sx?$/
));
import LiveController from '@symfony/ux-live-component';
import '@symfony/ux-live-component/styles/live.css';
app.register('live', LiveController);
- Et ajoutons quelques lighes à notre fichier
config/routes.yaml
live_component:
resource: '@LiveComponentBundle/Resources/config/routing/live_component.xml'
- Et enfin, lançons un
npm run watch
Générations de fixtures
Histoire d’avoir quelques articles de blog à manipuler, mettons nous en place quelques fixtures avec des fausses données.
-
Installons le Fixture Bundle
composer require --dev orm-fixtures
-
Installons Faker
composer require fakerphp/faker
-
Créons notre jeu de données dans le fichier
src/DataFixtures/AppFixtures.php
<?php
namespace App\DataFixtures;
use App\Entity\Blog;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Faker\Factory;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$faker = Factory::create('fr_FR');
for ($i=0; $i < 10; $i++) {
$blog = new Blog();
$blog->setTitle($faker->sentence())
->setContent($faker->paragraph());
$manager->persist($blog);
}
$manager->flush();
}
}
- Et enfin, jouons nos fixtures
symfony console d:f:l
Mise en place de Boostrap
Et pour avoir un bout d’interface plus agréable que du html sans CSS, on y ajoute une goutte de Boostrap.
- Modifions notre fichier
templates/base.html.twig
pour utiliser Boostrap via son CDN
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text></svg>">
<title>Bootstrap demo</title>
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% endblock %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-0evHe/X+R7YkIZDRvuzKMRqM+OrBnVFBL6DOitfPri4tjfHxaWutUpFmBp4vmVor" crossorigin="anonymous">
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{% endblock %}
</head>
<body>
<div class="container">
{% block body %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-pprn3073KE6tl6bjs2QrFaJGz5/SUsLqktiwsUTF55Jfv3qYSDhgCecCxMW52nD2" crossorigin="anonymous"></script>
</body>
</html>
Les “Twig Components”
Testons le principe des Twig Component avec l’affichage d’une “card” pour chaques articles de blog. Vous allez voir c’est génial et SIMPLE !
Le concept
- Créons un fichier
src/Components/BlogpostComponent.php
<?php
namespace App\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('blogpost')]
class BlogpostComponent
{
}
- Et son pendant pour Twig
templates/components/blogpost.html.twig
<div class="card m-4">
<div class="card-body">
<h5 class="card-title">Blogpost Title</h5>
<p class="card-text">Blogpost content Lorem ipsum dolor sit amet consectetur, adipisicing elit. Reprehenderit molestiae quam quaerat corrupti fugiat enim dolorem, esse aspernatur porro non dignissimos fugit quae ratione temporibus!</p>
</div>
</div>
- Enfin, apellons ce component dans notre fichier
templates/blog/index.html.twig
{% extends 'base.html.twig' %}
{% block title %}Hello BlogController!{% endblock %}
{% block body %}
{{ component('blogpost') }}
{% endblock %}
Si on charge notre page, trés logiquement nous affichons une card, rendons la “dynamique” désormais, en lui passant des paramètres.
- Adpatons notre fichier
src/Components/BlogpostComponent.php
<?php
namespace App\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('blogpost')]
class BlogpostComponent
{
public string $title;
public string $content;
}
- Modifions également le fichier twig
templates/components/blogpost.html.twig
<div class="card m-4">
<div class="card-body">
<h5 class="card-title">{{ title }}</h5>
<p class="card-text">{{ content }}</p>
</div>
</div>
- Et biensur, modifions notre manière d’utiliser le composant pour lui passser nos paramètres
{% extends 'base.html.twig' %}
{% block title %}Hello BlogController!{% endblock %}
{% block body %}
{{ component('blogpost', {
title: 'Hello World',
content: 'This is my first blog post'
}) }}
{% endblock %}
Si on recharge notre page, nous somme désormais en mésure de passer des paramètres à notre composant ! Cool ! Passons à l’étape supérieur en récupérant les données depuis notre base de données !
- Modifions, une nouvelle fois, notre fichier
src/Components/BlogpostComponent.php
<?php
namespace App\Components;
use App\Entity\Blog;
use App\Repository\BlogRepository;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('blogpost')]
class BlogpostComponent
{
public int $id;
public function __construct(
private BlogRepository $blogRepository
) {
}
public function getBlogpost(): Blog
{
return $this->blogRepository->find($this->id);
}
}
- Et le Twig qui va avec (vous avez l’habitude maintenant logiquement)
templates/components/blogpost.html.twig
<div class="card m-4">
<div class="card-body">
<h5 class="card-title">{{ this.blogpost.title }}</h5>
<p class="card-text">{{ this.blogpost.content }}</p>
</div>
</div>
- Et enfin, changeons notre façons d’apeller notre composant dans le fichier
templates/blog/index.html.twig
{% extends 'base.html.twig' %}
{% block title %}Hello BlogController!{% endblock %}
{% block body %}
{{ component('blogpost', {
id: 1,
}) }}
{{ component('blogpost', {
id: 2,
}) }}
{% endblock %}
Un composant dans un composant
Et si nous voulons fabriquer un composant qui en utilise lui-même un autre, c’est possible ? Mais OUI !
- Créons un nouveau Composant, en commençant par le fichier php
src/Components/AllBlogpostComponent.php
<?php
namespace App\Components;
use App\Repository\BlogRepository;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('all_blogpost')]
class AllBlogpostComponent
{
public function __construct(
private BlogRepository $blogRepository
) {
}
public function getAllBlogpost(): array
{
return $this->blogRepository->findAll();
}
}
- Puis le fichier Twig qui va avec
templates/components/all_blogpost.html.twig
(et en réutilisant notre premier composant)
{% for blogpost in this.allBlogpost %}
{{ component('blogpost', {'id': blogpost.id}) }}
{% endfor %}
- Puis, utilisons ce nouveau composant dans notre fichier
templates/blog/index.html.twig
{% extends 'base.html.twig' %}
{% block title %}Hello BlogController!{% endblock %}
{% block body %}
{{ component('all_blogpost') }}
{% endblock %}
Les “Live Components”
Découvrons rapidement le principe dess Live Components avec deux exemples (et un bonus!) :
- Une recherche d’articles de Blog
- Un système d’édition “reactif”
Un moteur de recherche
- Création, comme pour les Twig Components, de notre composant “coté” php dans le fichier
src/Components/BlogpostSearchComponent.php
<?php
namespace App\Components;
use App\Repository\BlogRepository;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
#[AsLiveComponent('blogpost_search')]
class BlogpostSearchComponent
{
use DefaultActionTrait;
#[LiveProp(writable: true)]
public string $query = '';
public function __construct(
private BlogRepository $blogRepository
) {
}
public function getBlogposts(): array
{
return $this->blogRepository->findByQuery($this->query);
}
}
- Au passage, on ajoute une recherche dans notre repository
src/Repository/BlogRepository.php
class BlogRepository extends ServiceEntityRepository
{
// ...
public function findByQuery(string $query): array
{
if (empty($query)) {
return [];
}
return $this->createQueryBuilder('b')
->andWhere('b.title LIKE :query')
->setParameter('query', '%'.$query.'%')
->orderBy('b.id', 'ASC')
->getQuery()
->getResult()
;
}
}
- Et biensur, n’oublions notre fichier Twig
templates/components/blogpost_search.html.twig
(Et re-utilisons une nouvelle fois notre Twig Component)
<div {{ attributes }} class="m-4">
<input
type="search"
name="query"
value="{{ query }}"
data-action="live#update"
>
{% for blogpost in this.blogposts %}
{{ component('blogpost', {'id': blogpost.id}) }}
{% endfor %}
</div>
- Enfin, cérons une nouvelle route dans le controller
src/Controller/BlogController.php
#[Route('/search', name: 'app_search')]
public function search(): Response
{
return $this->render('blog/search.html.twig', [
'controller_name' => 'BlogController',
]);
}
- Et… le fichier twig
templates/blog/search.html.twig
{% extends 'base.html.twig' %}
{% block title %}Hello BlogController!{% endblock %}
{% block body %}
{{ component('blogpost_search') }}
{% endblock %}
Testons notre route /search: TADA ! Nous voici capable d’afficher de manière dynamique nos articles en fonction de notre recherche sans aucunes lignes de JavaScripts !
Un système d’édition réactif
- Créons notre composant php
src/Components/EditBlogpostComponent.php
<?php
namespace App\Components;
use App\Entity\Blog;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
#[AsLiveComponent('edit_blogpost')]
final class EditBlogpostComponent extends AbstractController
{
use DefaultActionTrait;
use ValidatableComponentTrait;
#[LiveProp(exposed: ['title', 'content'])]
#[Assert\Valid]
public Blog $blogpost;
public bool $isSaved = false;
#[LiveAction]
public function save(EntityManagerInterface $entityManager)
{
$this->validate();
$this->isSaved = true;
$entityManager->flush();
}
}
- Puis notre twig
templates/components/edit_blogpost.html.twig
<div{{ attributes }}>
<div>
<h1>{{ blogpost.title }}</h1>
<hr>
<div class="mb-3">
<label for="blogpost_title" class="form-label"><h2>Title</h2></label>
<div class="input-group">
<input
type="text"
data-model="blogpost.title"
data-action="blur->live#update"
class="form-control"
value="{{ blogpost.title }}"
id="blogpost_title"
>
</div>
</div>
<div class="mb-3">
<label for="blogpost_content" class="form-label"><h2>Content</h2></label>
<div class="input-group">
<textarea
data-model="blogpost.content"
data-action="live#update"
class="form-control"
id="blogpost_content"
>{{ blogpost.content }}</textarea>
</div>
</div>
</div>
<div class="my-5 p-5 shadow bg-secondary text-light">
<h3>{{ blogpost.title }}</h3>
{{ blogpost.content }}
</div>
<div class="d-grid gap-2">
<button
data-action="live#action"
data-action-name="save"
class="btn btn-primary btn-sm"
>
Enregistrer les modifications
</button>
</div>
{% if isSaved %}
<div class="alert alert-success my-4">Enregistrer !</div>
{% endif %}
</div>
- Ajoutons une nouvelle route à notre controller
src/Controller/BlogController.php
#[Route('/edit/{id}', name: 'app_edit')]
public function edit(Blog $blogpost): Response
{
return $this->render('blog/edit.html.twig', [
'controller_name' => 'BlogController',
'blogpost' => $blogpost,
]);
}
- Et enfin, utilisons ce nouveau composant dans le fichier Twig de cette route
templates/blog/edit.html.twig
{% extends 'base.html.twig' %}
{% block title %}Hello BlogController!{% endblock %}
{% block body %}
{{ component('edit_blogpost', { blogpost: blogpost }) }}
{% endblock %}
Bonus : Des données en temps réel
Essayons de mettre en place un système qui nous affiche en temps réel le nombre de Blogpost, sans recharger la page.
- Créons notre composant
src/Components/CountBlogpostComponent.php
<?php
namespace App\Components;
use App\Repository\BlogRepository;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent('count_blogpost')]
class CountBlogpostComponent
{
use DefaultActionTrait;
public function __construct(
private BlogRepository $blogRepository
) {
}
public function getAllBlogpost(): int
{
return $this->blogRepository->count([]);
}
}
- Puis sont Twig
templates/components/count_blogpost.html.twig
<div{{ attributes }}
data-poll
>
Nombre de Blogpost: ({{ this.countBlogpost }})
</div>
- Ajoutons ce composant sur notre page
templates/blog/index.html.twig
{% extends 'base.html.twig' %}
{% block title %}Hello BlogController!{% endblock %}
{% block body %}
{{ component('count_blogpost') }}
{{ component('all_blogpost') }}
{% endblock %}
- Et créons une route qui génére un article à chaque chargement dans notre controller (oui c’est débile, mais c’est pour l’exemple hein)
src/Controller/BlogController.php
#[Route('/generate', name: 'app_generate')]
public function generate(EntityManagerInterface $entityManagerInterface): Response
{
$faker = Factory::create('fr_FR');
$blogpost = new Blog();
$blogpost->setTitle($faker->sentence())
->setContent($faker->paragraph());
$entityManagerInterface->persist($blogpost);
$entityManagerInterface->flush();
dd('Blog post created');
}