Andrew Welch · Insights · #craftcms #matrix #behaviors

Published , updated · 5 min read ·


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

Custom Matrix Block Peer Validation Rules

Lever­age a Craft CMS mod­ule to add cus­tom peer val­i­da­tion rules to your con­tent builder Matrix blocks for a bet­ter con­tent author­ing experience

This arti­cle will show you how to make a bet­ter con­tent author­ing expe­ri­ence in Craft CMS by adding cus­tom val­i­da­tion rules to your Matrix block con­tent builders.

We can do this by extend­ing the basic field val­i­da­tion to include peer val­i­da­tion rules.

Peer validation means whether a matrix block validates or not can depend on other matrix blocks

So instead of each matrix block exist­ing in iso­la­tion, we can have them depend on each oth­er so that we can do things like:

  • Ensure only so many matrix blocks of a giv­en type exist
  • Make sure at least one matrix block of a giv­en type exists
  • Val­i­date a matrix block based on the con­tents of fields in anoth­er matrix block
  • …and pret­ty much any­thing else you’d like to do

We’re assum­ing you are famil­iar with using Matrix blocks as con­tent builders; if not, check out the Cre­at­ing a Con­tent Builder in Craft CMS arti­cle first.

To extend our web­site, we’ll be lever­ag­ing a site mod­ule; for more on that, see the Enhanc­ing a Craft CMS 3 Web­site with a Cus­tom Mod­ule article.

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

Link Sundae, Sundae

As an exam­ple, we’re going to make a Sun­dae Builder that allows you to build sun­daes by adding scoops and top­pings as matrix blocks.

The rub is, we only want to make sure we have:

  • A max­i­mum of 2 scoops of ice cream per sundae
  • A max­i­mum of 3 top­pings per sundae
  • At least 1 scoop of ice cream (oth­er­wise, it isn’t a sundae!)
  • You can’t mix a scoop of straw­ber­ry ice cream and a scoop of choco­late ice cream

We’re not ani­mals; we have our standards.

The result will look some­thing like this:

Sun­dae Builder Matrix blocks with peer val­i­da­tion rules

Notice that this lim­its how many scoops of ice cream we can have, lim­its how many top­pings we can have, and notes when we have a fla­vor com­bi­na­tion that we don’t deem acceptable.

In addi­tion to the errors asso­ci­at­ed with each matrix block, we also have sum­ma­ry errors on the entire entry.

Knowing the rules is half the game

This type of peer matrix block val­i­da­tion allows us to have a nicer con­tent author­ing expe­ri­ence because we can ensure the hygiene of our content.

Link Sundae Setup

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

So noth­ing too crazy here; now let’s take a look at the code we can use to make this happen.

Link Adding Event Rules

An Entry ele­ment in Craft CMS will already have to pass what­ev­er val­i­da­tions are asso­ci­at­ed with the fields it contains.

We want to add to these exist­ing validations.

We’ll achieve this by using a cus­tom site mod­ule as a way to add the val­i­da­tion rules we need. In case you’re new to cus­tom site mod­ules, see the Enhanc­ing a Craft CMS 3 Web­site with a Cus­tom Mod­ule article.

We add our cus­tom val­i­da­tion rules in our addCustomValidationRules() method that we’ll call from our mod­ule’s init() method:

<?php

namespace modules\sitemodule;

use modules\sitemodule\validators\SundaeValidator;

use craft\elements\Entry;
use craft\events\DefineRulesEvent;

class SiteModule extends Module
{

    // Constants
    // =========================================================================
    
    const SUNDAES_SECTION_ID = 7;

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

    /**
     * @inheritdoc
     */
    public function init()
    {
        parent::init();
        $this->addCustomValidationRules();
    }

// ... other module code

    // Protected Methods
    // =========================================================================
    
    /**
     * Register our custom validation rules
     */
    protected function addCustomValidationRules()
    {
        Event::on(
            Entry::class,
            Entry::EVENT_DEFINE_RULES,
            function(DefineRulesEvent $event) {
                if ((int)$event->sender->sectionId === self::SUNDAES_SECTION_ID) {
                    $event->rules[] = [['sundaeBuilder'], SundaeValidator::class];
                }
            });
    }
}

Here we’re lis­ten­ing for the EVENT_DEFINE_RULES event trig­gered by the Entry class.

We first check whether the $event->sender (which is going to be an Entry ele­ment) is from our Sun­daes sec­tion and if so, we add our own val­i­da­tion rule for the matrix block field that has the han­dle sundaeBuilder.

We pass in the class name of our stand­alone SundaeValidator that should be called to val­i­date the sundaeBuilder field.

Link Sundae Validator

For mod­el val­i­da­tion rules, you can lever­age the built-in core val­ida­tors in Yii2, or you can write your own stand­alone val­ida­tor class­es as we have for our cus­tom SundaeValidator.

This is the meat on the bone, so we’ll dis­cuss this class in detail below:

<?php

namespace modules\sitemodule\validators;

use craft\elements\db\MatrixBlockQuery;
use craft\elements\Entry;
use craft\helpers\ElementHelper;

use yii\validators\Validator;

class SundaeValidator extends Validator
{
    // Constants
    // =========================================================================

    const MAX_SCOOPS = 2;
    const MAX_TOPPINGS = 3;

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

    /**
     * @param Entry $model
     * @param string $attribute
     */
    public function validateAttribute($model, $attribute)
    {
        // If a revision is being saved, don't run this validation
        if (ElementHelper::isDraftOrRevision($model)) {
            return;
        }
        // Initialize counters
        $numScoops = 0;
        $numToppings = 0;
        $flavors = [];
        /** @var MatrixBlockQuery $sundaeBuilderQuery */
        $sundaeBuilderQuery = $model->$attribute;
        // Iterate through all of the blocks
        $blocks = $sundaeBuilderQuery->getCachedResult() ?? $sundaeBuilderQuery->limit(null)->anyStatus()->all();
        foreach ($blocks as $block) {
            switch ($block->type) {
                case 'scoop':
                    // Make sure we don't have too many scoops
                    $numScoops++;
                    if ($numScoops > self::MAX_SCOOPS) {
                        $block->addError('flavor', 'Too many scoops');
                    }
                    // Make sure we don't have forbidden flavor combos
                    $thisScoop = $block->flavor->value;
                    $flavors[$thisScoop] = true;
                    if (isset($flavors['chocolate'], $flavors['strawberry'])) {
                        if ($thisScoop === 'chocolate' || $thisScoop === 'strawberry') {
                            $block->addError('flavor', 'Gross');
                        }
                    }
                    break;
                case 'topping':
                    // Make sure we don't have too many toppings
                    $numToppings++;
                    if ($numToppings > self::MAX_TOPPINGS) {
                        $block->addError('kind', 'Too many toppings');
                    }
                break;
            }
        }
        // The matrix block elements have already been validated, but we want to trick
        // Craft into adding the new errors we've added onto each of the field's blocks.
        $sundaeBuilderQuery->setCachedResult($blocks);
        // Add errors to the entry, too
        if ($numScoops > self::MAX_SCOOPS) {
            $model->addError($attribute, 'You can only have '.self::MAX_SCOOPS.' scoops of ice cream per sundae');
        }
        if ($numScoops < 1) {
            $model->addError($attribute, 'You have to have at least one scoop of ice cream to make a sundae');
        }
        if ($numToppings > self::MAX_TOPPINGS) {
            $model->addError($attribute, 'You can only have '.self::MAX_TOPPINGS.' toppings per sundae');
        }
        if (isset($flavors['chocolate'], $flavors['strawberry'])) {
            $model->addError($attribute, 'Strawberry and chocolate do not go together; rethink your life.');
        }
    }
}

Every val­ida­tor has to imple­ment at least the validateAttribute() method, which gets passed in the $model the val­i­da­tion is being done on, and the $attribute being validated.

In our case, the $model will always be the Entry ele­ment that we attached the SundaeValidator to, and the $attribute will always be the sundaeBuilder field name (it’s a string).

Recall that our sundaeBuilder field a Matrix field, so the attribute is a MatrixBlockQuery.

We loop through all of the matrix blocks in the sundaeBuilder field, count­ing how many scoops & top­pings we have, and adding errors to the MatrixBlock ele­ments as we go.

Houston, we have a problem

This all sounds pret­ty straight­for­ward, but there’s a rub. Before the val­i­da­tion on our Entry gets called, all of the matrix block ele­ments them­selves have already been validated.

That means any errors we add to them will be ignored by Craft, because as far as it is con­cerned, the matrix block ele­ments have already been val­i­dat­ed, and it is just val­i­dat­ing the par­ent Entry ele­ment now.

So what we do is lever­age the fact that ElementQuerys all have an inter­nal cache of the last query results.

That’s why we do:

        $blocks = $sundaeBuilder->getCachedResult() ?? $sundaeBuilder->limit(null)->anyStatus()->all();

…to get the matrix blocks. If they’ve already been val­i­dat­ed, we want to keep what­ev­er errors have already been added by grab­bing the result of the query from getCachedResult().

And then, once we’ve poten­tial­ly added our own new errors to the matrix block ele­ments, we call setCachedResult():

        // The matrix block elements have already been validated, but we want to trick
        // Craft into adding the new errors we've added onto each of the field's blocks.
        $sundaeBuilder->setCachedResult($blocks);

This takes the MatrixBlock ele­ments we added our errors to and puts them into the result cache, auto­mat­i­cal­ly used by the query when any­thing else exe­cutes it. 🎉

We’re essen­tial­ly swap­ping in our new set of matrix block ele­ments (with the errors added to them) into Craft’s inter­nal cache, which is what it’ll use when dis­play­ing them.

This is exactly how Craft handles tracking validation errors on matrix blocks internally

This allows us to help the con­tent author by high­light­ing the matrix blocks where the val­i­da­tion error occurred, rather than just show­ing an over­all error on the entry.

This can be cru­cial if you have a con­tent builder with many matrix blocks in the matrix soup.”

Then final­ly, we also add some sum­ma­ry errors on the $model (our Entry) to pro­vide greater clar­i­ty on what did­n’t validate.

N.B.: We did­n’t make our val­i­da­tion errors trans­lat­able for brevi­ty’s sake, but you should do so with Craft::t().

Link Feeling Validated

This sim­ple exam­ple is just the tip of the ice­berg; you can make some fair­ly involved val­i­da­tions using the pat­terns shown here.

In the end, the rea­son for doing so is to pro­vide more infor­ma­tion for our con­tent authors, to make it a more pleas­ant expe­ri­ence for everyone.

Hap­py validation!