Craft CMS, how to insert or update nested Matrix content
When the Pixel and Tonic team rolled out Craft CMS v5 last year, it seemed like any other upgrade with some minor features and improvements. I could not have been more wrong. The update to the Matrix field to allow nesting was the most profound addition to any CMS I've experienced since they added Project Config in version 3.
I've coming up for air after spending the last few months diving deep into the capabilities (and one annoying limitation) of the Matrix field in Craft 5.
Importing data into entries that have matrix fields nested in matrix fields is not supported by the import plugin, Feed Me (yet). Below, let's explore how to get data into a Matrix field within a Matrix field using a custom plugin.
A recent project had thousands of recipes stored in a simple database managed by Laravel. We were given a dump of the the DB and tasked with rebuilding these recipes in Craft CMS v5... challenge accepted!
Each Recipe has some Meta information (Title, Description, number of servings, etc) at the top level, which is easy enough. But the Ingredients and Instructions can be nested a couple levels deep. Let's take No-Bake Cherry Pomegranate Cheesecake, as an example. The ingredients are a list of Ingredient Groups (Crust, Filling, Cherry Pomegranate topping). Each of these groups has a list of ingredients. Each of these ingredients has several pieces of data (quantity, name, whether or not there is an associated product in the catalog, etc). This is a perfect example of why we love nested matrix fields.
We started by mapping out the data we received and to then map out how this would be best organized in Craft.
Basic Recipe Structure
- Title --> Native field
- Description --> Redactor WYSIWYG field
- Ingredients --> Matrix field "ingredientGroups", using a "Ingredient Group" entry type
- Ingredient Group #1 (Crust)
- Group Title
- Ingredients --> Matrix field "ingredients", using an "Ingredient" entry type
- Granola
- Butter
- Ingredient Group #2 (Filling)
- Group Title
- Ingredients --> Matrix field "ingredients", using an "Ingredient" entry type
- Cream Cheese
- Sweetened Condensed Milk
- Sugar
- Vanilla
- Ingredient Group #3 (Pomegranate topping)
- Group Title
- Ingredients --> Matrix field "ingredients", using an "Ingredient" entry type
- Pomegranate Juice
- Corn Starch
- Cherries
- Salt
- Vanilla
- Ingredient Group #1 (Crust)
That's all great... but we had a pile of data in a legacy database, and a bright shiny (empty) Craft Channel waiting for data to be inserted. Feed Me can't bring the data in, so we have to build a little plugin to do this.
When we start a significant project, we always create a "Custom Features" plugin. This kitchen sink is used whenever we need to make a custom twig function, do something when an event is triggered or whatever. In this case, we're going to leverage the console CLI to do some work for us. In short, we will do this:
- From the CLI, trigger the console controller
- Connect to a separate DB (the one where we have the legacy recipes)
- Load some recipe information, check if it's been inserted before (that way, if there's an error along the road, we can fix and restart without creating duplicates)
- Create a nested array of information
- Create a new entry, and load that nest of info into the record.
- Save the recipe record and do some checks to see if it's been inserted correctly...
- repeat until all recipes are inserted
Once the data is mapped, and the entry types and fields are all organized in Craft, the rest is an exercise in nested arrays:
// Top-level recipe data
$recipe = [
'title' => 'No-Bake Cherry Pomegranate Cheesecake', // Native field for the recipe title
'description' => 'Too hot to cook? This creamy, luscious dessert needs no oven—just mix and chill overnight for a cool, fruity treat everyone will love. Featuring our Homestyle Granola, this cheesecake can also be made dairy free!', // Redactor WYSIWYG field
'ingredientGroups' => [ // Matrix field "ingredientGroups"
[
'type' => 'ingredientGroups', // Must match your block type handle
'title' => 'Crust', // Ingredient Group #1 title
'fields' => [
'groupTitle' => 'Crust', // If you have a dedicated group title field
'ingredients' => [ // Nested Matrix field "ingredients"
[
'type' => 'ingredients', // Must match your nested block type handle
'title' => 'Granola', // Ingredient title
'fields' => [
// Additional fields like quantity, product, etc.
],
],
[
'type' => 'ingredients',
'title' => 'Butter',
'fields' => [
// Additional fields for Butter if needed
],
],
],
],
],
[
'type' => 'ingredientGroups',
'title' => 'Filling', // Ingredient Group #2 title
'fields' => [
'groupTitle' => 'Filling',
'ingredients' => [
[
'type' => 'ingredients',
'title' => 'Cream Cheese',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Sweetened Condensed Milk',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Sugar',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Vanilla',
'fields' => [],
],
],
],
],
[
'type' => 'ingredientGroups',
'title' => 'Pomegranate topping', // Ingredient Group #3 title
'fields' => [
'groupTitle' => 'Pomegranate topping',
'ingredients' => [
[
'type' => 'ingredients',
'title' => 'Pomegranate Juice',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Corn Starch',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Cherries',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Salt',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Vanilla',
'fields' => [],
],
],
],
],
],
];
This structure allows us to nest as many levels deep as we need, while simply gathering the data using PHP and bringing it into the Craft record.
namespace modules\mymodule\console\controllers;
use Craft;
use craft\elements\Entry;
use craft\console\Controller;
use yii\console\ExitCode;
class RecipeController extends Controller
{
public function actionCreateRecipe()
{
// Recipe data as defined earlier
$recipe = [
'title' => 'Your Recipe Title',
'description' => 'A detailed recipe description goes here...',
'ingredientGroups' => [
[
'type' => 'ingredientGroups', // Matrix block type handle
'title' => 'Crust',
'fields' => [
'groupTitle' => 'Crust',
'ingredients' => [
[
'type' => 'ingredients', // Nested Matrix block type handle
'title' => 'Granola',
'fields' => [
// Example additional fields:
// 'quantity' => '1 Cup',
// 'product' => [123], // Assuming product ID is 123
],
],
[
'type' => 'ingredients',
'title' => 'Butter',
'fields' => [],
],
],
],
],
[
'type' => 'ingredientGroups',
'title' => 'Filling',
'fields' => [
'groupTitle' => 'Filling',
'ingredients' => [
[
'type' => 'ingredients',
'title' => 'Cream Cheese',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Sweetened Condensed Milk',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Sugar',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Vanilla',
'fields' => [],
],
],
],
],
[
'type' => 'ingredientGroups',
'title' => 'Pomegranate topping',
'fields' => [
'groupTitle' => 'Pomegranate topping',
'ingredients' => [
[
'type' => 'ingredients',
'title' => 'Pomegranate Juice',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Corn Starch',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Cherries',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Salt',
'fields' => [],
],
[
'type' => 'ingredients',
'title' => 'Vanilla',
'fields' => [],
],
],
],
],
],
];
// Get the section for recipes
$section = Craft::$app->sections->getSectionByHandle('recipes');
if (!$section) {
$this->stderr("Section 'recipes' not found.\n");
return ExitCode::UNSPECIFIED_ERROR;
}
// Retrieve the recipe entry type from the section
$entryTypes = $section->getEntryTypes();
$recipeEntryType = null;
foreach ($entryTypes as $entryType) {
if ($entryType->handle === 'recipe') {
$recipeEntryType = $entryType;
break;
}
}
if (!$recipeEntryType) {
$this->stderr("Entry type 'recipe' not found in section 'recipes'.\n");
return ExitCode::UNSPECIFIED_ERROR;
}
// Create the new entry
$entry = new Entry();
$entry->sectionId = $section->id;
$entry->typeId = $recipeEntryType->id;
$entry->title = $recipe['title'];
// Set custom field values
$entry->setFieldValue('description', $recipe['description']);
$entry->setFieldValue('ingredientGroups', $recipe['ingredientGroups']);
// Save the entry
if (Craft::$app->elements->saveElement($entry)) {
$this->stdout("Recipe entry created successfully (ID: {$entry->id}).\n");
return ExitCode::OK;
} else {
$this->stderr("Could not save recipe entry.\n");
$this->stderr(print_r($entry->getErrors(), true));
return ExitCode::UNSPECIFIED_ERROR;
}
}
}
When the client gave us an updated DB dump half-way through the project, it was trivial to reload the local DB, run the command and it imported the new recipes flawlessly.
The advancements in Craft CMS v5—particularly the ability to nest Matrix fields—have opened up so many possibilities for managing complex data structures. This capability not only streamlines the migration of legacy data but also empowers us to build more flexible, scalable applications with ease. By leveraging a custom plugin and CLI controller, even intricate recipe structures can be imported seamlessly, underscoring Craft's commitment to solving real-world challenges in content management.
Continue reading.
The Element API plugin is a very powerful tool that you can use for quickly exposing your data structures to an external source.
Find out moreHere at Brilliance, we LOVE CraftCMS. Our clients love it as well.
Find out moreA brief introduction to consensus mechanisms and why proof of stake is the right move for Ethereum.
Find out moreLet's chat about your project
Portland, OR 97215