On croise des macros Twig dans tous les projets Symfony d'un certain âge, et on les écrit de mémoire, sans avoir relu la page de doc depuis des années. Ce billet fait le tour complet : la syntaxe, les valeurs par défaut, les trois façons d'importer. Il couvre aussi la règle de portée, celle qui coûte une heure de debug la première fois qu'on la rencontre, et le moment précis où une macro doit céder sa place à un Twig Component.
Une fonction déguisée en template
{% macro %} définit un fragment réutilisable qui se comporte comme une fonction : des arguments, des valeurs par défaut, du HTML en sortie.
{# templates/_ui.html.twig #}
{% macro badge(label, color = 'zinc', size = 'sm') %}
<span class="badge badge-{{ color }} badge-{{ size }}">{{ label }}</span>
{% endmacro %}Deux différences avec une fonction PHP, et elles sont structurantes. D'abord, les arguments sont toujours optionnels : appeler badge() sans rien ne lève aucune erreur, label vaut null et le span sort vide. Ensuite, les arguments surnuméraires ne cassent rien non plus : ils atterrissent dans une variable spéciale varargs, une liste que la macro peut parcourir. Twig encaisse tout sans broncher. La validation, c'est notre travail, pas le sien.
Trois façons d'importer
Une macro définie dans un autre fichier doit être importée avant usage. Deux tags s'en chargent :
{# tout le fichier sous un alias #}
{% import '_ui.html.twig' as ui %}
{{ ui.badge('PHP 8.5', 'green') }}
{# des macros précises, renommables au passage #}
{% from '_ui.html.twig' import badge, badge as pill %}
{{ pill('brouillon', 'amber') }}import ramène toutes les macros du fichier derrière un préfixe, from va chercher à l'unité. Le préfixe a un mérite discret : dans un template de 200 lignes, ui.badge() dit d'où vient la macro, badge() ne dit rien.
Troisième cas : la macro définie dans le template courant. Pas besoin d'import, la variable spéciale _self la sert directement :
{{ _self.badge('brouillon', 'amber') }}Et le cas que peu de gens connaissent : une macro qui appelle une macro du même fichier. Chaque macro vit dans son propre espace, le wrapper doit donc importer _self chez lui :
{% macro field(label, name, value = '') %}
{% import _self as ui %}
<div class="field">
{{ ui.badge(label) }}
<input name="{{ name }}" value="{{ value|e }}"/>
</div>
{% endmacro %}Le piège : une macro porte des œillères
La doc l'énonce en une phrase, et on la découvre en général devant un template qui refuse de faire ce qu'on lui demande : une macro n'a pas accès aux variables du template qui l'appelle.
Sur le même sujet
Comment tailwind_merge résout les conflits de classes dans Twig
{% set current_route = 'billets' %}
{% macro nav_item(label, route) %}
{# current_route n'existe pas ici : la condition est toujours fausse #}
<a class="{{ current_route == route ? 'active' }}" href="{{ path(route) }}">
{{ label }}
</a>
{% endmacro %}Rien ne crashe. Le template compile, la page s'affiche, et la classe active n'apparaît juste jamais. C'est le pire genre de bug : silencieux, légal, invisible au premier survol.
Nuance qui sauve du temps : les fonctions Twig (path(), dump(), les filtres…) restent disponibles dans la macro. Ce sont les variables du contexte qui sont invisibles, pas le moteur. La macro ne voit que ce qu'on lui tend : ici, current_route doit devenir un troisième argument.
La doc offre une échappatoire : passer le contexte entier via la variable spéciale _context.
{{ ui.nav_item('Billets', 'billets', _context) }}Ça marche, et c'est presque toujours une mauvaise idée. Une macro qui reçoit tout le contexte redépend de tout : on perd la seule garantie qu'elle offrait, celle de ne dépendre que de ses arguments. Si une macro a besoin du contexte complet pour fonctionner, elle demande en réalité à être autre chose.
Macro, include ou Component ?
Autre chose, mais quoi ? Twig et Symfony offrent trois outils pour réutiliser un fragment, et le bon choix tient en trois questions.
Pour aller plus loin
Live Components : JavaScript, moi non plus
- Le fragment a besoin du contexte du template ?
include: il reçoit les variables du parent par défaut, c'est sa raison d'être. - Le fragment est du HTML pur, paramétré par des scalaires, répété au sein d'un même domaine de templates ? Macro : le badge, la ligne de tableau, le champ de formulaire maison. Local, bête, parfait.
- Il y a de la logique, un typage à poser, un test à écrire, ou une réutilisation qui traverse les domaines ? Twig Component : une classe PHP porte les props et la logique, le template ne fait que rendre.
La méta-description du billet Twig Components de ce blog promet d'« arrêter les macros Twig », et il faut la lire comme une accroche plus que comme une doctrine. Les macros gardent un territoire légitime. Il est juste plus petit qu'on ne le croit, et il rétrécit à chaque fois qu'une macro gagne un if de plus.
Pour la bascule
Twig Components : la puissance du Backend dans le Frontend
Le mot de la fin
La macro est l'outil le plus honnête de Twig : une fonction avec des œillères, qui ne connaît que ses arguments et ne promet rien d'autre. C'est une limite, et c'est exactement ce qui la rend fiable : tout ce dont elle dépend tient dans sa signature. Savoir écrire une macro, c'est surtout savoir quand ne pas en écrire une.
Ce billet s'arrête volontairement à Twig 3 : Twig 3.28 vient d'ajouter l'appel de macro par nom dynamique via l'opérateur point (macros.(name)(args)), et Twig 4 arrive derrière. Les macros y survivront très bien. La vraie question, c'est combien des nôtres méritent le voyage, et combien attendaient juste qu'on les promeuve en Component.
Une coquille, une erreur dans ce billet ? Signale-la-moi.