Andrew Welch · Insights · #craftcms #matrix #behaviors

Published , updated · 5 min read ·


Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.

Searching Craft CMS Matrix Blocks

With a lit­tle cus­tom Behav­ior, we can eas­i­ly query for Ele­ments such as Entries in Craft CMS based on the con­tents of the Matrix Blocks they contain

Matrix blocks in Craft CMS are fan­tas­ti­cal­ly flex­i­ble, allow­ing you to cre­ate high­ly cus­tomized free-form block edi­tors for your con­tent authors.

How­ev­er, the more con­tent you put into Matrix blocks, the more you may need to search on the con­tent they contain.

The more you put into the Matrix, the more you’ll want to get out

While Craft CMS offers Matrix block queries to facil­i­tate query­ing them, some com­mon pat­terns can be a lit­tle complicated.

For instance, a com­mon sit­u­a­tion is you need to query for Ele­ments such as Entries, Orders, etc. based on the con­tent inside of Matrix blocks that they contain.

In this arti­cle, we’ll learn how to do just that, so read on!

Link Hot Fudge Sundae

We’ll lever­age the same exam­ple used in the Cus­tom Matrix Block Peer Val­i­da­tion Rules arti­cle, with a Sun­dae Builder that allows you to build sun­daes by adding scoops and top­pings as matrix blocks.

Let’s have a quick look at the set­up in the Craft CMS CP for our Sun­dae Builder.

First, we have a Sundae sec­tion that has a sin­gle field added to it, a Matrix block field named Sundae Builder:

Sun­daes Section

We then have two block types in our Sun­dae Builder.

First, we have the Scoop block with a field named Flavor that lets you add a scoop of ice cream, and pick a flavor:

Scoop Matrix block type

Next, we have a Topping block with a field named Kind that lets you add a top­ping to the sundae:

Top­ping Matrix block type

While the exam­ple pre­sent­ed here is rel­a­tive­ly sim­ple, we can expand on it to do some pret­ty amaz­ing things.

Link The Problem

So with this set­up, what we want to be able to do is find Entries in the Sun­dae sec­tion, but we want to fil­ter them based on the con­tent in the Sun­dae Builder Matrix blocks.

For exam­ple, maybe we need to find all Entries in the Sun­dae sec­tion where the sun­dae was made with a scoop of choco­late ice cream.

There’s no straight­for­ward way to do this in Craft CMS, but Bran­don Kel­ly post­ed a solu­tion on a GitHub issue that we’re bas­ing ours on.

The basic idea is to query the Matrix blocks first, then use the results to filter our Element query

So what we do is find the Matrix blocks that match our cri­te­ria, get their ownerIds (the id of the Ele­ment that con­tains the Matrix field), and then use the result to fil­ter our Ele­ment query.

While this is all well and good if we’re flu­ent in PHP, we’d like a solu­tion that works in Twig as well. In addi­tion, a way for it to be a bit more gen­er­al­ized would be aces.

Link Oh, Behave!

What we’re going to do is make queries fil­tered by Matrix cri­te­ria glob­al­ly avail­able to all Ele­ment queries, in both PHP and Twig.

We’ll do this by uti­liz­ing Yii2 Behav­iors, which allow us to dynam­i­cal­ly extend the func­tion­al­i­ty of exist­ing PHP classes.

For more on Behav­iors, check out the Extend­ing Craft CMS with Val­i­da­tion Rules and Behav­iors article.

We’ll be adding this func­tion­al­i­ty via a Mod­ule, dis­cussed in depth in the Enhanc­ing a Craft CMS 3 Web­site with a Cus­tom Mod­ule article.

Every website I create has a Module baked into it called Site Module.

Even if your Craft CMS web­site does­n’t need a cus­tom Mod­ule imme­di­ate­ly, it’s nice to have the scaf­fold­ing there in place for future needs.

So let’s have a look at the full MatrixCriteriaBehavior that we’re using (don’t wor­ry, we’ll break it down later):

<?php

namespace modules\sitemodule\behaviors;

use Craft;
use craft\elements\db\ElementQuery;
use craft\elements\db\ElementQueryInterface;
use craft\elements\MatrixBlock;
use craft\events\CancelableEvent;

use yii\base\Behavior;

/**
 * @author    nystudio107
 * @package   SiteModule
 * @since     1.0.0
 */
class MatrixCriteriaBehavior extends Behavior
{
    // Constants
    // =========================================================================

    const NO_MATCHING_MATRIX_CRITERIA = 'no-matching-matrix-criteria';

    // Public Properties
    // =========================================================================

    /**
     * @var string the Matrix field to use
     */
    public $matrixFieldHandle;

    /**
     * @var array the criteria for the Matrix query
     */
    public $matrixCriteria;

    // Public Methods
    // =========================================================================

    /**
     * @inheritDoc
     */
    public function events()
    {
        return [
            ElementQuery::EVENT_BEFORE_PREPARE => function($event) {
                $this->applyMatrixCriteriaParams($event);
            },
        ];
    }

    /**
     * Limit the ElementQuery to elements that match the passed in Matrix criteria
     *
     * @param string $matrixFieldHandle the handle of the Matrix field to match the criteria in
     * @param array $matrixCriteria the criteria for the MatrixBlock query
     * @return ElementQueryInterface
     */
    public function matrixCriteria(string $matrixFieldHandle, array $matrixCriteria): ElementQueryInterface
    {
        $this->matrixFieldHandle = $matrixFieldHandle;
        $this->matrixCriteria = $matrixCriteria;
        /* @var ElementQueryInterface $elementQuery */
        $elementQuery = $this->owner;

        return $elementQuery;
    }

    // Private Methods
    // =========================================================================

    /**
     * Apply the 'matrixFieldHandle' & 'matrixCriteria' params to select the ids
     * of the elements that own matrix blocks that match, and then add them to the
     * id parameter of the ElementQuery
     *
     * @param CancelableEvent $event
     */
    private function applyMatrixCriteriaParams(CancelableEvent $event): void
    {
        if (!$this->matrixFieldHandle || empty($this->matrixCriteria)) {
            return;
        }
        /* @var ElementQueryInterface $elementQuery */
        $elementQuery = $this->owner;
        // Get the id of the matrix field from the handle
        $matrixField = Craft::$app->getFields()->getFieldByHandle($this->matrixFieldHandle);
        if ($matrixField === null) {
            return;
        }
        // Set up the matrix block query
        $matrixQuery = MatrixBlock::find();
        // Mix in any criteria for the matrix block query
        Craft::configure($matrixQuery, $this->matrixCriteria);
        // Get the ids of the elements that contain matrix blocks that match the matrix block query
        $ownerIds = $matrixQuery
            ->fieldId($matrixField->id)
            ->select('matrixblocks.ownerId')
            ->orderBy(null)
            ->distinct()
            ->column();
        // If the original query's `id` is not empty, use the intersection
        if (!empty($elementQuery->id)) {
            $originalIds = $elementQuery->id;
            if (!is_array($originalIds)) {
                $originalIds = [(int)$originalIds];
            }
            $ownerIds = array_intersect($originalIds, $ownerIds);
        }
        // Ensure the parent query returns nothing if no ids were found
        if (empty($ownerIds)) {
            $ownerIds = null;
            $elementQuery->uid = self::NO_MATCHING_MATRIX_CRITERIA;
        }
        // Add them to the original query that was passed in
        $elementQuery->id($ownerIds);
    }
}

This is a Behav­ior, locat­ed in our Site Mod­ule’s modules/sitemodule/src/behaviors/ direc­to­ry, that we will attach to the craft\elements\db\ElementQuery class, which is what you’re using every time you do some­thing like:

{% set orders = craft.entries
    .section('sundaes')
    .all()
%}

This Behav­ior does the following:

  • Adds two new prop­er­ties to the ElementQuery class: $matrixFieldHandle & $matrixCriteria to hold the new para­me­ters our Matrix­Cri­te­ria needs
  • Adds a new method to the ElementQuery class called matrixCriteria() that lets you spec­i­fy the Matrix field han­dle and Matrix cri­te­ria to fil­ter the Ele­ment query by
  • Lis­tens for the ElementQuery::EVENT_BEFORE_PREPARE event that the ElementQuery trig­gers before the query para­me­ters you’ve spec­i­fied are con­vert­ed into a data­base query
  • When that event is trig­gered, it finds any Matrix blocks that match the Matrix field han­dle and Matrix cri­te­ria you’ve spec­i­fied, and adds them to the id query para­me­ter of the Ele­ment query

This allows you to then do a query like this:

{% set orders = craft.entries
    .section('sundaes')
    .matrixCriteria('sundaeBuilder', {
        'type': 'scoop',
        'flavor': 'chocolate'
    })
    .all()
%}

Or the same Matrix­Cri­te­ria query in PHP:

use craft\elements\Entry;

$orders = Entry::find()
    ->section('sundaes')
    ->matrixCriteria('sundaeBuilder', [
        'type' => 'scoop',
        'flavor' => 'chocolate'
    ])
    ->all();

Both of these query the sundaes Sec­tion for Entries that have a Matrix field with the han­dle sundaeBuilder for Matrix blocks of the type scoop, which have the cus­tom field flavor set to chocolate.

This just works” because the Behav­ior has been added to the built-in Craft ElementQuery! 🎉

You can pass any­thing into the sec­ond para­me­ter that you nor­mal­ly would in a Matrix Block query, includ­ing cus­tom fields.

It’s just using array syn­tax rather than object syn­tax, so that you don’t have to cre­ate a Query object to pass in.

N.B.: We could also have lis­tened for the ElementQuery::EVENT_AFTER_PREPARE event, and mod­i­fied the $elementQuery->subQuery instead, to accom­plish the same thing (and it might even lead to a bit clean­er of an imple­men­ta­tion, too).

Link Getting Attached

The only oth­er thing we need to do in order to make the MatrixCriteriaBehavior work is attach it to Ele­men­t­Query objects.

We do that dynam­i­cal­ly in our Site Mod­ule’s init() method like so:

use modules\sitemodule\behaviors\MatrixCriteriaBehavior;

use craft\elements\db\ElementQuery;
use craft\events\DefineBehaviorsEvent;

// Add the MatrixCriteriaBehavior behavior to ElementQuery objects
Event::on(
    ElementQuery::class,
    ElementQuery::EVENT_DEFINE_BEHAVIORS,
    function(DefineBehaviorsEvent $event) {
        $event->sender->attachBehaviors([
            MatrixCriteriaBehavior::class,
        ]);
    }
);

Link End of Query

You can use this Behav­ior right out of the box to start doing queries based on the con­tents of Matrix blocks.

In fact, you could include this Behav­ior as a default on every site you make, so the func­tion­al­i­ty is there wait­ing for you.

If some­one wants to turn this into a plu­g­in to make the bar­ri­er of entry low­er, feel free to do so!

But more impor­tant­ly, hope­ful­ly, it has also giv­en you insight into how you might extend Ele­men­t­Query objects, or oth­er parts of Craft CMS in won­der­ful ways using Behaviors.

Hap­py querying!