Level Up with Craft CMS: Know When to Ditch Twig

If you notice your Twig templates are getting overly complex, it may be time to extend Craft with a custom Module.

Twig is an extremely powerful templating language, and I am always grateful that Pixel & Tonic chose an open source templating language. But, there are times when you can start to push your Twig code too far, and it would be much better suited for a custom Module.

On a recent project, we needed to display the five most recent items from various sources. Those sources were entries from the blog and press release channels, Solspace calendar events, and headlines from two external RSS feeds. This was quite messy to pull all of these items together, normalize them, and then display the most recent ones.

My first approach was to solve this with Twig:

{% set items = [] %}

{# Entries #}
{% set entries = craft.entries({
	section: ['blog', 'pressRelease'],
	limit: 5,
}) %}

{% for entry in entries.all() %}
	{% set items = items | merge([{
		url: entry.url,
		title: entry.title,
		type: entry.section.name,
		date: entry.postDate | date('U'),
	}]) %}
{% endfor %}

{# Events #}
{% set events = craft.calendar.events({
	calendar: 'default',
	orderBy: 'startDate DESC',
	limit: 2,
}) %}

{% for event in events.all() %}
	{% set items = items | merge([{
		url: event.url,
		title: event.title,
		type: 'Event',
		date: event.startDate | date('U'),
	}]) %}
{% endfor %}

{# In the News #}
{% set feedPrefix = '@root/data/feeds/' %}
{% set inTheNews = craft.feedme.feed({
	url: feedPrefix ~ 'in-the-news.xml',
	type: 'xml',
	element: 'item',
}) %}

{% for item in inTheNews %}
	{% set items = items | merge([{
		url: item.link,
		title: item.title,
		type: 'In The News',
		source: item['dc:publisher'] ?? null,
		date: item['dc:date'] | date('U', timezone='UTC'),
	}]) %}
{% endfor %}

{# Portfolio news #}
{% set feedPrefix = '@root/data/feeds/' %}
{% set portfolioNews = craft.feedme.feed({
	url: feedPrefix ~ 'portfolio.xml',
	type: 'xml',
	element: 'item',
}) %}

{% for item in portfolioNews %}
	{% set items = items | merge([{
		url: item.link,
		title: item.title,
		type: 'Portfolio News',
		source: item['dc:publisher'] ?? null,
		date: item['dc:date'] | date('U', timezone='UTC'),
	}]) %}
{% endfor %}

{% set items = items | supersort('rsortAs', '{date}', SORT_NUMERIC) [:5] %}

All of that code just to display five items, not great. This approach was also utilizing two third party plugins: Feed Me (now first party) and SuperSort. I accomplished the goal, but there were a couple of other instances on the site where I needed to solve similar problems, and there wasn't a clean way to make this reusable with Twig.

Luckily, Craft 3 added the ability to extend Craft with custom Yii modules. The Craft documentation provides some explanation of when to use a Module vs Plugin, and in this case, a Module made the most sense. It may seem daunting, but once you get the initial framework in place, Modules are very straightforward.

The best place to start is pluginfactory.io. Choose the type, Craft CMS Module version 3.x, provide the module details, and then in my case, I needed Services and Variables.

Once I had my Module bootstrapped, I went in and created a News service that contained all of my logic:

<?php

namespace modules\neamodule\services;

use modules\neamodule\NEAModule;

use Craft;
use craft\base\Component;
use craft\web\View;
use craft\elements\Entry;
use Solspace\Calendar\Elements\Event;

class News extends Component
{
	public function list($params = [])
	{
		$items = [];

		foreach ($params as $key => $limit) {
			switch ($key) {
				case 'blogsPress':
					$blogsPress = Entry::findAll([
						'section' => ['blog', 'pressRelease'],
						'limit' => $limit,
					]);

					foreach ($blogsPress as $item) {
						$items[] = [
							'url' => $item->url,
							'title' => $item->title,
							'type' => $item->section->name,
							'date' => $item->postDate,
							'sortDate' => (int) $item->postDate->format('U'),
						];
					}
				break;

				case 'events':
					$events = Event::findAll([
						'calendar' => 'default',
						'orderBy' => 'startDate DESC',
						'limit' => $limit,
					]);

					foreach ($events as $item) {
						$items[] = [
							'url' => $item->url,
							'title' => $item->title,
							'type' => 'Event',
							'date' => $item->startDate,
							'sortDate' => (int) $item->startDate->format('U'),
						];
					}
				break;

				case 'inTheNews':
					$feedItems = $this->feed('in-the-news.xml', 'In The News', $limit);
					$items = array_merge($items, $feedItems);
				break;

				case 'portfolioNews':
					$feedItems = $this->feed('portfolio.xml', 'Portfolio News', $limit);
					$items = array_merge($items, $feedItems);
				break;
			}
		}

		return $items;
	}

	public function feed($feed, $type, $limit)
	{
		$items = [];

		// Suppressed logic for retrieving feed items
		...		

		return $items;
	}
}

This is a somewhat abbreviated version of the full class, but notice the similarities between the Twig and PHP code. Below is a comparison of retrieving the blog and press release entries in both Twig and PHP:

Twig
{% set entries = craft.entries({
	section: ['blog', 'pressRelease'],
	limit: 5,
}) %}

{% for entry in entries.all() %}
	{% set items = items | merge([{
		url: entry.url,
		title: entry.title,
		type: entry.section.name,
		date: entry.postDate | date('U'),
	}]) %}
{% endfor %}
Php
<?php

$blogsPress = Entry::findAll([
	'section' => ['blog', 'pressRelease'],
	'limit' => $limit,
]);

foreach ($blogsPress as $item) {
	$items[] = [
		'url' => $item->url,
		'title' => $item->title,
		'type' => $item->section->name,
		'date' => $item->postDate,
		'sortDate' => (int) $item->postDate->format('U'),
	];
}

Now, I just needed to create a way for my Twig templates to access my PHP code. So in my Variable file, I created a function that Twig could access and would call my newly created list function in the News service:

<?php

namespace modules\neamodule\variables;

use modules\neamodule\NEAModule;

use Craft;

class NEAVariable
{
	public function newsList($params = [])
	{
		return NEAModule::getInstance()->news->list($params);
	}
}

Now in my twig template, I can remove all of the complexity and simply use:

{% set items = craft.nea.newsList({
	blogsPress: 5,
	events: 2,
	inTheNews: 5,
	portfolioNews: 5,
}) | supersort('rsortAs', '{sortDate}', SORT_NUMERIC) [:5] %}

Note: I ended up continuing to use the SuperSort plugin since we were using it elsewhere on the site, so it was simpler to use it here than to write my own sorting logic.

By switching to a module, I accomplished the following:

  • Simplified my Twig code (remember, it's still just a templating language)
  • Created a clean and reusable way to output the same functionality in other places
  • Removed the reliance on one plugin (Feed Me)
  • Setup a framework for adding custom functionality to the site
  • Improved performance! Twig compiles to PHP, but that PHP is less efficient than the module's PHP

As I continue to build with Craft, I'm looking for more opportunities to move complex logic to PHP and keep my Twig templates clean.

Trevor Davis

Trevor is a senior front-end developer who specializes in writing bulletproof code for clients including the World Wildlife Fund and GoPole.

More articles by Trevor