Content Modeling in CraftCMS Using Matrix Blocks

TLDR;

The goal of this article is to show how to model the content blocks of your site using Matrix and Twig, and some convetions to create easy to maintain templates. This article assumes that you have a basic understanding of CraftCMS, it's Matrix field and Twig template language.

One of my favorite CraftCMS feature is the Matrix field, which allow you to create multiple blocks of content within a single field.

To give you a bit of context, let's assume that you have to build a website with the following requirements:

  • A Pages bucket, that allows the user to to create pages, controls their order, with the following content types, in any given order:

    • Text ontent with basic formating
    • Full width banner image
    • Responsive Video
    • Responsive square image picture gallery, with full size image opening in modal (we will open in a new tab for now)
  • A Blog bucket, with the same fields above, plus:

    • Cover Picture
    • Excerpt
  • Two single sections, Home and Blog (for our blog listing page)

After installing and configuring Craft, we can setup our template directory as follow:

./templates/
├── _blocks/
├──── ... (we will add those later)
├── _layouts/
├──── _default.twig
├── _pages/
├──── _home.twig
├──── _page.twig
├──── _page_template.twig
├──── _blog.twig
├──── _blog_detail.twig
├── _partials/
├──── _content_blocks.twig
├──── _footer.twig
├──── _header.twig

It's good practice to prefix templates and template directories with an underscore to exclude from route mapping.

Let's keep the templates simple for now:

{# _layouts/_default.twig #}
<!doctype html>
<html lang="en">
  <head> {# ... #} </head>
  <body>

    {% include "_partials/_header" %}

    {% block body %}{% endblock %}

    {% include "_partials/_footer" %}

  </body>
</html>
{# _partials/_header.twig #}
<header>
  <a href="{{ url("/") }}">
    <h1>My Awesome Site!</h1>
  </a>
</header>
{# _partials/_footer.twig #}
<footer>
  &copy; {{ now|date('Y') }} My Awesome Site!
</footer>
{# _pages/_home.twig #}
{% extends '_layouts/_default.twig' %}

{% block content %}
  <h2>Home Page</h2>
{% endblock %}

That's enough for now, we will code the other templates later. Now let's create the following sections in Craft:

  • Home: Single (check home checkbox), template _pages/_home
  • Blog Entries: Channel, URI blog/{slug}, template _pages/_blog_detail
  • Pages: Structure, Max Levels 1, URI {slug}, template _pages/_page.
  • Notice that we didn't create a Blog Single Section. We will defer that functionally to the Pages section.

After creating the Pages Section, add the following entries (don't worry about not having any fields yet):

  • About Us (slug: about-us)
  • Blog (slug: blog)
  • Contact (slug: contact)

Let's add some fields!

Now that we have the sections in place, let's add some fields. We can start by adding the Cover Picture (Asset) and Excerpt (Plain Text) fields.

With those out of the way, let's create the Content Blocks field:

Content Block Field Config

Next, let's setup the following blocks: Rich Text, Banner, Video, Gallery:

Rich Text Block Configuration

Banner Block Configuration

Video Block Configuration

Gallery Block Configuration

Save it and let's add this Content Blocks to the Pages section.

To the templates!

Before we code the contentBlocks field into our template, let's setup our _pages/_page.twig to allow us to create custom templates for specific pages. That will allow us to create a custom blog listing template that will list our blog entries.

{# _pages/_page_.twig #}
{% extends [
  "_pages/_" ~ entry.uri,
  "_pages/_page_template"
] %}

The above code makes use of the Dynamic Inheritance feature in Twig, trying first to load a template that matches the entry.uri.

So _pages/_page_template will be the "catch all" page templates, where we will add the follwing code:

{# _pages/_page_template.twig #}
{% extends '_layouts/_default.twig' %}

{% block content %}

  <h3>{{ entry.title }}</h3>

  {# Check if there are any blocks, otherwise return null #}
  {% set contentBlocks = entry.contentBlocks
    ? entry.contentBlocks.all()
    : null
  %}

  {% include '_partials/_content_blocks' with {
    blocks: contentBlocks
  } only %}'

{% endblock %}

Now let's code our _partials/_content_blocks.twig partial:

{# _partials/_content_blocks.twig #}

{% if blocks is defined and blocks %}

  {#
    Here is where the convention kicks in: by including
    the Matrix block based on the Matrix block type name,
    we avoid having to use `switch` statements and maintain
    the code for each block in their own twig file.

    By using "ignore missing" Twig will ignore the statement
    if the block template does not exist. This allow us to
    create and test each block template, one at the time.

    By using "only" at the end, we disable access to the
    CraftCMS context, ensuring that only content in the Matrix
    block is available to the block template.
  #}
  {% for blk in blocks %}
    {% include '_blocks/_' ~ blk.type ignore missing with {
      blk: blk
    } only %}
  {% endfor %}

{% endif %}

Great! At this point, we can start coding each block type:

{# _blocks/_richText.twig #}

{# check if blk is the correct type #}
{% if blk is defined and blk and blk.type == 'richText' %}
<div class="content-block content-block--rich-text">
  {{ blk.richContent }}
</div>
{% endif %}
{# _blocks/_banner.twig #}

{# check if blk is the correct type #}
{% if blk is defined and blk and blk.type == 'banner' %}

  {% set pic = blk.picture.one() %}

  {% if pic %} {# only display block if picture exists #}
    <div class="content-block content-block--banner">
      <picture>
        <img src="{{pic.url}}" alt="{{pic.title}}">
      </picture>
    </div>
  {% endif %}

{% endif %}
{# _blocks/_video.twig #}

{# check if blk is the correct type #}
{% if blk is defined and blk and blk.type == 'video' %}

  {% if blk.videoUrl and blk.videoUrl|length %}
  <div class="content-block content-block--video">
    <div class="content-block--video--16x9">
      <iframe src="{{blk.videoUrl}}?rel=0" title="YouTube video" allowfullscreen></iframe>
    </div>
  </div>
  {% endif %}

{% endif %}
{# _blocks/_gallery.twig #}

{# check if blk is the correct type #}
{% if blk is defined and blk and blk.type == 'gallery' %}

  {% for pic in blk.pictures.all() %}

    {% if loop.first %}<div class="content-block content-block--gallery">{% endif %}

    {# create square thumbnails and the full version #}
    {% set transformedImages = craft.imager.transformImage(pic, [
      { width: 100, ratio: 1/1 },
      { width: 1200 },
    ],{
      jpegQuality: 80,
      position: pic.focalPoint.x * 100 ~ '% ' ~ pic.focalPoint.y * 100 ~ '%',
      allowUpscale: true,
    }) %}

    <a href="{{transformedImages[0].url}}" target="_blank">
      <img src="{{transformedImages[1].url}}" alt="{{pic.url}}" />
    </a>

    {% if loop.last %}</div>{% endif %}

  {% endfor %}

{% endif %}

With the block templates in place, let's add the Cover Picture, Excerpt and Content Blocks fields to the Blog section and edit the _pages/_blog_detail template:

{# _pages/_blog_detail.twig #}

{% extends '_layouts/_default.twig' %}

{% block content %}

  <h3>Blog / {{ entry.title }}</h3>

  {# Same as _page_template #}
  {% set contentBlocks = entry.contentBlocks
    ? entry.contentBlocks.all()
    : null
  %}

  {% include '_partials/_content_blocks' with {
    blocks: contentBlocks
  } only %}'

{% endblock %}

Let's leverage the Dynamic Inheritance we've put in place and implement our blog listing template by extending the _page_template template and add the listing at the bottom of the page:

{# _pages/_blog.twig #}

{% extends '_pages/_page_template.twig' %}

{% block content %}

  {# load the regular page content  #}
  {{ parent() }}

  {# list blog entries  #}
  {% set blogEntries = craft.entries().section('blogEntries').all() %}
  <ul>
  {% for blogEntry in blogEntries %}
    <li><a href="{{blogEntry.url}}">{{blogEntry.title}}</a></li>
  {% endfor %}
  </ul>

{% endblock %}

And that's it! From this point now you can add more pages, customize some of the page templates, like adding a form to the Contact page, etc.

By leveraging naming convention with Twig's extends and include we can design independent, well-defined templates that keep our code decoupled and avoid global data. That's the text-book definition of Orthogonality.

Happy Coding 😎 !

Bonus Points

You can go even further by adding either Super Table or Neo plugins and create more complex blocks following the same pattern.