#027 TUTO blog 8 min de lecture

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 !

Version .md Le Markdown brut de l'article, à coller dans Claude Code, Codex ou Cursor.
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 !
SOMMAIRE · 8 PARTIES
  1. Objectifs
  2. Initialisation de notre projet Symfony
  3. Configuration de Live Component
  4. Générations de fixtures
  5. Mise en place de Boostrap
  6. Les “Twig Components”
  7. Les “Live Components”
  8. Resssources
Voir la vidéo

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');
    }

Resssources

Tous les articles

Commentaires (0)

Chargement des commentaires…

Laisser un commentaire