Symfony met le Turbo avec Stimulus !

Symfony met le Turbo avec Stimulus !

Je m'abonne
Temps de lecture: 20 mins

Aujourd'hui on va parler frontend (ça m'arrive de temps en temps paraît-il, personne n'est parfait !).

Avec l'essor du Javascript moderne dans nos navigateurs, créer une application nécessite souvent de maîtriser du JavaScript avancé, avec des fichiers .js ou .jsx, et d’adopter un framework front tel que React ou Vue.

Le principal problème de cette approche est que le rendu s'opère côté Javascript, ce qui entraîne de mauvaises performances en SEO. Il existe bien des alternatives, comme Next.js, mais elles nécessitent de revoir la façon de créer son backend et son architecture.

Pour pallier à ce problème et rendre les applications proches d'une SPA (Single Page Application), Symfony a intégré la suite Hotwired à son framework depuis la version 5.2. Cette suite est un ensemble d’outils qui permettent d'utiliser du JavaScript avancé tout en donnant l'illusion d’une SPA. Pour cela, l'équipe de 37Signals nous propose deux outils principaux :

- Turbo (c’est lui la partie SPA)
- Stimulus (Une couche de JS dans le HTML)

Turbo va modifier le comportement du navigateur et intercepter des événements tels que les clics ou les soumissions de formulaires. Stimulus, quant à lui, ajoute du JavaScript sous forme de fichiers que l’on peut doser finement grâce à des attributs HTML. Grâce à ces outils, on bénéficie d'une application moderne, mais il faut un peu de compréhension côté Symfony pour utiliser Turbo efficacement.

On va voir ça ensemble !

Pour ce tuto, nous allons créer une application de recettes de cuisine (et parce que je veux migrer la mienne de Next.js à Symfony 😅).

On va partir sur quelque chose de très simple : 

Diagramme de base de données
Diagramme de l'application

Une recette avec des éléments et deux relations ManyToOne (Country + Category).

J’ai créé pour l'article un repository contenant deux branches :

  • Intro : elle contient uniquement la mise en place du projet. Tu pourras suivre et appliquer les étapes au fur et à mesure de l’article (en gros, l’initialisation du projet est déjà terminée).
  • master : elle permet de voir le projet dans son ensemble.

Je te conseille bien entendu de commencer par la branche Intro pour te familiariser avec Turbo et Stimulus.

Initialisation du projet

Le projet est crée en Symfony 7.3, avec Tailwind en framework CSS et la librairie FlyonUI pour avoir quelques composants stylisés de base. J'initialise le projet avec une base de données SQLite pour aller plus vite dans la configuration du projet. Lors de la migration, vérifies bien que tu as l'extension sqlite et pdo_sqlite.

Pour l'installation, j'ai suivi les liens suivants : 

Tu pourras t'y référer régulièrement au besoin si tu n'es pas familier de Tailwind ou de FlyonUI.

Schéma
Tu dois avoir ça en début d'initialisation

À partir d’ici, on démarre.

Si tu es à jour avec la branche Intro, je te laisse le soin de faire ta migration et de créer quelques recettes pour qu’on puisse avancer ensemble.

Petit disclaimer avant de commencer : pour des raisons de praticité, j’enregistre l'image de chaque recette sous forme de base64 directement dans la base de données. Ce n'est pas du tout une bonne pratique, mais cela permet de simplifier l'article en supprimant une étape.

Si tu as téléchargé le projet, tu as sûrement remarqué que la navigation était plutôt "classique", avec un rechargement côté serveur.

Rends-toi dans base.html.twig et supprime l'attribut data-turbo="false" de la balise html.

Recharge ensuite la page et promène-toi dans l'application. C'est fluide, non ?

C’est grâce à Turbo Drive, un composant de Turbo qui permet une navigation sans rechargement. Turbo Drive agit comme un middleware et intercepte tes clics.

Cependant tu remarqueras que Turbo Drive va toujours tenter de précharger (prefetch) une requête et parfois, un peu trop tôt.

Tu peux t'en rendre compte en survolant, sans cliquer, le lien "Ajouter une recette" : tu verras dans la barre du profiler qu'une requête a été faite, même sans cliquer.

Turbo Drive et son prefetch

Quand c'est un lien d'affichage de page, tout va bien, mais quand c'est un lien qui mène vers une action comme une suppression ou un update directement depuis le backend, ça peut être embêtant. Tu pourras désactiver ce préchargement au besoin dans la balise de lien concernée en y ajoutant l'attribut data-turbo-prefetch="false".

Notre première mission sera de faire notre filtre sur les selects du menu pour pouvoir filtrer nos recettes. On va démarrer par celui des Pays.

Pour la partie qui va suivre, je me suis basé sur la doc de Turbo.

 Notre premier Turbo Stream

Déjà, un Turbo Stream, c'est quoi ?

C'est une réponse déjà formatée en HTML que le navigateur va directement intégrer dans le DOM.

À l'intérieur, il y a des instructions pour indiquer ce qu'il faut faire avec ce HTML : par exemple "remplacer", "ajouter", etc.

Grâce à ça, le navigateur sait exactement comment mettre à jour la page sans rechargement complet.

Occupons nous de préparer le terrain pour notre turbo stream du select.

Déjà, on va le transformer en formulaire pour faire ça proprement et ça se passe dans dans templates/partials/_countries.html.twig.

<form method="POST">
    <select class="select w-fit appearance-none" aria-label="select" onchange="this.form.requestSubmit()">
        <option disabled selected>Selectionne un pays</option>
        {% for country in getCountries() %}
            <option value="{{ country.id }}">{{ country.emoji }} {{ country.name }} {{ country.emoji }}</option>
        {% endfor %}
    </select>
</form>

On y ajoute onchange="this.form.requestSubmit()" pour éviter d'ajouter un bouton submit histoire de laisser le menu épuré et de garantir que Turbo interceptera la soumission.

Maintenant on va devoir lui indiquer une route vers laquelle le formulaire va pointer. Pour ça direction notre HomeController.php.

    #[Route('/list/{filter}', name: 'app_recipes_filter', requirements: ['filter' => 'country|category'], methods: ["POST"])]
    public function listByFilter(string $filter, RecipeRepository $recipeRepository, Request $request): Response
    {
	    // ...
    }

J'ai ajouté aussi un requirements afin de rendre cette fonction réutilisable pour notre select des categories et faire le travail une seule fois.

Notre <form> ressemble donc à ceci maintenant :

<form action="{{ path('app_recipes_filter', {filter: "country"}) }}" method="POST">
    <select class="select w-fit appearance-none" aria-label="select" onchange="this.form.requestSubmit()">
    ....

Jusqu’ici finalement, rien ne change. On va à présent créer notre filtre via le repository.

    public function findByCountry(int $id)
    {
        return $this->createQueryBuilder('r')
            ->leftJoin('r.country', 'c')
            ->andWhere('c.id = :id')
            ->setParameter('id', $id)
            ->orderBy('r.id', 'DESC')
            ->getQuery()
            ->getResult();
    }
    public function findByCategory(int $id)
    {
        return $this->createQueryBuilder('r')
            ->leftJoin('r.category', 'c')
            ->andWhere('c.id = :id')
            ->setParameter('id', $id)
            ->orderBy('r.id', 'DESC')
            ->getQuery()
            ->getResult();
    }

et l'ajouter dans notre route :

    #[Route('/list/{filter}', name: 'app_recipes_filter', requirements: ['filter' => 'country|category'], methods: ["POST"])]
    public function listByFilter(string $filter, RecipeRepository $recipeRepository, Request $request): TurboStreamResponse
    {
        $id = $request->request->get($filter, 1);

        $recipes = match ($filter) {
            'country' => $recipeRepository->findByCountry($id),
            'category' => $recipeRepository->findByCategory($id),
            default => []
        };
    }

Maintenant que l'on récupère bien toutes nos recettes via le select, notre rôle va être de les afficher sur la page d’accueil à la place de "Les derniers articles".

Pour ça, on va coller deux ids qui vont nous permettre de jouer avec les turbo-stream.

Dans /templates/pages/home/index.html.twig, on ajoute id="title" et id="recipes-list"

{% block body %}
    <div class="container mx-auto px-4 py-8">
        <h1 class="text-3xl font-bold" id="title">Les dernières recettes</h1>

        <div class="grid grid-cols-1 sm:grid-cols-2 w-2/3 mx-auto gap-x-12 gap-y-3" id="recipes-list">
            {% for recipe in recipes %}
                {% include 'partials/_recipe_card.html.twig' with {recipe: recipe} %}
            {% endfor %}
        </div>
    </div>
{% endblock %}

Maintenant que le terrain est prêt, on va créer notre TurboStream qui remplacera la liste des derniers articles.

On crée un fichier _list.stream.twig dans pages/home/streams/ (on crée aussi le dossier).

Dans ce stream on va recréer une partie de notre HTML qui remplacera l’existant. Pour cela, on va découvrir l'utilisation de deux nouvelles balises html : <turbo-stream> et <template>. La première comme tu t'en doutes permet d'indiquer au navigateur que c'est un turbo stream et ce qu'il doit faire avec tandis que la seconde est juste le contenu de ce qu'il doit insérer.

<turbo-stream action="replace" target="title">
    <template>
        <h1 class="text-3xl font-bold" id="title">{{ title|default('La recherche') }}</h1>
    </template>
</turbo-stream>

target permet de cibler l’élément HTML (l' qu'on a rajouté juste au dessus) présent dans le navigateur et action indique au navigateur quelle action faire dessus.

Ici on cible donc l’id title et on lui demande de remplacer son contenu par tout ce qui est dans le tag template.

On fait exactement pareil pour les recettes que l’on a récupéré :

<turbo-stream action="replace" target="recipes-list">
    <template>
        <div id="recipes-list" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 px-4">
            {% for recipe in recipes %}
                {% include 'partials/_recipe_card.html.twig' with { recipe: recipe } %}
            {% else %}
                <p class="text-gray-600">Aucune recette trouvée.</p>
            {% endfor %}
        </div>
    </template>
</turbo-stream>

C'est un fichier Twig, il va donc falloir l'envoyer dans une Response. Mais pas n'importe laquelle, une TurboStreamResponse (c'est une classe qui à pour parent Response) mais qui indique au navigateur que c'est un turbostream et qu'il doit le traiter comme tel.

return new TurboStreamResponse($this->renderView('pages/home/_list.stream.twig', [
    'recipes' => $recipes,
    'title' => count($recipes) > 0 ? match ($filter) {
    'country' => $recipes[0]?->getCountry()->getName(),
    'category' => $recipes[0]?->getCategory()->getName(),
    } : ""
]));

On récupère finalement notre _list.stream.twig créé juste au dessus et on lui donne les variables nécessaires.

A présent on fait de même avec le select des catégories qui finalement sera le même, on a juste à mettre à jour notre form (templates/partials/_categories.html.twig).

<form action="{{ path('app_recipes_filter', {filter: "category"}) }}" method="POST">
    <select name="category" class="select w-fit appearance-none" aria-label="select" onchange="this.form.requestSubmit()">
	    <option disabled selected>Selectionne un type</option>
	    {% for category in getCategories() %}
	        <option value="{{ category.id }}">{{ category.name }}</option>
	    {% endfor %}
	</select>
</form>

Voilà, notre premier Turbo Stream est terminé !

Grâce à lui, nous pouvons maintenant mettre à jour des parties de notre application sans écrire une seule ligne de JavaScript.

Je te laisse jeter un œil à la documentation pour les Turbo Frames, qui se ressemblent mais diffèrent par leur usage. Une Turbo Frame sera plutôt utilisé pour de la navigation, tandis qu’un Turbo Stream sert davantage à la mise à jour d’éléments existants comme on vient de le faire ici.

 Stimulus : le JavaScript sous stéroïdes

Pour cette partie, je me suis basé sur la documentation officielle de Stimulus.

Nous venons de voir Turbo, la première partie de ce duo. Voyons maintenant son acolyte : Stimulus.

Stimulus va nous permettre d'écrire du JavaScript de manière très pratique, parfois comparable à du HTMX, en ajoutant simplement des attributs dans nos balises HTML.

Pour l'exemple, rendez-vous sur la page /nouvelle-recette qui contient le formulaire d’ajout des recettes.

Sur cette page, nous avons un champ "Titre de la recette". L'idée est de prévenir l'utilisateur si le titre dépasse 25 caractères afin d'éviter toute frustration de l'utilisateur.

Let's gooooo !

On commence par créer un controller Stimulus dans assets/controllers, avec le nom de notre controller. Ici, nous l'appellerons form_controller.js.

La convention de Stimulus pour l'autoloading des controllers est d’écrire le nom en kebab-case, suivi de _controller.js.

On démarre toujours comme ceci.

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
    connect() {
	    console.log("Baaaaaby Shark ! 🦈");
    }
}

Ok … "toujours comme ceci" c'est peut être un peu too much. On peut enlever le console.log 😅. Mais j'aime bien voir que mon controller s'initialise bien en console (et aussi pour te mettre la musique dans ta tête pendant 3 jours).

On importe donc notre classe Controller qu’on vient extends (un peu comme notre AbstractController de Symfony) sur le notre.

On doit maintenant importer notre controller dans notre twig afin de pouvoir l'utiliser.

Dans templates/pages/recipe/index.html.twig, on l'injecte grace à l'attribut "data-controller" :

{{ form_start(form, {attr: {class: "flex flex-col sm:grid sm:grid-cols-2 gap-6 sm:px-8", "data-controller": 'form'}}) }}

De cette manière, le controller va surveiller toutes les actions qui se trouve à l'intérieur de la balise <form>

Ouvre l'inspecteur et refresh la page et hop ! doo doo doo doo doo doo. Baby shark.

console de l'inspecteur

On va maintenant surveiller les différentes frappes du clavier de notre input Titre. Pour ça, on va créer notre fonction dans le controller : 

    warnUserIfExceed(event) {
        console.log(event);
    }

Et ensuite on rattache notre input à cette fonction, on créer un data-action sur cet input, une sorte de addEventListener().

{{ form_widget(form.title, {attr: {"data-action": "form#warnUserIfExceed"}}) }}

pour la nomenclature, c'est l’action, le controller puis la fonction. Comme ceci :

action->controller#fonction.

Un grand nombre d'éléments possède une action native qui lui est liée. Ici les inputs ont l’action "input" liée donc pas besoin de spécifié l'action dans le data-action.

Sur un select par exemple, c'est le "change" qui est lié d’office.

Ici si l'on veut surcharger cette action, on pourrait faire ceci et elle ne s'exécuterait qu’au clic sur l'input:

{{ form_widget(form.title, {attr: {"data-action": "click->form#warnUserIfExceed"}}) }}

Voici la partie de la documentation où tu trouveras les actions natives : https://stimulus.hotwired.dev/reference/actions

On refresh la page, et maintenant à chaque frappe de clavier, on à un event récupéré. C'est exactement pareil qu’un addEventListener('input') finalement.

console de l'inspecteur

On va pouvoir récupérer notre "target" en destructurant event en {target} et ainsi récupérer la value.

export default class extends Controller {

    warnUserIfExceed({target}) {
        if(target.value.length === 25) {
            target.classList.add('is-invalid');
        } else {
            target.classList.remove('is-invalid');
        }
    }
}

On a à présent notre input en rouge pour avertir, mais pas vraiment de message, on va en rajouter un juste en dessous.

<div class="my-1">
    {{ form_label(form.title) }}
    {{ form_widget(form.title, {attr: {"data-action": "form#warnUserIfExceed"}}) }}
    <span data-form-target="titleError" class="text-error font-bold text-sm"></span>
</div>

data-form-target="titleError" permet d'avoir accès à l'élément dans le controller mentionné dans l'attribut (ici form_controller.js).

On l'ajoute ensuite dans notre controller :

static targets = [ "titleError" ]

warnUserIfExceed({target}) {
    if(target.value.length === 25) {
        target.classList.add('is-invalid');
        this.titleErrorTarget.textContent = "Le titre ne peut exécder 25 caractères.";
    } else {
        target.classList.remove('is-invalid');
        this.titleErrorTarget.textContent = "";
    }
}

De cette manière, on y accède avec this.titleErrorTarget. C’est la même chose qu’un querySelector en JS vanilla.

L'input est bien surveillé
L'input est bien surveillé

L'utilisateur est maintenant avertit que son texte est trop long et n'est pas surpris d'être bloqué.

On va mettre en disabled le button de soumission tant que le formulaire n'est pas complet. C'est un peu plus sympa côté UX.

Pour ça, on récupére l'ensemble des inputs à remplir et vérifier à chaque action si les inputs sont remplis.

On va utiliser data-target en masse.

On ajoute pour chaque input obligatoire, "data-form-target": "inputRequired" dans ses attributs HTML.

On en profite aussi pour ajouter sur le bouton submit un target : "data-form-target": "submitBtn".

Et on oublie pas d’enregistrer tout ça dans le controller pour y avoir accès.

static targets = [ "titleError", "inputRequired", "submitBtn" ]

Maintenant on va faire une fonction pour surveiller chaque événements des inputs, et si les champs sont correctement remplis.

verifyIfAllInputsAreFilled() {
    const allFilled = this.inputRequiredTargets.every(input => input.value.trim() !== "");
    this.submitBtnTarget.disabled = !allFilled;
}

Pour chaque input, à nouveau on renseigne un data-action : form#verifyIfAllInputsAreFilled .

Notre fonction va alors vérifier à chaque événement si le bouton doit être disabled ou non.

Il y a énormément de possibilité avec Stimulus, je pourrais aussi t’évoquer le cas où tu peux passer des paramètres à tes fonctions :

// Dans le controller.js
redirectToPage({params}) {
	console.log(params.url)
}

// Dans ton Twig
<button type="button" data-form-url-param="{{ path('app_home')}}>Test d'un passage de params</button>

De cette manière tu peux passer des paramètres à la manière des props en React.

 

 Un Toast sur un plateau

Si on retourne dans RecipeController.php, on s'aperçoit qu’avant l’activation de Turbo, dans le try/catch d’insertion en base, j'avais ajouté un flash pour faire apparaître une alerte avant d'y avoir insérer les fonctions de Turbo.

$this->addFlash('error', 'Une erreur est survenue lors de l\'enregistrement de la recette.');

Hors la fonction $this-addFlash() vient stocker dans la session le toast mais à présent, avec le rendu asynchrone de Turbo, il n'est pas garantie que le toast soit rendu correctement. On va s'occuper de modifier ça en rendu dynamique via un turbo-stream.

On crée un service dans src/service/ToastService.php qui nous servira pour gérer les toasts.

class ToastService
{
    public function __construct(private readonly Environment $twig) {}
    public function successToast(string $message): TurboStreamResponse
    {
        return $this->sendToast($message, 'success');
    }
    public function errorToast(string $message): TurboStreamResponse
    {
        return $this->sendToast($message, 'error');
    }

    private function sendToast(string $message, string $type): TurboStreamResponse
    {
        return new TurboStreamResponse($this->twig->render('toast.stream.twig', [
            'type' => $type,
            'message' => $message
        ]));
    }
}

le stream twig : 

<turbo-stream target="toast" action="append">
    <template>
        <div
            data-controller="toast"
            data-toast-type-value="{{ type }}"
            data-toast-message-value="{{ message }}"
        >
        </div>
    </template>
</turbo-stream>

Nouveauté dans l'implémentation, on utilise les data-value pour injecter dans le controller des valeurs que l'on peut utiliser directement via static values situé notre controller.js que l'on verra juste après !

On remplace dans notre templates/base.html.twig la partie flash :

// on supprime ça 
{% for message in app.flashes('error') %}
    <div class="fixed top-4 left-1/2 transform -translate-x-1/2 bg-red-500 text-white px-4 py-2 rounded shadow-md z-50">
        {{ message }}
    </div>
{% endfor %}

// on remplace par ça
<div id="toast"></div>
// Même id que le turbo-stream pour faire le lien
De cette manière on peut remplacer notre addFlash() de app_recipe.
return $this->toastService->errorToast("Une erreur est survenue lors de l'enregistrement de la recette.");

Pour le front, on installe Notyf comme indiqué dans la doc de FlyonUI puis on se crée notre assets/controllers/toast_controller.js :

import { Controller } from "@hotwired/stimulus"
import { Notyf } from 'notyf';

export default class extends Controller {
    // Ici les values passés depuis le stream twig
    static values = {
        type: String,
        message: String,
        duration: { type: Number, default: 3000 }
    }

    connect() {
        const notyf = new Notyf({
            duration: this.durationValue,
            position: { x: 'right', y: 'top' },
            dismissible: true
        });
        switch(this.typeValue) {
            case "success":
                notyf.success(this.messageValue);
                break;
            case "error":
                notyf.error(this.messageValue);
                break;
            default:
                notyf.open({ type: 'info', message: this.messageValue });
                break;
        }
    }
}

On test à présent avec une erreur dans le try/catch : 

Test d'une erreur try/catch

et ça nous retourne bien le toast rendu dynamiquement.

Toast dynamique

De cette manière on a un système de toast dynamique, réutilisable dans nos projets !

Nous voilà à la fin de cette démonstration de Symfony + Stimulus/Turbo. J'espère que ça t'aura stimulé et te donnera envie d'essayer ! (#JeuDeMotsPourri 😅)

Tu peux, si tu le souhaites, améliorer le projet. De toute façon, il est public, et je compte aussi, de mon côté, gérer la partie "Update" des recettes que je n'ai pas traitée dans ce tutoriel, sinon ça serait beaucoup trop long à lire (et encore… là déjà ... 😅).

Pour ma part, après avoir testé plusieurs langages et frameworks (notamment ReactJS puis Next.js), je ne reviendrai pas en arrière depuis que j'ai adopté Stimulus + Turbo dans mes projets Symfony. Grâce à ce duo, on dispose d'un framework PHP complet et moderne. Et encore, je n'ai même pas abordé la possibilité de faire du rendu en temps réel grâce à Mercure, mais ça mériterait un article à lui tout seul !

Je sais que c'était un peu technique, mais après un ou deux projets réalisés avec cette approche, on a du mal à s’en passer.

Je ne peux que t'encourager de parcourir les docs de Stimulus et de Turbo pour approfondir le sujet !

C'est aussi la première fois que je mets un projet GitHub en lien avec mon blog. J'espère que ce ne sera pas trop compliqué à suivre ! Je serais curieux d'avoir vos retours à ce sujet.