Fabien Potencier
Contributed by Fabien Potencier

Macros are as old as Twig itself. They look like functions, you call them like functions, and for more than fifteen years they have quietly refused to behave like functions: every argument is optional, extra arguments vanish into a magic variable and a typo in a named argument is silently ignored. Twig 4.0 ends the special treatment: macros become full members of the callable family, with the same argument handling as functions and filters. Let me show you what changes for your templates.

The Problem: Macros Are Too Forgiving

Take the form helper that almost every project has a variant of:

1
2
3
4
{% macro input(name, value, type, size) %}
    <input type="{{ type|default('text') }}" name="{{ name }}"
           value="{{ value|e }}" size="{{ size|default(20) }}"/>
{% endmacro %}

Now, be honest: how many of these calls have you shipped at some point?

1
2
3
4
5
6
7
8
9
10
{% import 'forms.html.twig' as forms %}

{# "name" is missing: it silently becomes null #}
{{ forms.input() }}

{# "sise" is a typo: it is silently swallowed and the size is lost #}
{{ forms.input('username', sise: 30) }}

{# one argument too many: it silently lands in a magic "varargs" variable #}
{{ forms.input('username', '', 'text', 20, 42) }}

None of them raises an error. All of them render broken HTML that you have to spot by eye, often in production. I am partly to blame: macros have been lenient since the very first version of Twig, and the leniency even had a certain charm: a macro never complained, it always rendered something. But something is rarely what you want from a form helper, and the price shows in every macro out there: bodies sprinkled with |default() filters because no argument can be trusted to be there.

Functions and filters have always been strict about their arguments; macros were the only Twig callables that accepted anything. Twig 4.0 closes that gap.

Required Arguments

In Twig 4.0, a macro argument works like a PHP function argument: it is required unless it declares a default value.

1
2
3
4
5
6
7
{% macro input(name, value = '', type = 'text', size = 20) %}
    <input type="{{ type }}" name="{{ name }}"
           value="{{ value|e }}" size="{{ size }}"/>
{% endmacro %}

{# Error: Value for argument "name" is required for macro "input". #}
{{ forms.input() }}

To keep an argument optional, give it an explicit default value (= null reproduces the old behavior). The signature now tells the whole story: reading the definition is enough to know which arguments you must pass and which ones you may omit. And because defaults live in the signature, the body no longer needs defensive |default() calls.

Explicit Variadic Arguments

Today, every macro is implicitly variadic: extra arguments are collected into a reserved varargs variable that no signature mentions; you either know it exists or you don't. Twig 4.0 replaces this magic with an explicit opt-in, using the same ... notation as PHP:

1
2
3
4
5
6
7
8
{% macro tag(element, ...attrs) %}
    <{{ element }}
    {%- for name, value in attrs %} {{ name }}="{{ value }}"{% endfor -%}
    >
{% endmacro %}

{# renders <input type="text" name="username"> #}
{{ html.tag('input', type: 'text', name: 'username') }}

The variadic argument collects both extra positional and extra named arguments, which makes patterns like the HTML attributes helper above natural to write. It must be the last argument, and you pick its name, so the signature documents that the macro accepts an open-ended argument list. A macro that does not declare a variadic argument rejects extra arguments, like a function does.

The explicit syntax already works in Twig 3.29, so you can adopt it today; a macro that relies on the implicit varargs variable keeps working as-is once it declares ...varargs.

Calls Look Like Calls

Referencing a macro and calling it have always been blurred together: Twig quietly calls a macro referenced without parentheses, and accepts parentheses in places where nothing is called. Twig 4.0 separates the two usages. Calling a macro requires parentheses:

1
2
3
4
{# Twig 3.x renders the macro as if you had written forms.input() #}
{# Twig 4.0: Omitting parentheses when calling a macro is not
   allowed; add parentheses after the macro name. #}
{{ forms.input }}

And testing whether a macro exists rejects them, because the defined test inspects the macro itself, it does not call it:

1
2
3
4
5
6
{# Twig 4.0 #}
{% if forms.input is defined %}...{% endif %}

{# deprecated in 3.29: this reads as "is the result of the call
   defined?", which is not what the test checks #}
{% if forms.input() is defined %}...{% endif %}

Clear Errors Instead of Broken Output

With strict resolution, every calling mistake that used to corrupt your output now fails with a precise message:

  • Value for argument "name" is required for macro "input".
  • Unknown argument "sise" for macro "input".
  • Too many arguments for macro "input".
  • Argument "name" is defined twice for macro "input".

This is the same experience you already get when miscalling a function or a filter; a typo now shows up during development instead of shipping as an empty attribute. Definitions get the same cleanup: macro names become case-sensitive in 4.0, so calling forms.Input() no longer resolves the input macro; defining the same macro twice in one template, which today silently keeps the last definition, becomes a syntax error; and a macro tag must now sit at the root of a template, not nested inside an if or another tag.

A More Capable Macro Toolbox

The overhaul does not come alone.

Dynamic macro names, added in Twig 3.28: the macro to call can now be computed at runtime by wrapping an expression in parentheses after the dot operator:

1
2
{% set field = widget.multiline ? 'textarea' : 'input' %}
{{ forms.(field)('description') }}

Deprecating a macro: this one is not new, but it completes the toolbox. Shared macro libraries can evolve their API the same way PHP libraries do, by placing the deprecated tag at the top of a macro body; a deprecation notice is triggered whenever the macro is called:

1
2
3
4
{% macro input(name, value = '') %}
    {% deprecated 'The "input" macro is deprecated, use "field" instead.' %}
    <input name="{{ name }}" value="{{ value|e }}"/>
{% endmacro %}

The Upgrade Path

As usual, Twig 3.29 triggers a deprecation for every macro call and definition that will break in 4.0. All the fixes work on 3.29, so an application that runs deprecation-free is ready for Twig 4.0, where macros finally behave like the functions they always looked like.

Published in #Living on the edge #Twig