Le 30/03/2025 dans symfony

Parler d'HTMX à Symfony Live 2025

J’ai eu la chance de pouvoir parler d’une librairie JavaScript que j’affectionne particulièrement en ce moment, surtout vis à vis de ma fatigue du JavaScript (je n’ai jamais trop pris le virage du full React / Thick Client – j’aime le backend !).

Cette librairie c’est HTMX : HyperText Markup eXtensions.

Elle étend HTML pour le rendre plus puissant, capable de faire des appels HTTP plus simplement – de rendre nos application backend dynamiques et chouette à utiliser.

C’était un challenge, je n’avais pas donné de conférence IRL depuis 5 ans, et depuis ma vie à changé, je n’ai plus de temps libre 👶 – la préparation m’a donc demandé 2 mois ! 🤣

J’avais quelques doutes sur la pertinence de mon sujet – surtout dans le contexte de Symfony. En effet, l’initiative Symfony UX, maintenant officiellement intégrée au framework, promeut une librairie différente : Hotwire Turbo. Mais j’ai eu de chouettes retours.

Les deux approches sont similaires, mais les implémentations et leurs conséquences très différentes, et HTMX à ma préférence.

J’espère avoir aussi ouvert les yeux aux développeurs qui ne connaissent ni l’un ni l’autre : ces deux outils font vraiment gagner du temps et permettent aux développeurs backend de couvrir un scope fonctionnel bien plus grand en restant fidèle à Twig / au back pur.

Etant donné que j’utilise Sli.dev, il n’y a pas de SSR / de contenu indexable par les moteurs de recherche – je met donc ici même un « dump » du contenu Markdown. Je conseil plutôt la lecture des slides.

La complexité du Web d’aujourd’hui

On ne fait plus des sites perso en .php3

L’avènement des SPA

Pour répondre à nos besoins d’interactivité

  • Notre backend répond du JSON et non plus du HTML
  • JS pour le router, et gestion de la navigation
  • JS pour le moteur de rendu
  • JS pour certains éléments UI
  • JS pour gérer le state
  • Séparation des équipes back & front

🐋 Approche client lourd
« Thick Client »

  • HTML n’est qu’un outil de rendu
  • Tous les comportements sont en JavaScript
  • Contrat entre le back et le front
  • Véritable « plateforme »

🧚 Approche HTMX
Hotwire Turbo

  • HTML traité comme Hypermedia As The Engine of Application State (HATEOAS)
  • Enrichissement de HTML
  • Le back seul responsable
  • Repose sur les fondamentaux du Web

HTMX

Librairie JavaScript pour ne pas écrire de JavaScript

  • HyperText Markup eXtensions
  • Première version en 2020
  • Réécriture d’intercooler.js (jQuery, 2013)
  • Zéro dépendance
  • Backend agnostic
  • 50.9 kB tout mouillé

Corriger HTML pour le rendre Hypermédia

La mission d’HTMX

  • Pourquoi seuls <a> et <form> sont capables de faire des requêtes HTTP ?
  • Pourquoi seuls les événements click et submit peuvent déclencher ces requêtes ?
  • Pourquoi seuls GET et POST devraient être dispo ?
  • Pourquoi on devrait toujours remplacer tout l’écran ?

Installer HTMX

  • Vendoring recommandé !
  • NPM, CDN…
<script src="/js/vendor/htmx-2.0.4.min.js"></script>

<!-- Ou -->

<script src="https://unpkg.com/htmx.org@2.0.4"></script>

C’est juste UN fichier.

👉 https://htmx.org/essays/vendoring/

hx-get et les autres

<button 
    hx-post="/clicked"
>Cliquez moi</button>

Cœur de l’outil, on précise quelle ressource récupérer du serveur :

  • hx-get = GET
  • hx-post = POST
  • hx-delete = DELETE
  • hx-patch = PATCH
  • hx-put = PUT

Cet appel Ajax est provoqué par un « trigger ».

hx-trigger pour déclencher

<button 
    hx-post="/clicked" 
    hx-trigger="mouseenter"
>Survol moi</button>

On change le trigger pour utiliser « au survol de la souris » 😜

Tous les événements JavaScript fonctionnent.

Bonus :

<button hx-get="/ping" hx-trigger="mouseenter once">
<button hx-post="/like" hx-trigger="click throttle:2s">
<input type="text" hx-trigger="input changed">
<span hx-get="/queue-position" hx-trigger="every 5s">

hx-target pour cibler

<button
    hx-get="/add10points" 
    hx-trigger="click" 
    hx-target="#total"
>Cliquez moi</button>
<div id="total"></div>

Vous pouvez cibler n’importe quel élément avec un sélecteur CSS
comme destination de la réponse HTTP.

Bonus :

  • closest <CSS selector>
  • next/prev <CSS selector>
  • find <CSS selector>

hx-swap pour contrôler

Il n’y a pas qu’innerHTML dans la vie.

Permet d’utiliser outerHTML, afterbegin, beforebegin, beforeend, afterend…
lors du remplacement dans le DOM.

<button
    hx-post="/clickOnceToWin" 
    hx-swap="outerHTML"
>

Bonus :

  • delete
  • none
  • ignoreTitle:true
  • scroll:top…

Exemple 1 : un bouton « like »

{% set isLiked = true %}
<button class="{{ isLiked ? 'text-rose-600' : 'text-neutral-500' }}">
    ❤
</button>

Besoin : faire un toggle persistent au click (like, unlike).

  • Construire un endpoint JSON
  • Réviser les Promise et l’API de fetch()
  • Utiliser Stimulus / jQuery / React… pour mettre à jour la vue
  • Avoir le nom de la classe CSS « liked » à deux endroits
  • Télécharger 318 dépendances JavaScript
  • Installer Webpack pour compiler ça

Exemple 1 : un bouton « like »

{% block like %}
	<button class="{{ isLiked ? 'text-rose-600' : 'text-neutral-500' }}"
        hx-post="{{ path('app_like_toggle') }}"
        hx-swap="outerHTML"
    >❤</button>
{% endblock %}
  • au click, un POST est envoyé sur app_like_toggle
  • la réponse du POST remplace le bouton entièrement

Côté backend

L’action HTMX :

#[Route('/like/toggle', methods: ['POST'])]
public function toggle(Request $request): Response
{
    $request->getSession()->set(
        'isLiked',
        !$request->getSession()->get('isLiked', false)
    );

    return $this->renderBlock('like/index.html.twig', 'like', [
        'isLiked' => $request->getSession()->get('isLiked', false),
    ]);
}
  • Stocke le nouvel état
  • Utilise renderBlock pour ne répondre QUE le bouton

Exemple 1 : et les animations ?

Remplacer le DOM == pas d’animations

La solution HTMX :

{% block like %}
	<button class="{{ isLiked ? 'text-rose-600' : 'text-neutral-500' }}"
        hx-post="{{ path('app_like_toggle') }}"
        hx-swap="outerHTML"
        id="something"
    >❤</button>
{% endblock %}

Avec un attribut id, HTMX se charge de conserver l’élément !

Exemple 2 : recherche As You Type

Afficher des résultats de recherche dynamiquement

On commence par le coder en full « back ».

  • Un <input type="search">
  • Un Controller
  • Une vue qui liste les résultats : /emoji?q=card

Controller :

public function index(Request $request): Response
{
    $q = $request->query->get('q');
    if ($q) {
        $results = // search for $q in the Emoji list
    } else {
        $results = [];
    }
    
    return $this->render('emoji/index.html.twig', [
        'results' => $results,
        'q' => $q,
    ]);
}

Twig :

<form action="{{ path('app_emoji_index') }}" method="get">
    <input type="search"
           name="q"
           placeholder="Begin Typing To Search..."
           autocomplete="off"
           value="{{ q }}"
    >

    <div id="search-results">
        {% block searchResults %}
            {% for emoji, label in results %}
                <div>
                    <span>{{ emoji }}</span>
                    <span>{{ label }}</span>
                </div>
            {% else %}
                <p>No results found.</p>
            {% endfor %}
        {% endblock %}
    </div>
</form>

Exemple 2 : recherche As You Type

Progressive Enhancement côté Twig

<form action="{{ path('app_emoji_index') }}" method="get">
    <input type="search"
           name="q"
           placeholder="Begin Typing To Search..."
           autocomplete="off"
           value="{{ q }}"
           hx-get="{{ path('app_emoji_index') }}"
           hx-trigger="input changed delay:200ms"
           hx-target="#search-results"
           hx-push-url="true"
    >

    <div id="search-results">
        {% block searchResults %}
            {% for emoji, label in results %}
                <div>
                    <span>{{ emoji }}</span>
                    <span>{{ label }}</span>
                </div>
            {% else %}
                <p>No results found.</p>
            {% endfor %}
        {% endblock %}
    </div>
</form>

Exemple 2 : recherche As You Type

Progressive Enhancement côté Controller

public function index(Request $request): Response
{
    $q = $request->query->get('q');
    if ($q) {
        $results = // search for $q in the Emoji list
    } else {
        $results = [];
    }
    
    if ($request->headers->has('hx-request')) {
      return $this->renderBlock('emoji/index.html.twig', 'searchResults', [
        'results' => $results,
        'q' => $q,
      ]);
    }
    
    return $this->render('emoji/index.html.twig', [
        'results' => $results,
        'q' => $q,
    ]);
}

Exemple 3 : navigation boostée

Exactement comme Turbo Drive

hx-boost rend tous les liens et formulaires enfants « Ajax ».

C’est du Progressive Enhancement !

<nav hx-boost="true">
    <a href="{{ path('app') }}">Home</a>
    <a href="{{ path('app_emoji') }}">Emoji</a>
</nav>
  • Fait un GET en Ajax
  • Remplace le DOM du <body> avec innerHTML
  • Met à jour le <head>, push dans l’historique.

Exemple 4 : Lazy load d’une portion de page

Quand vous avez mis un cache agressif sur votre site

Mais…

  • vous avez un formulaire (CSRF)
  • vous voulez afficher un panier ou un état « connecté »…
  • bref, un appel à la session

En Symfony, nous avons :

  • les ESI (Edge Side Includes, coucou Varnish)
  • les hinclude.js ! (qui connait ? 🤚🤚🤚)

Exemple 4 : Lazy load d’une portion de page

<p>
   Server time:
   <span 
       hx-get="{{ path('app_spa_time') }}" 
       hx-trigger="load">
       Chargement en cours
   </span>
</p>

hx-trigger se repose sur des événements, ici, on demande au chargement de la page.

  • Les cookies sont transmis.
  • Le JavaScript en réponse est exécuté.
  • Je peux mettre ma page en cache ! 🎉

Exemple 5 : scroll infini

  • Implémentez votre pagination comme d’habitude.
  • Laissez HTMX « append » le contenu du bouton « next » à votre liste !
{% for emoji, label in results %}
    <div class="m-3 text-center overflow-hidden"
    {% if loop.last and page < pages %}
        hx-get="{{ path('app_emoji_index', {
            q: q, page: (page + 1)
        }) }}"
        hx-trigger="revealed"
        hx-swap="afterend"
    {% endif %}
    >
        <span class="text-7xl block mb-2">{{ emoji }}</span>
        <span class="text-xs block font-mono">{{ label }}</span>
    </div>
{% else %}
    <p>No results found.</p>
{% endfor %}

Sur le dernier élément de la liste, ajoutez 3 attributs, c’est gagné 🎉

Exemple 6 : Formulaire

  • Pourquoi faire de la validation côté client quand le back le fait bien ?
  • Soumettre en Ajax pour un feedback direct !
  • Se passer du Post/Redirect/Get Pattern (PRG)

Cette redirection si le formulaire est soumis en Ajax, elle n’est plus utile.

Exemple 6 : Formulaire

En cas de succès

// Symfony
return $this->redirectToRoute('app_guestbook_success');

// HTMX
return $this->render('guestbook/success.html.twig');

En cas d’erreur

<meta name="htmx-config" content='{
    "responseHandling":[
        {"code":"422", "swap": true},
        {"code":"[45]..", "swap": false, "error":true},
        {"code":"...", "swap": true}
    ]
}' />

Ça dans Turbo Drive c’est natif 😉

Exemple 6 : Formulaire

  • Pas aussi « Fancy » ou granulaire que Symfony Live Controller
  • BEAUCOUP plus simple et proche du « vrai server side Symfony »
$htmxWayForm->handleRequest($request);
if ($htmxWayForm->isSubmitted() && $htmxWayForm->isValid()) {
    // Save
    $response = $this->render('guestbook/success.html.twig');

    // Not mandatory, only to be compliant with the "old way".
    $response->headers->set('HX-Push-Url', $this->generateUrl('app_guestbook_success'));

    return $response;
}
  • Réversible / facile à débugger — juste un attribut hx-boost=true
<form hx-boost="true" method="post" action="{{ path('app_guestbook_index') }}">
    {{ form_row(form.content) }}
    {{ form_row(form.email) }}
    {{ form_rest(form) }}
</form>

Exemple 7 : formulaire dynamique

Le FormType + FormEvents::POST_SUBMIT + FormEvents::PRE_SET_DATA

class DynamicType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('meal', EnumType::class, [
                'class' => Meal::class,
                'choice_label' => fn (Meal $meal): string => $meal->getReadable(),
                'placeholder' => 'Which meal is it?',
            ])
        ;

        $this->initializeListeners($builder);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => MealPlan::class]);
    }

    private function initializeListeners($builder): void
    {
        $formModifierPizza = function (FormInterface $form, ?Food $food = null) use ($builder): void {
            if ($food == Food::Pizza) {
                $builder->add('pizzaSize', EnumType::class, [
                    'class' => PizzaSize::class,
                    'placeholder' => 'What size pizza?',
                    'choice_label' => fn(PizzaSize $pizzaSize): string => $pizzaSize->getReadable(),
                    'required' => true,
                ]);
                $field = $builder->get('pizzaSize')->setAutoInitialize(false)->getForm();
                $form->add($field);
            }
        };

        $formModifierFood = function (FormInterface $form, ?Meal $meal = null) use ($builder, $formModifierPizza): void {
            $builder->add('mainFood', EnumType::class, [
                'class' => Food::class,
                'placeholder' => null === $meal ? 'Select a meal first' : \sprintf('What\'s for %s?', $meal->getReadable()),
                'choices' => $meal?->getFoodChoices(),
                'choice_label' => fn (Food $food): string => $food->getReadable(),
                'disabled' => null === $meal,
            ]);

            // auto initialize mimics FormBuilder::getForm() behavior
            $field = $builder->get('mainFood')->setAutoInitialize(false)->getForm();
            $form->add($field);

            $builder->get('mainFood')->addEventListener(
                FormEvents::POST_SUBMIT,
                function (FormEvent $event) use ($formModifierPizza): void {
                    $formModifierPizza($event->getForm()->getParent(), $event->getForm()->getData());
                }
            );
        };

        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (FormEvent $event) use ($formModifierFood, $formModifierPizza): void {
                $formModifierFood($event->getForm(), $event->getData()?->getMeal());
                $formModifierPizza($event->getForm(), $event->getData()?->getMainFood());
            }
        );

        $builder->get('meal')->addEventListener(
            FormEvents::POST_SUBMIT,
            function (FormEvent $event) use ($formModifierFood): void {
                $formModifierFood($event->getForm()->getParent(), $event->getForm()->getData());
            }
        );
    }
}

Exemple 7 : formulaire dynamique

Encore une autre approche HTMX

{% block form %}
    <form novalidate method="post" 
        hx-post="{{ path('app_dynamicform_index') }}" 
        hx-trigger="change submit" 
        hx-swap="outerHTML">
        {{ form_row(form.meal) }}
        {% if form.mainFood is defined %}
            {{ form_row(form.mainFood) }}
        {% endif %}
        {% if form.pizzaSize is defined %}
            {{ form_row(form.pizzaSize) }}
        {% endif %}
        {{ form_rest(form) }}
    </form>

    {% if app.request.isMethod('POST') %}
        <div id="result" hx-swap-oob="true">
            {{ block('result') }}
        </div>
    {% endif %}
{% endblock %}

Exemple 8 : afficher des SSE

La version HTMX de Turbo Stream

Avec une extension SSE pour HTMX (htmx-ext-sse) :

<div hx-ext="sse" 
  sse-connect="{{ mercure('csv:1234') }}" 
  sse-swap="message">
  The import is going to start
</div>

Le HTML de chaque Event sera placé dans notre <div> 😚

Des Pro Tips ©

Mes astuces

  • Il y a plusieurs façons de faire la même chose, adoptez-en une, et appliquez là partout
  • Visualisez tous les événements HTMX avec htmx.logAll()
  • Activez le support dans PHPStorm
  • Appuyez-vous sur le bundle tomcri/htmxfony (HtmxRequest, HtmxResponse…)
  • Pensez à la gestion de vos erreurs 💥

Plein d’autres features

  • Contrôler le scroll après un chargement Ajax
  • Synchroniser plusieurs requêtes
  • Plein d’events pour se brancher
  • Les « indicator » pour afficher des loaders Ajax
  • Les nombreux headers pour écraser les attributs
  • L’héritage
  • HyperScript
  • Extensions (SSE, WS, Preload…)

Avez-vous besoin d’HTMX ?

Ce n’est pas adapté à tous les projets

Super si 👍

  • CRUD-y / du bon vieux REST
  • Principalement texte, image, hypertext
  • Génération rapide de vos pages

No-go si 👎

  • Plein d’interdépendances dynamiques
  • Info à l’écran qui bouge beaucoup
  • Vous avez du offline
Vous pourrez toujours écrire du JavaScript (glue code) au besoin.

 

HTMX is boring

Et c’est bien comme ça

  • Boring tech ©

    • pas juste vieux (HTML, MySQL, jQuery, Twig, vanilla JS, PHP, Python…)
  • Du code qui fonctionnera toujours dans 10, 20, 30 ans.

  • Pas de nouvelles features

  • Jamais de BC Break

  • Une question ? VIEW SOURCE !

Et Hotwire Turbo / Stimulus ?

Ces outils ont leur place

  • on peut refaire 90% de Turbo Drive, Turbo Frames et Turbo Streams avec HTMX

  • Turbo Native non ✖

  • Moins magique, et on va plus loin avec moins de code

  • Comportements custom dans les deux

    • Turbo pousse Stimulus et s’intègre très bien avec
    • HTMX pousse Vanilla JS / HyperScript

Si vous utilisez Stimulus, utilisez Turbo !