WordPress
27 août 2025

Pourquoi fait-on toujours du Twig (et pas du React) pour certaines interfaces métiers

Pourquoi fait-on toujours du Twig, et pas du React ?

« Pourquoi vous ne partez pas sur React ? » On nous l’a demandé assez souvent pour avoir cessé de répondre dans l’abstrait. Le débat React contre Twig, pris en général, ne se tranche pas : les deux camps ont des arguments valables et personne ne change d’avis.

Ce qui se tranche, en revanche, c’est un projet précis avec des contraintes précises. Voici deux des nôtres, où la décision s’est jouée sur des critères dont on parle rarement, et où ni la performance ni le SEO n’ont été l’argument décisif. Suivi de ce que ce choix nous a réellement coûté, parce qu’un article qui n’énumère que ses succès ne vaut pas grand-chose.

Au programme :

  • Pourquoi une SPA aurait publié l’annuaire de revendeurs d’un client B2B
  • Ce qu’un algorithme de prix dupliqué côté client fait vraiment à un client final
  • La protection XSS de Twig n’est pas contextuelle, elle dépend du nom de fichier
  • La question qui remplace utilement « React ou Twig »
  • Trois endroits où notre choix nous a coûté

Opal : l’argument décisif n’était ni la performance ni le SEO

Opal vend des lunettes aux opticiens et anime un réseau de plus de 10 000 points de vente. Le besoin était un store locator permettant de trouver un revendeur et de filtrer par localisation et par marque, synchronisé avec l’ERP, et déployable sur plusieurs sites du groupe tournant les uns sous PrestaShop, les autres sous WordPress, avec des chartes graphiques distinctes.

On a livré un module PrestaShop, un module WordPress et une API Symfony connectée à l’ERP, avec un rendu serveur. Trois raisons, dans l’ordre de poids réel.

Une SPA aurait exposé l’annuaire complet

C’est l’argument qui a emporté la décision, et il n’est pas technique. Un store locator en React a besoin d’un endpoint qui renvoie les revendeurs, avec leurs coordonnées, pour que le navigateur puisse les filtrer et les afficher. Cet endpoint est public par construction : tout ce que le navigateur peut appeler, un script peut l’appeler.

Autrement dit, une SPA transforme un réseau de distribution de 10 000 points en un fichier JSON téléchargeable par n’importe qui, concurrents compris. On peut paginer, limiter par zone géographique, ajouter du rate limiting, mais on ne fait que ralentir une aspiration, on ne l’empêche pas. En rendu serveur, seule la page HTML correspondant à la recherche effectuée sort du serveur.

Pour une entreprise B2B dont le réseau de revendeurs est un actif commercial, ce n’est pas une préférence d’architecture, c’est une décision de direction.

Le multi-CMS n’est pas un problème de framework, c’est un problème de surface

Le second point est celui qu’on sous-estime toujours. Livrer une brique front sur deux CMS avec des chartes différentes, en React, c’est un build par hôte, un point de montage à insérer dans chaque thème, et une isolation CSS à tenir face à deux feuilles de style qui n’ont rien en commun. Chaque montée de version d’un thème rouvre le sujet.

En rendu serveur, le module écrit du HTML dans le thème hôte, et le thème hôte l’habille. Les classes viennent de la charte du site, pas d’un design system parallèle :

{# On ne porte aucune couleur ni typographie : elles viennent du thème hôte #}
<ul class="{{ theme_classes.list }}">
  {% for revendeur in revendeurs %}
    <li class="{{ theme_classes.item }}" data-lat="{{ revendeur.latitude }}" data-lng="{{ revendeur.longitude }}">
      <h3 class="{{ theme_classes.title }}">{{ revendeur.nom }}</h3>
      <address>{{ revendeur.adresse }}<br>{{ revendeur.codePostal }} {{ revendeur.ville }}</address>
      {% if revendeur.marques is not empty %}
        <p class="{{ theme_classes.tags }}">{{ revendeur.marques|join(', ') }}</p>
      {% endif %}
    </li>
  {% else %}
    <p>Aucun revendeur dans cette zone.</p>
  {% endfor %}
</ul>

Le même gabarit sert les deux CMS, seule la table de correspondance change. Chaque hôte déclare les classes de sa propre charte, et le module n’en connaît aucune :

// Côté module PrestaShop
final class ClassesThemePrestaShop implements FournisseurDeClasses
{
    public function pourLocalisateur(): array
    {
        return [
            'list'  => 'product-list row',
            'item'  => 'col-md-4 product-miniature',
            'title' => 'product-title h3',
            'tags'  => 'product-flags',
        ];
    }
}

// Côté plugin WordPress, même interface, autre charte
final class ClassesThemeWordPress implements FournisseurDeClasses
{
    public function pourLocalisateur(): array
    {
        return [
            'list'  => 'wp-block-columns',
            'item'  => 'wp-block-column card',
            'title' => 'card__title',
            'tags'  => 'card__meta',
        ];
    }
}

Ajouter un troisième site du groupe revient à écrire une implémentation de plus. C’est ce qui permet de tenir plusieurs chartes sans maintenir plusieurs front-ends.

Et pourtant, il y a du JavaScript

Soyons honnêtes : une carte est interactive, on ne l’écrit pas en Twig. Le JavaScript est bien là, il fait le déplacement de la carte, la mise en surbrillance au survol, le rafraîchissement des marqueurs.

La frontière qu’on a posée est la suivante : la liste et les filtres sont rendus serveur et restent fonctionnels sans JavaScript, la carte est une amélioration progressive. Un visiteur sans JS, un moteur de recherche ou un lecteur d’écran obtiennent la liste des revendeurs. Ceux qui ont le JS obtiennent la carte en plus. Ce n’est pas un compromis, c’est la définition du périmètre.

BACK IPLN : quand l’argent est en jeu, la règle métier ne se duplique pas

BACK IPLN est une plateforme de rachat de matériel photo et vidéo. Un particulier décrit son équipement, reçoit une évaluation, choisit d’être payé en virement ou en bon d’achat, édite un bon d’envoi Chronopost et suit son dossier. Le tout avec plus de dix statuts par reprise et un système de contre-offre.

Un algorithme d’évaluation tarifaire est au cœur du dispositif. C’est précisément ce qui a fermé le débat.

Dans une architecture découplée, la tentation est immédiate : recalculer le prix côté client pour l’afficher instantanément pendant que l’utilisateur remplit le formulaire. Et à partir du moment où la règle existe à deux endroits, elle diverge. Pas le jour de la livraison, six mois plus tard, quand on ajuste une décote côté serveur en oubliant le bundle front.

Le symptôme est alors le pire qui soit sur un service qui manipule de l’argent : un client voit un montant à l’écran et en reçoit un autre. Ce n’est pas un bug d’affichage, c’est une promesse commerciale rompue, et le support ne peut même pas expliquer l’écart.

Le rendu serveur règle la question en la supprimant. Il n’existe qu’un seul endroit où le prix se calcule, et l’affichage n’en est qu’une conséquence :

final class EvaluationController extends AbstractController
{
    public function __construct(
        private readonly CalculateurDeReprise $calculateur,
    ) {}

    #[Route('/reprise/evaluation', name: 'reprise_evaluation', methods: ['GET', 'POST'])]
    public function __invoke(Request $request): Response
    {
        $form = $this->createForm(DescriptionMaterielType::class);
        $form->handleRequest($request);

        $offre = null;
        if ($form->isSubmitted() && $form->isValid()) {
            // Seule et unique source de vérité pour le montant proposé
            $offre = $this->calculateur->evaluer($form->getData());
        }

        return $this->render('reprise/evaluation.html.twig', [
            'form' => $form,
            'offre' => $offre,
        ]);
    }
}

Les contraintes de validation vivent sur le DTO, donc à un seul endroit elles aussi. Un champ qui devient obligatoire ou une borne de prix qui change ne demandent aucune synchronisation avec un second dépôt.

Là encore, du JavaScript est présent pour l’auto-complétion et les champs conditionnels. Mais il n’a aucune autorité : il rend la saisie agréable, il ne décide de rien.

Ce que « Twig échappe automatiquement tout » ne dit pas

C’est l’argument sécurité qu’on répète partout, y compris dans la version précédente de cet article, et il est juste dans les grandes lignes et faux dans le détail qui compte.

Symfony ne demande pas à Twig d’échapper en HTML. Il lui demande de deviner la stratégie à partir du nom du fichier :

// symfony/twig-bundle, DependencyInjection/TwigExtension.php
if (isset($config['autoescape_service'])) {
    $config['autoescape'] = [new Reference($config['autoescape_service']), ...];
} else {
    $config['autoescape'] = 'name';
}

La valeur 'name' renvoie à FileExtensionEscapingStrategy, dont la logique est explicite :

// twig/twig, src/FileExtensionEscapingStrategy.php
switch ($extension) {
    case 'js':
    case 'json':
        return 'js';

    case 'css':
        return 'css';

    case 'txt':
        return false;   // échappement désactivé

    default:
        return 'html';
}

Deux conséquences concrètes.

Un gabarit .txt.twig n’échappe rien du tout. C’est exactement l’extension qu’on utilise pour les versions texte des e-mails. La « protection native » ne couvre donc pas vos gabarits d’e-mail en texte brut, où de la donnée utilisateur non filtrée se retrouve telle quelle.

La stratégie est choisie par fichier, pas par contexte. Dans un .html.twig, un {{ var }} placé dans un attribut ou dans un bloc <script> reçoit l’échappement html, qui n’est pas le bon dans ces deux cas. Le commentaire du cœur de Twig est sans ambiguïté sur ce point : la stratégie est fixée à la compilation, elle ne s’adapte pas à l’endroit où la variable est posée.

{# Insuffisant dans un attribut non quoté ou dans du JS #}
<div data-ville="{{ ville }}">
<script>const v = "{{ ville }}";</script>

{# Correct #}
<div data-ville="{{ ville|e('html_attr') }}">
<script>const v = {{ ville|json_encode|raw }};</script>

L’échappement automatique de Twig reste un vrai avantage sur un rendu manuel, mais c’est un filet de sécurité, pas une garantie. Le présenter comme une protection totale, c’est encourager à ne plus y penser, ce qui est précisément la condition pour se faire avoir.

La question qui remplace le débat

Après ces deux projets et quelques autres, on a arrêté de raisonner en « React ou Twig ». La question qui décide vraiment est celle-ci :

Cet état doit-il survivre à une navigation, et fait-il autorité ?

Un montant de reprise, un statut de dossier, un stock, une liste de revendeurs : cet état fait autorité, il engage l’entreprise, et il doit survivre à un rechargement, à un partage de lien, à un retour arrière. Sa place est côté serveur, et le rendu serveur est le chemin le plus court entre lui et l’écran.

La position d’une carte, un panneau de filtres ouvert, une saisie en cours, une animation : cet état est éphémère, il n’engage rien, et personne ne s’attend à le retrouver après un rechargement. Sa place est côté client, quel que soit l’outil.

Formulée ainsi, la décision se prend fonctionnalité par fonctionnalité au lieu de se prendre projet par projet. Et elle explique pourquoi nos deux projets « en Twig » contiennent tous les deux du JavaScript sans que ce soit une contradiction.

Où ce choix nous a coûté

Trois endroits, pour être complet.

Le JavaScript écrit à la main finit par s’accumuler. Tant qu’il s’agit d’auto-complétion et de champs conditionnels, tout va bien. Sur un enchaînement vraiment interactif comme une contre-offre, où l’utilisateur et l’acheteur se répondent, on écrit progressivement une gestion d’état sans en avoir le vocabulaire ni les outils. C’est la partie qui a le moins bien vieilli, et un composant React y aurait été plus lisible. On la traiterait aujourd’hui avec un composant serveur interactif plutôt qu’en repartant de zéro, mais l’honnêteté oblige à dire que le rendu serveur seul ne suffisait pas.

On hérite des bugs du thème hôte. C’est le revers exact de l’avantage multi-CMS. Puisque le module s’habille avec la charte du site, il subit aussi ses collisions de sélecteurs, ses règles trop génériques et ses versions de bibliothèques. Une isolation à la React aurait coûté cher au départ et évité une partie de ces frictions ensuite.

Le marché forme des développeurs front sur React. Une base de code très orientée rendu serveur ne réduit pas le vivier de recrutement, elle le déplace : on cherche des profils PHP à l’aise avec le HTML et le CSS, pas des intégrateurs React. C’est faisable, ce n’est pas gratuit, et il faut le savoir avant de s’engager sur dix ans de maintenance.

Notre position

React est un excellent outil et nous en faisons quand l’interface le justifie : mise à jour temps réel sans rechargement, expérience proche du natif, réutilisation d’une base de code vers du mobile, architecture réellement découplée entre deux équipes distinctes.

Ce qui nous gêne n’est pas React, c’est la décision prise avant l’analyse. Sur Opal, l’analyse a montré qu’une SPA revenait à publier un annuaire commercial. Sur IPLN, qu’elle revenait à dupliquer un calcul de prix. Aucun des deux arguments n’apparaît dans les comparatifs qu’on lit habituellement, parce qu’ils ne sortent pas d’un tableau de fonctionnalités, ils sortent du métier du client.

Questions fréquentes

Twig protège-t-il vraiment des failles XSS ?
En grande partie, mais pas totalement. Symfony configure Twig avec la stratégie 'name', qui déduit l’échappement de l’extension du fichier : html par défaut, js pour .js.twig et .json.twig, css pour .css.twig, et aucun échappement pour .txt.twig. À l’intérieur d’un fichier, la stratégie ne s’adapte pas au contexte : un attribut réclame |e('html_attr') et un bloc script réclame un encodage JSON.

Un rendu serveur interdit-il une interface dynamique ?
Non. Nos deux projets contiennent du JavaScript. La règle est que le client gère l’état éphémère (carte, filtres ouverts, saisie en cours) et que l’état faisant autorité reste côté serveur.

Pourquoi ne pas simplement faire du SSR avec React ?
C’est une option valable quand une équipe front existe déjà. Sur un module destiné à s’intégrer dans un thème PrestaShop et dans un thème WordPress, elle ajoute un runtime Node à déployer et à maintenir en plus du PHP, pour un bénéfice qui ne compensait ni l’exposition de l’annuaire ni le coût d’intégration.

Le rendu serveur est-il plus rapide ?
Sur le premier affichage, généralement oui, puisque le navigateur reçoit du HTML prêt à peindre au lieu d’un conteneur vide à hydrater. Sur les interactions suivantes, une SPA bien faite reprend l’avantage. C’est un arbitrage, pas une supériorité, et ce n’est pas le critère qui a décidé sur nos deux projets.

Comment éviter de dupliquer les règles métier ?
En n’ayant qu’une implémentation, côté serveur, et en traitant tout calcul affiché au client comme un affichage et non comme une décision. Sur un service qui manipule de l’argent, c’est non négociable : le montant montré doit être celui qui sera payé.

Vous hésitez sur l’architecture front d’un projet métier et les arguments qu’on vous donne ressemblent à des préférences ? Posez d’abord les deux questions qui décident : quel état fait autorité, et qu’est-ce que votre API rendrait public. On peut en parler.

Nous vous recommandons aussi