How We Roll With Craft CMS: Templates in Twig

Written By Matsuko Friedland
Posted on

Templating in Twig can get pretty messy. Loose variables can cause unexpected results, especially when multiple developers are working on a large project. Let's take a look at some problems with variables that can arise when using include in Twig, and how we now include templates at Zaengle.

The problem with leaky templates

In this example, we have three short templates: index.twig, card.twig, and button.twig:

{# index.twig #}
{% set text = 'The mitochondria is the powerhouse of the cell.' %}
{% include 'card' %}

{# card.twig #}
{% set text = text|default('') %}
<div>
    <p>{{ text }}</p>
    {% include 'button' %}
</div>

{# button.twig #}
{% set text = text|default('Show more') %}
<button>{{ text }}</button>

The developer's intent is to show a card on the homepage with a fun science fact and a button. The button template can be re-used to display any text, but the developer just wants to show the default text ('Show more'). This is the developer's expected markup:

<div>
    <p>The mitochondria is the powerhouse of the cell.</p>
    <button>Show more</button>
</div>

However, this is the actual output:

<div>
    <p>The mitochondria is the powerhouse of the cell.</p>
    <button>The mitochondria is the powerhouse of the cell.</button>
</div>

Why is this happening? By default, templates included with include inherit variables from the parent scope. The value of text is first defined index.twig, then being passed down through card.twig to button.twig. Even though there's a default value for text in button.twig, it doesn't display because text is already set.

Fixing leaky templates

We can work around the problem by avoiding variable name collisions. One way is to check that variable names aren't used in any other templates, but this is not very practical, especially for large projects. Another way is to come up with some naming scheme to enforce uniqueness, as shown below. In that case, you may end up with some long and ugly variable names.

{# index.twig #}
{% set cardText = 'The mitochondria is the powerhouse of the cell.' %}
{% include 'card' %}

{# card.twig #}
{% set cardText = cardText|default('') %}
<div>
    <p>{{ cardText }}</p>
    {% include 'button' %}
</div>

{# button.twig #}
{% set buttonText = buttonText|default('Show more') %}
<button>{{ buttonText }}</button>

A more robust solution is to use the only keyword when you include the template. When this keyword is added, only the variables specified through with are passed in.

In this particular case, adding the only keyword to where the button is included in card.twig will produce the expected markup:

{% include 'button' only %}

However, to ensure no unexpected variables leak through to any templates, you'll need to remember to add this to every instance of include:

{# index.twig #}
{% include 'card' with {
    text: 'The mitochondria is the powerhouse of the cell.',
} only %}

{# card.twig #}
{% set text = text|default('') %}
<div>
    <p>{{ text }}</p>
    {% include 'button' only %}
</div>

{# button.twig #}
{% set text = text|default('Show more') %}
<button>{{ text }}</button>

Abstracting the fix

We just looked at some ways to keep variables from unintentionally passing through templates, but they still take quite a lot of care and diligence on the developer's part.

While researching ways to improve templating in Twig, I came across DRY Templating with Craft and Twig. In this article, Pierre Stoffe describes how including components with macros has improved their development workflow.

Inspired by this, I was able to create a single macro to include any component, rather than creating a macro for each component, or using an additional plugin.

To use a single macro for any component, we pass in the name of the component as the macro's first parameter. The template by that name then gets included with the usual include, and variables specified in the macro's second parameter are also passed along.

Here's the basic set up and usage of the macro:

{# component.twig #}
{% macro component(componentName, variables = {}) %}
  {% include componentName with variables %}
{% endmacro %}

{# index.twig #}
{% from 'component' import component %}
{{ component('card', {
    text: 'The mitochondria is the powerhouse of the cell.',
}) }}

{# card.twig #}
{% from 'component' import component %}
{% set text = text|default('') %}
<div>
    <p>{{ text }}</p>
    {{ component('button') }}
</div>

{# button.twig #}
{% set text = text|default('Show more') %}
<button>{{ text }}</button>

If you noticed that the include doesn't use the only keyword, that's because Twig macros have their own variable context. Templates included through the macro will only have access to variables that are defined in the macro (in this case, just componentName), or explicitly passed into it.

We do need to import the macro in any template that uses it, but we only need to import it once to include any template with scoped variables.

Extending the macro

Because all templates are included through this macro, we only need to modify the macro template to adjust how all templates are included. Here are some examples of ways we can extend the macro.

Importing templates from deep folder structures

Suppose we've reorganized our project so that all files related to a component (styles, scripts, templates, etc.) are contained in their own folder in a components directory, as shown below:

└── component.twig
└── _components
    ├── button
    │   ├── button.css
    │   └── button.twig
    └── card
        ├── card.css
        ├── card.js
        └── card.twig

Here's the macro adjusted to include components following the new structure:

{# component.twig #}
{% macro component(name, variables = {}) %}
  {% include "_components/#{name}/#{name}" with variables %}
{% endmacro %}

If we'd used include directly, we would've had to change the path for every include. When we use the macro, the instances of its use are unchanged, and it's simpler to read:

{# Include with `include` #}
- {% include 'button' %}
- {% include 'card' %}
+ {% include 'components/button/button' %}
+ {% include 'components/card/card' %}

{# Include with includer macro #}
{{ component('button') }}
{{ component('card') }}

Setting a fallback template for all includes

During development, it can be handy to load a fallback template when one isn't found. When we do this, content authors can see that components are at least being placed where they expect.

We can add the fallback path in the includer macro, instead of needing to add it in every instance of include.

{# component.twig #}
{% macro component(name, variables = {}) %}
  {% include [name, 'fallback'] with variables %}
{% endmacro %}

{# fallback.twig #}
<pre>
    Missing template: {{ name }}
</pre>

{# index.twig #}
{% from 'component' import component %}
{{ component('banner', {
	headline: ‘Zaengle’,
	description: ‘Be Nice, Do Good’,
}) }}

{# index.html #}
<pre>
    Missing template: banner
</pre>

In closing

With this macro, we’ve reduced the chances of odd bugs creeping in due to scope leak. At the same time, we’ve made the developer’s job easier by reducing code duplication, tidying away the path to components, and eliminating any need to worry about variable name collisions.

Give it a try and see how it works for you. Maybe you can find more interesting ways to extend the macro for your workflow. I hope that you’ll share your experiences with us! Feel free to connect with us on Twitter @Zaengle.