Andrew Welch
Published , updated · 5 min read · RSS Feed
Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.
Custom Matrix Block Peer Validation Rules
Leverage a Craft CMS module to add custom peer validation rules to your content builder Matrix blocks for a better content authoring experience
This article will show you how to make a better content authoring experience in Craft CMS by adding custom validation rules to your Matrix block content builders.
We can do this by extending the basic field validation to include peer validation rules.
Peer validation means whether a matrix block validates or not can depend on other matrix blocks
So instead of each matrix block existing in isolation, we can have them depend on each other so that we can do things like:
- Ensure only so many matrix blocks of a given type exist
- Make sure at least one matrix block of a given type exists
- Validate a matrix block based on the contents of fields in another matrix block
- …and pretty much anything else you’d like to do
We’re assuming you are familiar with using Matrix blocks as content builders; if not, check out the Creating a Content Builder in Craft CMS article first.
To extend our website, we’ll be leveraging a site module; for more on that, see the Enhancing a Craft CMS 3 Website with a Custom Module article.
For more on custom validation, check out the Extending Craft CMS with Validation Rules and Behaviors article.
Link Sundae, Sundae
As an example, we’re going to make a Sundae Builder that allows you to build sundaes by adding scoops and toppings as matrix blocks.
The rub is, we only want to make sure we have:
- A maximum of 2 scoops of ice cream per sundae
- A maximum of 3 toppings per sundae
- At least 1 scoop of ice cream (otherwise, it isn’t a sundae!)
- You can’t mix a scoop of strawberry ice cream and a scoop of chocolate ice cream
We’re not animals; we have our standards.
The result will look something like this:
Notice that this limits how many scoops of ice cream we can have, limits how many toppings we can have, and notes when we have a flavor combination that we don’t deem acceptable.
In addition to the errors associated with each matrix block, we also have summary errors on the entire entry.
Knowing the rules is half the game
This type of peer matrix block validation allows us to have a nicer content authoring experience because we can ensure the hygiene of our content.
Link Sundae Setup
Let’s have a quick look at the setup in the Craft CMS CP for our Sundae Builder.
First, we have a Sundae section that has a single field added to it, a Matrix block field named Sundae Builder:
We then have two block types in our Sundae 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:
Next, we have a Topping block with a field named Kind that lets you add a topping to the sundae:
So nothing 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 element in Craft CMS will already have to pass whatever validations are associated with the fields it contains.
We want to add to these existing validations.
We’ll achieve this by using a custom site module as a way to add the validation rules we need. In case you’re new to custom site modules, see the Enhancing a Craft CMS 3 Website with a Custom Module article.
We add our custom validation rules in our addCustomValidationRules() method that we’ll call from our module’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 listening for the EVENT_DEFINE_RULES event triggered by the Entry class.
We first check whether the $event->sender (which is going to be an Entry element) is from our Sundaes section and if so, we add our own validation rule for the matrix block field that has the handle sundaeBuilder.
We pass in the class name of our standalone SundaeValidator that should be called to validate the sundaeBuilder field.
Link Sundae Validator
For model validation rules, you can leverage the built-in core validators in Yii2, or you can write your own standalone validator classes as we have for our custom SundaeValidator.
This is the meat on the bone, so we’ll discuss 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 validator has to implement at least the validateAttribute() method, which gets passed in the $model the validation is being done on, and the $attribute being validated.
In our case, the $model will always be the Entry element 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, counting how many scoops & toppings we have, and adding errors to the MatrixBlock elements as we go.
Houston, we have a problem
This all sounds pretty straightforward, but there’s a rub. Before the validation on our Entry gets called, all of the matrix block elements themselves have already been validated.
That means any errors we add to them will be ignored by Craft, because as far as it is concerned, the matrix block elements have already been validated, and it is just validating the parent Entry element now.
So what we do is leverage the fact that ElementQuery’s all have an internal 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 validated, we want to keep whatever errors have already been added by grabbing the result of the query from getCachedResult().
And then, once we’ve potentially added our own new errors to the matrix block elements, 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 elements we added our errors to and puts them into the result cache, automatically used by the query when anything else executes it. 🎉
We’re essentially swapping in our new set of matrix block elements (with the errors added to them) into Craft’s internal cache, which is what it’ll use when displaying them.
This is exactly how Craft handles tracking validation errors on matrix blocks internally
This allows us to help the content author by highlighting the matrix blocks where the validation error occurred, rather than just showing an overall error on the entry.
This can be crucial if you have a content builder with many matrix blocks in the “matrix soup.”
Then finally, we also add some summary errors on the $model (our Entry) to provide greater clarity on what didn’t validate.
N.B.: We didn’t make our validation errors translatable for brevity’s sake, but you should do so with Craft::t().
Link Feeling Validated
This simple example is just the tip of the iceberg; you can make some fairly involved validations using the patterns shown here.
In the end, the reason for doing so is to provide more information for our content authors, to make it a more pleasant experience for everyone.
Happy validation!