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.
- Mes slides sont hébergé ici : https://jolicode.github.io/htmx-conf/
- Le code de la démo est ici : https://github.com/jolicode/symfony-htmx-demo
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
etsubmit
peuvent déclencher ces requêtes ? - Pourquoi seuls
GET
etPOST
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
= GEThx-post
= POSThx-delete
= DELETEhx-patch
= PATCHhx-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
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.
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 !