· 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 !

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

Resssources

Back to Blog