Craft CMS Conventions in Twig

Written By Philip Zaengle
Posted on

A few years ago, we started writing about the patterns we used to keep our Craft CMS sites organized. At the time, we knew that Craft wasn't supplying the conventions we needed to keep a site easily maintainable and wanted a solution that would give each site a starting template structure. Then our friends at Viget (yes, we're really friends) expounded on these ideas, notably on how to turn this concept into a plugin and prevent scope leaking between templates.

Spoiler alert, we turned this into a plugin that allows you to implement your own conventions, or follow ours.

The smell of change

At Zaengle, we launch a few dozen sites a year. Some large, taking months of development, some small, taking only a few weeks. But they all have roughly the same structure.

Every developer at Zaengle is tasked with finding efficiencies. In my role, that leads me to ask questions such as:

  1. How do we make the systems we build and the sites we architect better?
  2. How can we ensure a system is built to last longer than seven years? (Seven years is the average lifespan of a website)
  3. Is it possible for us to write code now that we’ll enjoy working on (or at least readily understand) in 12 months?

We need to establish a pattern that gives our dev team all the tools they need to build complex sites, and a framework to make these systems predictable for our future selves.

As we think through this challenge, one primary question emerges.

How is your template code organized?

Before we get too far in, I want to be clear: we are by no means trailblazers. Plenty of opinionated systems like Laravel, Vue.js, and Nuxt.js laid the foundation for the architecture of our solution. Hats off to intelligent people we are inspired by.

There are multiple ways to organize and think about the relationship between code and design. Most notably, Brad Frost's Atomic Design, where Brad uses mostly scientific language to describe the hierarchy of building design systems.

Atomic design was one of the first attempts to describe building design systems in a standardized and predictable way. We’ve tried using the Atomic patterns on Craft CMS projects. Unfortunately, the systems tend to get overly complex and quickly lose focus.

It needs to be simple

When working in CMSes, template organization should be structured around the time to comprehension. In other words, any developer should be able to hop into a project and feel at home quickly. What we need is a common vocabulary or conventions for CMS development. But most importantly, the vocabulary needs to be simple.

Vocab: components, partials, and fields

We use three main buckets for organizing our templates. For the average project, that means that about ≈85% of our code will fall into one of three structures (components, partials, and fields).

Let's take a look at how this would work if we built a team grid in Craft.

Components

Components are our design building blocks, they get content injected into them in a standardized manner and have self contained logic that detail how each component should render.

Component rules

  1. Components must live within the `_components` directory but may live in subfolders for organization.
  2. Components shouldn’t extend or include other components without careful consideration.
  3. Components may receive two types of input: a `data` object used for passing data (mostly dynamic Craft content ), and an `opts` object used for giving specific component level options that could adjust how a component functions or looks.

Let’s take a look at how a component might be structured:

// _components/cards/teamCard.twig

<div class="card {{opts.isWide ? 'card--wide' }} ">
	<img src="{{ data.image.url }} alt="{{data.image.alt}}" />
	<h3>{{ data.title }}</h3>
	{{data.shortBio}}
</div>

A few things to pay attention to. We never reference entry or any other variable that’s not directly passed into the component, this is a forced constraint designed to prevent leaky templates. Remember: we’re only getting access to two objects, data and opts. We’re also attempting to keep logic and line length as minimal as possible.

Partials

A partial is a one-off code block that shouldn’t accept any input. An example of a partial would be a footer or header navigation. Often content from Craft Globals makes its way into partials.

Partial rules

  1. Partials live in the _partials directory but may live in subfolders for organization.
  2. Partials do not accept any injected input.

Let’s take a look at how a partial might be structured:

// _partials/header/primaryNavigation.twig

<header class"...">
	<nav class"...">
		{{ craft.navigation.render('primaryNavigation') }}
	</nav>
</header>

In this example, you see how we might output a primary nav. Why wouldn’t we just include this in the layout, you ask? We might, but in most cases, we’re looking to reduce the complexity of each template. If we can isolate the navigation to a partial, it’ll make the layout much easier to comprehend.

Fields

In Craft (and most CMSs), we get to define how our content is structured by making fields. Code complexity often revolves around how a given field interacts with the data it contains. Once again, let’s look at how to create a simple interface and make sure the complexity is only visible when needed.

Field rules

  1. Fields must live within the _fields directory but may live in subfolders for organization.
  2. A field should contain very little HTML (if any).
  3. A field must accept a field object and optionally accept an opts object.

Using our example of a team grid, let’s assume we have an Entries field where an author can select and order the team members they’d like displayed:

// _fields/teamGrid.twig

<ul class="...">
	 {% for entry in field %}
		<li class="...">
			{{ component('cards/teamCard', { data: entry }) }} 
		</li>
	{% endfor %}
</ul>

If Team grid was a matrix row instead of an entry relationship array we would do something like:

{% for row in field.collect() %}
  {{ component('matrixRowType/' ~ row.type.handle, {
    data: row,
    opts: opts ?? {},
  }) }}

Implementation

Putting this all together still leaves flexibility for a developer to implement the proper routing patterns and entry → template paths.

Let’s say the entry that holds our members teamGrid entry relationship field points to _pages/teamPage:

// _pages/teamPage.twig

{% extends '_layouts/default' %}

{% block pageBody %}

  {{ field('hero', {
    field: entry.hero,
  }) }}

  {{ field('teamGrid', {
    field: entry. teamGrid,
  } ) }}

{% endblock %}

We’ve been using this pattern for 18 months, and we’ve made a few minor refinements as we’ve progressed — but the core ideas have never wavered. Finding ways to decrease time to comprehension has started paying dividends for both our clients and us.

If you’d like to give these Craft Conventions a spin, check out the docs and install instructions and code on GitHub.