10 Things To NOT Do In Your Craft CMS Twig Templates
Posted: 17th Feb 2020
Craft makes getting up and running with new projects a piece of cake. A big part of that is the flexibility offered by its twig templating integration.
But along with that flexibility, Craft also hands over responsibility for the functionality of its front end to you.
As easy as it is to get started with Craft Twig templates, it's just as easy to dig yourself into a performance or usability hole that's difficult to diagnose!
Here are 10 things we think you should NOT do in your Craft Twig templates in order to avoid some common, and some not-so-common pitfalls...
1. Don't Include Templates Dynamically Without Defining A Fallback
If you've created more than one twig template, you'll likely have used {% include %} to pull child templates into a parent.
Sometimes these child templates will be loaded in dynamically. For instance you might require different templates depending on the current site or language selected by the end user. This might look something like:
{% include '_content/' ~ language %}
Because this is relying on the value of language matching a template on the filesystem there's a possibility that it will fail if language is ever set to something unexpected.
We can code defensively to prevent this from occurring by providing a fallback template to use if the first doesn't exist. Here's how:
{% include ['_content/' ~ language, '_content/default'] ignore missing %}
This tells Twig to look for each template in the array in order. The first one that is found will be rendered. If none of the options are found the ignore missing will prevent an exception being thrown and simply display nothing - it can be removed if you'd prefer an error to be thrown when your default template is missing.
2. Don't Use An if() If A default Will Do
It can be hard to overcome habit, and a habit that we've often found ourselves falling back into if using if() statements to check for a property's existence before outputting it. Something like this:
{% if post.description %}
{{ post.decription }}
{% else %}
No description added yet!
{% endif %}
Instead, Twig provides us with the useful default function which can reduce our code to:
{{ post.description|default('No description added yet!') }}
You can even use it when passing object properties into functions:
{{ plugin.doSomething(myObject.property|default(0)) }}
For templates which are inserting multiple optional properties, this can save a lot of lines of code and also helps to maintain appropriate indentation. It becomes especially useful if you are outputting properties into a node attribute or form element where white space is important and adding an {% if %} would look really messy.
3. Don't Specify All Function Properties Unnecessarily
On the subject of passing parameters to functions, many Twig functions support named parameters. If you only need to pass the final parameter to a multi-parameter function you can specify it by name rather than adding defaults for all the other parameters.
As an example:
{{ "now"|date("F j, Y H:i", "Europe/London") }}
Can become:
{{ "now"|date(timezone="Europe/London") }}
We didn't really save any characters there, but functions with several parameters (especially custom ones) can be easier to use with this technique.
4. Don't Concatenate When You Can Interpolate
Ever needed to stitch multiple strings together to make a longer one? At first you might try something like:
{% set longString = "Today: " ~ artistName|capitalize ~ " will be playing " ~
instrument ~ " on the " ~ stageName ~ " stage at " ~ performanceDate|date('H:i') %}
Interpolation allows us to make this look a lot friendlier, like so:
{% set longString = "Today: #{artistName|capitalize} will be playing #{instrument} on the #{stageName} stage at #{performanceDate|date('H:i')}" %}
Pretty useful when working with long strings!
5. Don't Use {% spaceless %} On Anything With A Large Output
The {% spaceless %} tag removes all unnecessary white space from a template's output. Sounds simple enough, but its implementation can cause issues if you're unprepared.
In order to perform its magic, {% spaceless %} will do the following:
- Buffer all of your template's output into a big string
- Run a preg_replace() over the entire string to remove whitespace
- Output the newly edited string
Output buffering in PHP isn't anything unusual, but if you're outputting large quantities of text this can cause two issues.
Memory Consumption
Normally PHP will stream its output back to the client as it is generated so it only needs to keep a small buffer in memory at any one time. {% spaceless %} prevents this and instead keeps the entire spaceless block in memory before sending it back to the client all in one go. If you're outputting a large quantity of text this can significantly increase your PHP worker's memory usage. On a high traffic site this could end up causing problems.
CPU Usage
The preg_replace() used by {% spaceless %} isn't particularly complex:
preg_replace('/>\s+</', '><', $content)
But it will have a non-trivial impact on CPU usage when executed over large volumes of text, so running it for every page request might have an impact on your site's overall performance.
If you're using {% spaceless %} to save on bytes sent over the network, just get your webserver to gzip responses instead.
6. Don't Loop Over An Include Which Contains An Element Query
You probably already know that you shouldn't create a loop which contains an element query as it will cause Craft to perform multiple expensive database queries which can often be consolidated into a single query outside of the loop.
This rule is also important to remember when you're working with {% include %} statements.
As an example, consider the following slightly complex example where we want to display some recent blog posts along with a list other posts that link to them.
#Parent template
{% set posts = craft.entries.section('blog').limit(8).all() %}
{% for post in posts %}
{{ post.title }}
{% include 'linkingPosts' with {post: post}
{% endfor %}
#linkingPosts template
{% set postsWhichLinkToThisPost = craft.entries.section('blog').relatedTo(
{targetElement: post, field: 'linksTo'}
).all() %}
{% for linkingPost in postsWhichLinkToThisPost %}
{{ linkingPost.title }}
{% endfor %}
This will, of course, create and execute the postsWhichLinkToThisPost query once for each time the child template is included in the parent, resulting in 9 database requests in total, all of which will be searching and filtering a potentially large number of entries. O(N+1) for the math inclined.
To protect against this we should always keep in mind whether or not a template file that we're creating might be included within a loop. If so, we should carefully consider whether or not to add any element queries to the template - there might be a more performant option by moving the query out to the parent and passing it into the child as a variable.
#Parent template
{% set posts = craft.entries.section('blog').limit(8).all() %}
{% set postsWhichLinkToAllPosts = craft.entries.section('blog').relatedTo(
{targetElement:posts, field: 'linksTo'}
).with('linksTo').all() %}
{% for post in posts %}
{{ post.title }}
{% set postsWhichLinkToThisPost = postsWhichLinkToAllPosts|filter(
x => post.uid in x.linksTo|map(y => y.uid)
) %}
{% include 'linkingPosts' with {post: post, linkingPosts:postsWhichLinkToThisPost} %}
{% endfor %}
#linkingPosts template
{% for linkingPost in linkingPosts %}
{{ linkingPost.title }}
{% endfor %}
By adding a little more thought to how we can find and filter our results from the database we've reduced the number of expensive database queries down to 2, no matter how many blog posts we'd like to display using our child template. O(1) 👌
7. Don't Make Calls To External Services Without Caching
There are a lot of Craft plugins available which allow your project to connect to external services. It's common for these plugins to provide twig functions which prevent you from having to create custom modules in order to use them.
It's also common for these plugins to forego any type of caching of responses from external services. In many circumstances this can be forgiven as the plugin is unlikely to be aware of how often you would like to bust any cache it provides.
For example, take the popular Craft 3 plugin Mailchimp Subscribe. This plugin provides a twig function to fetch 'Interest Groups' which have been defined in the Mailchimp account. These interest groups can then be shown to the user before they subscribe so that they can opt in to specific groups.
The example implementation from the docs:
{% set interestGroups = craft.mailchimpSubscribe.getInterestGroups('audience-id') %}
{% for group in interestGroups %}
<h4>{{ group.title }}</h4>
{# Add form elements for group #}
{% endfor %}
If you add this to a subscription form to your website's footer, it will cause every page load to hit the Mailchimp API multiple times. The time taken to talk to the API will all be added to the end user's load time. This can increase your page load from 200ms to > 2 seconds, and if Mailchimp's API slows down, so will your entire website!
If you ever need to make calls to external services as part of your twig templates:
- Check for (and use) any caching functionality in the plugin you're using.
- Add your own caching if needed.
With the Mailchimp example we can easily solve the problem for most users by adding our own caching to the template:
{% set interestGroups = craft.app.cache.get('mailchimp_groups') %}
{% if interestGroups is empty %}
{% set interestGroups = craft.mailchimpSubscribe.getInterestGroups('audience-id') %}
{% set success = craft.app.cache.set('mailchimp_groups', interestGroups, 60 * 60 * 24) %}
{% endif %}
Now we're caching the interest groups for 24 hours so there will only be one unlucky user per 24 hours that has to wait for the Mailchimp API before their page renders.
8. Don't Use The |length Function On Element Queries Which Will Subsequently Be Executed
What does that even mean?
Example:
{% set entries = craft.entries.section('posts')
.with([['thumbnail', { withTransforms: ['small'] }]]).limit(3) %}
{% if entries | length %}
<p>Here are some Posts!<p>
{% for entry in entries.all() %}
<img src="{{ entry.thumbnail.url('small') }}">
{{ entry.title }}
{% endfor %}
{% endif %}
Looks innocent enough - we're creating an element query, if it has any results we output some boilerplate, then we loop over the results of the element query. We've seen this a few times in un-optimised template code.
However, in the background this template will be performing the database query to get our results twice! Once when we pass the query to the |length function, and again when we call .all() on it in the {% for %}.
Not only are we calling the query twice, the first time it is executed it is including the eager loading for thumbnails, but then ignoring them because |length doesn't care about them!
Let's fix this by simply executing the query before passing it to |length:
{% set entries = craft.entries.section('posts')
.with([['thumbnail', { withTransforms: ['small'] }]]).limit(3).all() %}
{% if entries | length %}
<p>Here are some Posts!<p>
{% for entry in entries %}
<img src="{{ entry.thumbnail.url('small') }}">
{{ entry.title }}
{% endfor %}
{% endif %}
Now the |length function is acting on the results of the query, rather than on the query itself, it doesn't need to execute it unnecessarily.
9. Don't Use {% cache %} Tags Without Understanding How They Work
{% cache %} tags feel as though they should solve all of our performance woes by magically caching our template output and sending it to all of our end users, but using it without knowing how cache keys are generated can be worse than not caching at all.
The implementation of Craft's native caching is a little complex, but we like to think about it like this:
- By default, Craft will cache using a key made up from the current URL and a random ID assigned to each individual cache block
- You can set your own cache key to use instead of the random ID
- You can remove the current URL from the key by setting the globally flag
You can use these bits of info to figure out what your cache keys will be set to and how that will impact how portions of your site are cached.
A few examples.
For an article template which will render different content for every URL that uses it:
{% cache %}
#Output article with images and other dynamically linked things
{% endcache %}
#Possible unique keys generated:
# https://yoursite.com/page1|block123
# https://yoursite.com/page2|block123
# http://anotherdomain.com/page1|block123
For a header which will be static across an entire site:
{% cache globally %}
#Output navigation and things
{% endcache %}
#Possible unique keys generated:
# block456
For a trending articles bar which is included on all article pages but filtered by a current category:
{% cache globally using key 'trending-bar-' ~ currentCategory %}
#Output trending bar for the current category
{% endcache %}
#Possible unique keys generated:
# trending-bar-cat1
# trending-bar-cat2
# trending-bar-cat3
Once you have an idea of how cache keys are going to be generated it's easy to see how they can get out of control. On a site with 20k pages, if you forget to add the globally flag to the trending bar example you might end up with (20k * number of categories) different caches!
If you then consider that by default, Craft will try to track all of the elements whose data might have an impact on the cache tag's content, and stores all of these as relationships in the database, you could end up with (20k * number of categories * a significant subset of total entries) rows in the database. Just for your single {% cache %} tag! And trust us when we say, that can cause problems.
In order to keep things under control we normally do two things:
1. Define all of our cache keys manually
Rather then rely on Craft to include the URL in the cache key, we like to include all the variables that might impact the content of a cache tag within a manually set cache key.
This means we use globally using key 'custom-key' on all of our cache tags.
Our first example of a blog article's content would become:
{% cache globally using key 'article-content-' ~ article.uid %}
#Output article with images and other dynamically linked things
{% endcache %}
#Possible unique keys generated:
# article-content-aaaa-028319-6787237
# article-content-bbbb-987263-0298374
This way we maintain complete control over how the cache is being used and also prevent a single article which is displayed on two different URLs from being cached twice.
2. Disable Craft's automated cache busting
This is essential for sites with a significant quantity of Entries. On multiple occasions we have seen this functionality completely cripple a live site when the automated cache busting is being executed.
The down side is that Craft will no longer be able to bust the cache of relevant cache tags when entries are updated in the control panel so your caches will only expire when they reach their maximum time-to-live value.
You can disable automated cache busting with a single setting in your config/general.php file:
<?php
return [
//...
'cacheElementQueries' => false,
//...
];
Once you've done that you should review the settings for cache TTL, either by setting a sane default in your config/general.php or by defining it explicitly on each cache block by adding for 2 hours or similar to the tag itself.
For a more thorough look into how cache tags work and the different ways you can use them, nystudio107 have an excellent article about it.
10. Don't Include Inline Javascript In Your Templates
Wow, those last few points were pretty heavy. Let's end on a simple one.
If you ever need to include some JavaScript, but only when a specific child template is being included, it is tempting to just include it inline:
<a href="#" class="a-link">Click Me</a>
<script>
$('.a-link').click(function(){alert('Clicked')});
</script>
That's a bit of a contrived example, but you can see what we're getting at.
Rather than include this script inline - which would require us to preload any dependencies in our head, we can include it at the bottom of our page even though this template doesn't have direct access to that portion of the page.
We do this using the {% js %} tag:
<a href="#" class="a-link">Click Me</a>
{% js %}
$('.a-link').click(function(){alert('Clicked')});
{% endjs %}
Using this method your JavaScript will be rendered at the bottom of your page, even though your template might have been included elsewhere. So you can add your dependencies to your footer and still be sure they'll have been executed before your custom JS is parsed.
The {% js %} also has some additional options which can insert your javascript in other portions of the page, or even within a jQuery(document).ready() callback. You can read full details here.
Writing Twig can get tricky, but knowing what to watch out for can be half the battle!
Once you've optimised those templates and reduced your TTFB as low as it can possibly go you might not want to do it all over again with your DevOps and hosting.
Instead, Servd will sort it all out for you with minimal fuss. Just deploy straight from your git repo and get the performant, well organised hosting that your Twig templates deserve.