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.