Craft CMS Matrix Field: 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. We could not have been more wrong. The update to the Craft CMS Matrix field to allow nesting was the most profound addition to any CMS we've experienced since they added Project Config in version 3.
We'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
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.

Brilliance NW is a 5 star rated and certified SEM Rush Agency partner that specializes in web development, SEO and Digital Marketing. We have bright and talented people dating back to the 90's when the internet first became a thing.
No project is too complex. We love to tackle new challenges and equally we love making our clients happy and want to believe that in one way or another we have increased the quality of their business and their professional lives.
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.
Read moreA brief introduction to consensus mechanisms and why proof of stake is the right move for Ethereum.
Read moreLet's chat about your project
Portland, OR 97215