Andrew Welch · Insights · #craftcms #craft-3 #php

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

Craft cms matrix block validation

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:

Sundae builder craft cms matrix block validation

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:

Sundae section craft cms matrix block validation

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 flavor craft cms matrix block validation

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:

Topping kind craft cms matrix block validation

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.

This is where the magic happens

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.

Hope you feel validated

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!