Aller au contenu

Templates Twig

Twig est le moteur de templates utilisé par Drupal depuis la version 8. C’est un langage de templating moderne, sûr et flexible qui sépare la logique métier (PHP) de la présentation (HTML).

Avant Drupal 8 : PHPTemplate (mélange PHP/HTML difficile à maintenir) Depuis Drupal 8 : Twig (séparation claire, sécurité renforcée)

  • Sécurité : Échappement automatique contre les attaques XSS
  • Syntaxe claire : Facile à lire et à maintenir
  • Héritage : Réutilisation et extension de templates
  • Filtres puissants : Transformation de données sans PHP
  • Séparation : Logique métier (PHP) vs présentation (Twig)
  • Performance : Templates compilés en PHP optimisé
AspectPHPTemplate (ancien)Twig (actuel)
SécuritéÉchappement manuelAutomatique
Syntaxe<?php echo $var ?>{{ var }}
LogiquePHP mélangé au HTMLSéparé en .theme
MaintenabilitéDifficileExcellente
Courbe d’apprentissageConnaissance PHPSyntaxe simple
{# Commentaire - ignoré dans le rendu #}
{{ variable }} {# Affiche une variable #}
{{ variable|filter }} {# Applique un filtre #}
{% if condition %} {# Structure de contrôle #}
{% endif %}
{% for item in items %} {# Boucle #}
{% endfor %}
  1. Copiez le fichier de services par défaut

    Fenêtre de terminal
    cp sites/default/default.services.yml sites/default/services.yml
  2. Éditez sites/default/services.yml

    parameters:
    twig.config:
    debug: true
    auto_reload: true
    cache: false
  3. Videz le cache

    Fenêtre de terminal
    drush cr
<!-- THEME DEBUG -->
<!-- THEME HOOK: 'node' -->
<!-- FILE NAME SUGGESTIONS:
* node--product--full.html.twig
* node--product.html.twig
* node--1.html.twig
* node.html.twig
-->
<!-- BEGIN OUTPUT from 'core/themes/olivero/templates/content/node.html.twig' -->
node.html.twig # Template générique
└── node--article.html.twig # Type de contenu spécifique
└── node--article--teaser.html.twig # + View mode
└── node--123.html.twig # Nœud spécifique
  1. node--{id}.html.twignode--123.html.twig
  2. node--{type}--{view-mode}.html.twignode--product--card.html.twig
  3. node--{type}.html.twignode--product.html.twig
  4. node--{view-mode}.html.twignode--teaser.html.twig
  5. node.html.twig → Template de base

Structure globale de la page :

{#
/**
* @file
* Theme override for the page template.
*/
#}
<!DOCTYPE html>
<html{{ html_attributes }}>
<head>
<head-placeholder token="{{ placeholder_token }}">
<title>{{ head_title|safe_join(' | ') }}</title>
<css-placeholder token="{{ placeholder_token }}">
<js-placeholder token="{{ placeholder_token }}">
</head>
<body{{ attributes }}>
<a href="#main-content" class="visually-hidden focusable skip-link">
{{ 'Skip to main content'|t }}
</a>
{{ page_top }}
{{ page }}
{{ page_bottom }}
<js-bottom-placeholder token="{{ placeholder_token }}">
</body>
</html>
{#
/**
* @file
* TailStore theme override for the page template.
*/
#}
<div class="layout-container">
<header class="site-header" role="banner">
<div class="header-inner container">
{{ page.header }}
<nav class="main-nav" role="navigation">
{{ page.primary_menu }}
</nav>
<div class="header-actions">
{{ page.secondary_menu }}
</div>
</div>
</header>
{% if page.highlighted %}
<div class="highlighted">
{{ page.highlighted }}
</div>
{% endif %}
{% if page.breadcrumb %}
<nav class="breadcrumb-nav" aria-label="Breadcrumb">
<div class="container">
{{ page.breadcrumb }}
</div>
</nav>
{% endif %}
<main role="main" id="main-content" class="site-main">
<div class="container">
{{ page.help }}
<div class="main-content-wrapper{% if page.sidebar %} has-sidebar{% endif %}">
<div class="content-area">
{{ page.content }}
</div>
{% if page.sidebar %}
<aside class="sidebar" role="complementary">
{{ page.sidebar }}
</aside>
{% endif %}
</div>
{% if page.content_below %}
<div class="content-below">
{{ page.content_below }}
</div>
{% endif %}
</div>
</main>
<footer class="site-footer" role="contentinfo">
<div class="container">
{% if page.footer_top %}
<div class="footer-top">
{{ page.footer_top }}
</div>
{% endif %}
{% if page.footer_bottom %}
<div class="footer-bottom">
{{ page.footer_bottom }}
</div>
{% endif %}
</div>
</footer>
</div>
{#
/**
* @file
* Theme override for Product full display.
*
* Available variables:
* - node: The node entity.
* - label: The node title.
* - content: All node items.
* - formatted_price: Prix formaté (depuis preprocess).
* - in_stock: Booléen de disponibilité.
* - is_on_sale: Produit en promotion.
* - old_price: Ancien prix si promo.
*/
#}
{% set classes = [
'node',
'node--type-' ~ node.bundle|clean_class,
node.isPromoted() ? 'node--promoted',
node.isSticky() ? 'node--sticky',
not node.isPublished() ? 'node--unpublished',
view_mode ? 'node--view-mode-' ~ view_mode|clean_class,
'product',
'product--full',
] %}
<article{{ attributes.addClass(classes) }}>
<div class="product__gallery">
{% if content.field_images|render %}
<div class="product__main-image">
{{ content.field_images.0 }}
</div>
{% if content.field_images|length > 1 %}
<div class="product__thumbnails">
{% for key, image in content.field_images if key matches '/^\\d+$/' %}
<button class="product__thumbnail" data-index="{{ key }}">
{{ image }}
</button>
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>
<div class="product__details">
{# Marque #}
{% if content.field_brand|render %}
<div class="product__brand">
{{ content.field_brand }}
</div>
{% endif %}
{# Titre #}
{{ title_prefix }}
<h1 class="product__title">{{ label }}</h1>
{{ title_suffix }}
{# Prix #}
<div class="product__pricing">
{% if is_on_sale and old_price %}
<span class="product__old-price">{{ old_price }}</span>
<span class="product__price product__price--sale">{{ formatted_price }}</span>
<span class="product__discount-badge">Promo</span>
{% else %}
<span class="product__price">{{ formatted_price }}</span>
{% endif %}
</div>
{# Disponibilité #}
<div class="product__availability">
{% if in_stock %}
<span class="badge badge--success">En stock</span>
{% else %}
<span class="badge badge--danger">Rupture de stock</span>
{% endif %}
</div>
{# Description courte #}
{% if content.field_short_description|render %}
<div class="product__short-description">
{{ content.field_short_description }}
</div>
{% endif %}
{# Tailles #}
{% if content.field_sizes|render %}
<div class="product__sizes">
<label class="product__option-label">Taille :</label>
<div class="product__size-options">
{% for size in node.field_sizes %}
<button class="size-btn" type="button" data-size="{{ size.entity.id }}">
{{ size.entity.label }}
</button>
{% endfor %}
</div>
</div>
{% endif %}
{# Couleurs #}
{% if content.field_colors|render %}
<div class="product__colors">
<label class="product__option-label">Couleur :</label>
<div class="product__color-options">
{% for color in node.field_colors %}
<button
class="color-btn"
type="button"
data-color="{{ color.entity.id }}"
style="background-color: {{ color.entity.field_color_code.value }}"
title="{{ color.entity.label }}"
></button>
{% endfor %}
</div>
</div>
{% endif %}
{# Actions #}
<div class="product__actions">
<div class="product__quantity">
<label for="quantity">Quantité :</label>
<input type="number" id="quantity" name="quantity" value="1" min="1" max="10">
</div>
<button
type="button"
class="btn btn--primary btn--lg product__add-to-cart"
data-product-id="{{ node.id }}"
{% if not in_stock %}disabled{% endif %}
>
<span class="btn__icon">🛒</span>
<span class="btn__text">Ajouter au panier</span>
</button>
<button type="button" class="btn btn--outline product__wishlist">
♡ Favoris
</button>
</div>
{# SKU #}
{% if content.field_sku|render %}
<div class="product__sku">
<strong>Référence :</strong> {{ content.field_sku }}
</div>
{% endif %}
{# Catégorie #}
{% if content.field_category|render %}
<div class="product__category">
<strong>Catégorie :</strong> {{ content.field_category }}
</div>
{% endif %}
</div>
{# Description complète #}
{% if content.field_description|render %}
<div class="product__full-description">
<h2>Description</h2>
{{ content.field_description }}
</div>
{% endif %}
</article>
{#
/**
* @file
* Theme override for Product card display.
*/
#}
{% set classes = [
'product-card',
is_on_sale ? 'product-card--sale',
not in_stock ? 'product-card--out-of-stock',
] %}
<article{{ attributes.addClass(classes) }}>
{# Badges #}
<div class="product-card__badges">
{% if is_on_sale %}
<span class="badge badge--danger">-{{ discount_percent }}%</span>
{% endif %}
{% if node.isPromoted() %}
<span class="badge badge--primary">Nouveau</span>
{% endif %}
</div>
{# Image #}
<a href="{{ url }}" class="product-card__image-link">
{% if content.field_images.0 %}
{{ content.field_images.0 }}
{% else %}
<img src="/themes/custom/tailstore/images/placeholder.jpg" alt="Image non disponible">
{% endif %}
</a>
{# Actions rapides #}
<div class="product-card__quick-actions">
<button class="quick-action quick-action--wishlist" title="Ajouter aux favoris">
</button>
<button class="quick-action quick-action--view" title="Aperçu rapide">
👁
</button>
</div>
<div class="product-card__info">
{# Marque #}
{% if node.field_brand.entity %}
<span class="product-card__brand">
{{ node.field_brand.entity.label }}
</span>
{% endif %}
{# Titre #}
<h3 class="product-card__title">
<a href="{{ url }}">{{ label }}</a>
</h3>
{# Prix #}
<div class="product-card__price">
{% if is_on_sale and old_price %}
<span class="price--old">{{ old_price }}</span>
<span class="price--current price--sale">{{ formatted_price }}</span>
{% else %}
<span class="price--current">{{ formatted_price }}</span>
{% endif %}
</div>
{# Couleurs disponibles #}
{% if node.field_colors|length > 0 %}
<div class="product-card__colors">
{% for color in node.field_colors|slice(0, 4) %}
<span
class="color-dot"
style="background-color: {{ color.entity.field_color_code.value }}"
title="{{ color.entity.label }}"
></span>
{% endfor %}
{% if node.field_colors|length > 4 %}
<span class="color-more">+{{ node.field_colors|length - 4 }}</span>
{% endif %}
</div>
{% endif %}
</div>
{# Bouton ajout panier #}
<button
class="product-card__add-to-cart btn btn--primary btn--full"
data-product-id="{{ node.id }}"
{% if not in_stock %}disabled{% endif %}
>
{% if in_stock %}
Ajouter au panier
{% else %}
Indisponible
{% endif %}
</button>
</article>
FiltreDescriptionExemple
|tTraduction{{ 'Hello'|t }}
|rawHTML non échappé{{ html_content|raw }}
|escapeÉchapper le HTML{{ user_input|escape }}
|lengthLongueur{{ items|length }}
|first / |lastPremier/dernier{{ items|first }}
|slice(0, 5)Sous-ensemble{{ items|slice(0, 5) }}
|join(', ')Concaténer{{ tags|join(', ') }}
|default('N/A')Valeur par défaut{{ value|default('N/A') }}
|clean_classClasse CSS valide{{ type|clean_class }}
|without('field_x')Exclure un champ{{ content|without('field_images') }}
|renderRendre un élément{% if content.field_x|render %}
|number_formatFormater un nombre{{ price|number_format(2, ',', ' ') }}
FonctionDescriptionExemple
url(route)Générer une URL{{ url('entity.node.canonical', {'node': node.id}) }}
path(route)Chemin sans domaine{{ path('user.page') }}
link(text, url)Créer un lien{{ link('Cliquez', url) }}
file_url(uri)URL d’un fichier{{ file_url(node.field_image.entity.fileuri) }}
attach_library()Charger une librairie{{ attach_library('tailstore/slider') }}
create_attribute()Créer des attributs{% set attr = create_attribute() %}

Exercice 1 : Créer un template page personnalisé

Section intitulée « Exercice 1 : Créer un template page personnalisé »

Objectif : Surcharger le template de page pour ajouter une structure HTML personnalisée.

  1. Localisez le template de base :

    Fenêtre de terminal
    # Trouver le template page.html.twig du thème de base
    find web/core/themes -name "page.html.twig"
  2. Copiez-le dans votre thème :

    Fenêtre de terminal
    mkdir -p themes/custom/tailstore/templates/layout
    cp web/core/themes/olivero/templates/layout/page.html.twig \
    themes/custom/tailstore/templates/layout/page.html.twig
  3. Modifiez le template pour ajouter une classe personnalisée :

    <div class="layout-container tailstore-layout">
    {# Votre structure personnalisée #}
    </div>
  4. Videz le cache :

    Fenêtre de terminal
    drush cr
  5. Inspectez le HTML (F12) : la classe tailstore-layout doit apparaître

Validation : ✅ La classe personnalisée est visible dans l’inspecteur


Objectif : Créer un template basique pour afficher un produit avec titre et prix.

  1. Créez le fichier :

    Fenêtre de terminal
    touch themes/custom/tailstore/templates/node/node--product.html.twig
  2. Ajoutez ce code minimal :

    <article class="product">
    <h2>{{ label }}</h2>
    {% if content.field_price|render %}
    <div class="price">
    {{ content.field_price }}
    </div>
    {% endif %}
    {{ content|without('field_price') }}
    </article>
  3. Videz le cache et visitez une page produit

Validation : ✅ Le produit s’affiche avec le nouveau template


Objectif : Manipuler des données avec les filtres Twig.

Tâches :

  1. Limiter la description :

    {# Afficher seulement les 100 premiers caractères #}
    {{ content.field_description|render|striptags|slice(0, 100) }}...
  2. Formater un prix :

    {# Dans tailstore.theme, préparez formatted_price #}
    {# Puis dans le template : #}
    <span class="price">{{ formatted_price }}</span>
  3. Afficher une liste de catégories :

    {% if node.field_categories %}
    <div class="categories">
    {% for category in node.field_categories %}
    <span class="badge">{{ category.entity.label }}</span>
    {% endfor %}
    </div>
    {% endif %}

Validation : ✅ Les filtres fonctionnent et transforment les données correctement


Objectif : Afficher du contenu conditionnel selon le contexte.

{# Badge "Nouveau" si publié il y a moins de 7 jours #}
{% set created_date = node.getCreatedTime() %}
{% set current_date = "now"|date('U') %}
{% set days_old = (current_date - created_date) / 86400 %}
{% if days_old < 7 %}
<span class="badge badge--new">Nouveau</span>
{% endif %}
{# Badge "Promo" si prix barré existe #}
{% if content.field_old_price|render %}
<span class="badge badge--sale">Promo</span>
{% endif %}
{# Afficher stock #}
{% if in_stock %}
<span class="stock stock--available">En stock</span>
{% else %}
<span class="stock stock--unavailable">Rupture de stock</span>
{% endif %}

Validation : ✅ Les badges s’affichent selon les conditions

Échappement automatique

Twig échappe automatiquement toutes les variables pour prévenir les attaques XSS.

{# ✅ Sécurisé par défaut #}
{{ user_input }} {# Échappe automatiquement < > " ' & #}
{# ⚠️ Dangereux - À éviter #}
{{ user_input|raw }} {# Pas d'échappement, risque XSS #}
{# ✅ Si vous devez afficher du HTML, utilisez les fonctions Drupal #}
{{ content.field_body }} {# Drupal gère l'échappement intelligent #}

Vérifier l’existence avant le rendu

{# ❌ Mauvais - Rend même si vide, génère du HTML inutile #}
{% if content.field_images %}
{{ content.field_images }}
{% endif %}
{# ✅ Bon - Vérifie vraiment si le champ a du contenu #}
{% if content.field_images|render %}
{{ content.field_images }}
{% endif %}
{# ✅ Encore mieux - Vérifie en PHP (preprocessing) #}
{% if has_images %}
{{ content.field_images }}
{% endif %}

Limiter les boucles

{# ❌ Mauvais - Affiche potentiellement des milliers d'items #}
{% for item in items %}
{{ item }}
{% endfor %}
{# ✅ Bon - Limite à 10 items #}
{% for item in items|slice(0, 10) %}
{{ item }}
{% endfor %}

Landmarks HTML5 et ARIA

{# ✅ Utiliser les balises sémantiques HTML5 #}
<header role="banner">
{{ page.header }}
</header>
<nav role="navigation" aria-label="{{ 'Navigation principale'|t }}">
{{ page.primary_menu }}
</nav>
<main role="main" id="main-content" tabindex="-1">
<a id="main-content"></a>
{{ page.content }}
</main>
<aside role="complementary" aria-label="{{ 'Filtres'|t }}">
{{ page.sidebar }}
</aside>
<footer role="contentinfo">
{{ page.footer }}
</footer>

Skip link obligatoire

<a href="#main-content" class="visually-hidden focusable skip-link">
{{ 'Aller au contenu principal'|t }}
</a>

Images accessibles

{# ❌ Mauvais - Pas d'attribut alt #}
<img src="{{ image_url }}">
{# ✅ Bon - Alt descriptif #}
<img src="{{ image_url }}" alt="{{ node.label }}">
{# ✅ Image décorative #}
<img src="{{ icon_url }}" alt="" role="presentation">
  1. Classes CSS : Utilisez BEM ou une convention cohérente

    <div class="product-card">
    <h3 class="product-card__title">{{ label }}</h3>
    <div class="product-card__price">{{ price }}</div>
    </div>
  2. Variables Twig : snake_case

    {% set is_featured = node.field_featured.value %}
    {% set product_classes = ['product', 'product--' ~ view_mode] %}
  3. Toujours commenter :

    {# Badge promo si prix barré existe #}
    {% if old_price %}
    <span class="badge badge--sale">-{{ discount }}%</span>
    {% endif %}

Afficher les variables disponibles

{# En mode debug uniquement #}
{{ dump() }} {# Toutes les variables #}
{{ dump(node) }} {# Variable spécifique #}
{{ dump(node.field_price) }} {# Champ spécifique #}

Inspecter les suggestions de templates

Avec le débogage Twig activé, inspectez le HTML source (Ctrl+U) pour voir :

  • Les suggestions de noms de fichiers
  • Le template actuellement utilisé
  • Les variables disponibles

Avant de valider un template :

  • Échappement automatique préservé (pas de |raw inutile)
  • Vérifications |render pour les champs optionnels
  • Balises sémantiques HTML5 (header, nav, main, aside, footer)
  • Attributs ARIA appropriés
  • Classes CSS cohérentes (BEM ou convention choisie)
  • Commentaires Twig pour expliquer la logique
  • Pas de dump() oublié
  • Responsive (mobile-first avec Tailwind)
  • Images avec attribut alt
  • Skip link pour navigation clavier
TemplateEmplacementValidationStatut
page.html.twigtemplates/layout/Inspecter la structure HTML
node--product.html.twigtemplates/node/Visiter une page produit
node--product--card.html.twigtemplates/node/Voir la grille de produits
block--system-branding-block.html.twigtemplates/block/Vérifier le logo
  1. Débogage Twig activé

    Fenêtre de terminal
    # Vérifier la config
    grep -A3 "twig.config" sites/default/services.yml

    Attendu : debug: true, auto_reload: true, cache: false

  2. Templates détectés par Drupal

    Fenêtre de terminal
    # Vider le cache
    drush cr
    # Vérifier les suggestions dans le HTML source (Ctrl+U)
    # Chercher "THEME DEBUG" dans le code source
  3. Pas d’erreurs dans les logs

    Fenêtre de terminal
    drush watchdog:show --severity=Error --count=20

    Attendu : Aucune erreur Twig

  4. Templates compilés

    Fenêtre de terminal
    ls -la sites/default/files/php/twig/

    Attendu : Fichiers .php générés pour chaque template

  • La structure HTML de la page est personnalisée
  • Les produits s’affichent avec le bon template (mode full)
  • Les cartes produits utilisent le template card
  • Les badges (nouveau, promo) s’affichent correctement
  • Les conditions (stock, prix) fonctionnent
  • Les boucles (couleurs, tailles) affichent les bonnes données
  • Pas de code PHP visible dans le HTML
  • Responsive fonctionne (tester mobile F12)
Fenêtre de terminal
# Script de validation complet
echo "=== Vérification templates Twig ==="
# 1. Config Twig debug
if grep -q "debug: true" sites/default/services.yml; then
echo "✅ Debug Twig activé"
else
echo "❌ Debug Twig désactivé"
fi
# 2. Templates présents
echo "\n📁 Templates trouvés :"
find themes/custom/tailstore/templates -name "*.twig" -type f
# 3. Cache Twig
echo "\n🗂️ Templates compilés :"
ls -1 sites/default/files/php/twig/*.php | wc -l
echo "fichiers compilés"
# 4. Erreurs récentes
echo "\n⚠️ Erreurs récentes :"
drush watchdog:show --severity=Error --count=5
echo "\n✅ Vérification terminée"

Temps estimé total : 2-3 heures pour créer et tester tous les templates

Symptôme : Modifications du template non visibles

Causes possibles :

  • Cache non vidé
  • Nom de fichier incorrect
  • Mauvais emplacement

Solution :

Fenêtre de terminal
# 1. Vider le cache
drush cr
# 2. Vérifier le nom exact suggéré
# Activer debug Twig et inspecter le HTML source (Ctrl+U)
# Chercher "FILE NAME SUGGESTIONS"
# 3. Vérifier l'emplacement
ls -la themes/custom/tailstore/templates/node/node--product.html.twig

Symptôme : Warning: Undefined variable

Cause : Variable non préprocessée ou mal nommée

Solution :

{# ❌ Mauvais - Erreur si la variable n'existe pas #}
{{ my_custom_var }}
{# ✅ Bon - Vérifie l'existence #}
{% if my_custom_var is defined %}
{{ my_custom_var }}
{% endif %}
{# ✅ Ou avec valeur par défaut #}
{{ my_custom_var|default('Valeur par défaut') }}

Symptôme : Balises vides dans le HTML (<div></div>)

Cause : Condition sur le champ sans vérifier le rendu

Solution :

{# ❌ Mauvais - Génère des balises vides #}
{% if content.field_description %}
<div class="description">
{{ content.field_description }}
</div>
{% endif %}
{# ✅ Bon - Vérifie vraiment le contenu #}
{% if content.field_description|render %}
<div class="description">
{{ content.field_description }}
</div>
{% endif %}

Symptôme : Page blanche, timeout, ou erreur Twig

Solution :

Fenêtre de terminal
# 1. Consulter les logs Drupal
drush watchdog:show --severity=Error
# 2. Consulter les logs PHP
tail -f sites/default/files/php-errors.log
# 3. Désactiver temporairement le template
mv themes/custom/tailstore/templates/node/node--product.html.twig \
themes/custom/tailstore/templates/node/node--product.html.twig.bak
drush cr

Symptôme : Classes présentes mais pas de style

Cause : Problème de chargement CSS, pas de problème Twig

Solution :

Fenêtre de terminal
# Vérifier que la librairie CSS est chargée
# F12 → Network → Filtrer CSS
# Vérifier que tailstore.css est chargé (statut 200)
# Vider le cache CSS
drush cc css-js
drush cr
  • Extension VS Code : Twig Language 2 (autocomplétion, coloration)
  • Module Drupal : Devel (dump, Kint pour debug)
  • Module Drupal : Twig Tweak (fonctions Twig supplémentaires)

Les templates sont en place ! Passons aux Assets (CSS/JS) pour styliser tout ça avec Tailwind CSS.