Andrew Welch · Insights · #craftcms #craft-5 #migration

Published , updated · 5 min read ·


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

Adding Content with Content Migrations in Craft CMS

Learn how to cre­ate con­tent migra­tions in Craft CMS 5 to add con­tent that syncs across environments

If you need to cre­ate Sec­tions, Fields, Entry Types, etc. in Craft CMS you’d typ­i­cal­ly cre­ate them in local devel­op­ment, and then push the changes to oth­er envi­ron­ments using Project Con­fig.

This works great for schema changes, but you can’t add con­tent like Entries this way.

There are cas­es where you might want to add con­tent in an auto­mat­ed way, such as gen­er­at­ing data, import­ing data, or using an exter­nal API.

It’d be nice if you could write some code to add the con­tent, and ensure that code gets run deter­min­is­ti­cal­ly in all environments.

My goal was to seed the database with a ton of data for performance testing

My spe­cif­ic use-case is that I need­ed to be able to add a whole bunch of Entries to the data­base with usable seed data for per­for­mance test­ing purposes.

I also want­ed to be able to have the con­tent migra­tion cre­ate all of the Fields, Entry­Types, and Sec­tions for me in addi­tion to adding Entries, and I want­ed to be able to remove every­thing it added after the test­ing was done.

And final­ly, I want­ed to be able to do this test­ing in local devel­op­ment, as well as in a live pro­duc­tion environment.

Link Content Migrations

This is exact­ly what Con­tent Migra­tions in Craft CMS allow you to do.

Content Migrations to the rescue!

Con­tent Migra­tions are just Yii2 migra­tions by anoth­er name. These are the same migra­tions that Craft and some plu­g­ins run when they need to update the data­base in some manner.

Migra­tions are PHP files that con­tain code which alters the data­base from one state to anoth­er. Migra­tions are ver­sion con­trolled in your Git repos­i­to­ry along with the rest of your pro­jec­t’s source code.

Con­tent Migra­tions are typ­i­cal­ly run via deploy­ment, the same way you run pend­ing Craft or plu­g­in migrations:

php craft up

The Craft CLI com­mand craft up runs pend­ing migra­tions and applies pend­ing project con­fig changes.

You can also use the craft migrate CLI com­mand to run migra­tions, roll back migra­tions, redo migra­tions, and more. Let’s have a look at the help for the craft migrate CLI com­mand to see all of the avail­able options:

DESCRIPTION

Manages Craft and plugin migrations.

A migration means a set of persistent changes to the application environment that is shared among different
developers. For example, in an application backed by a database, a migration may refer to a set of changes to
the database, such as creating a new table, adding a new table column.
This controller provides support for tracking the migration history, updating migrations, and creating new
migration skeleton files.
The migration history is stored in a database table named migrations. The table will be automatically
created the first time this controller is executed, if it does not exist.
Below are some common usages of this command:
~~~
# creates a new migration named 'create_user_table' for a plugin with the handle pluginHandle.
craft migrate/create create_user_table --plugin=pluginHandle
# applies ALL new migrations for a plugin with the handle pluginHandle
craft migrate up --plugin=pluginHandle
~~~


SUB-COMMANDS

- migrate/all           Runs all pending Craft, plugin, and content migrations.
- migrate/create        Creates a new migration.
- migrate/down          Downgrades the application by reverting old migrations.
- migrate/fresh         Not supported.
- migrate/history       Displays the migration history.
- migrate/mark          Modifies the migration history to the specified version.
- migrate/new           Displays the un-applied new migrations.
- migrate/redo          Redoes the last few migrations.
- migrate/to            Upgrades or downgrades till the specified version.
- migrate/up (default)  Upgrades Craft by applying new migrations.

To see the detailed information about individual sub-commands, enter:

  craft help <sub-command>

You’ll also see the migra­tion in the Craft CP via Util­i­ties → Migra­tions, where you can run them as well (but we’ll stick to the CLI):

Migra­tions in the Craft CP

Link Creating a Migration

We’ve explored cre­at­ing con­tent migra­tions before in the Index­ing by Cus­tom Field in Craft CMS arti­cle before, so let’s cre­ate a new migra­tion using the CLI again using craft migrate/create:

/var/www/project/cms_v5 $ php craft migrate/create seed_db_data
Create new migration '/var/www/project/cms_v5/migrations/m240430_143914_seed_db_data.php'? (yes|no) [yes]:
New migration created successfully.

Con­tent Migra­tions you cre­ate are stored in the migrations/ direc­to­ry in your project. Note that it also added a date and time­stamp that pre­fix­es the migra­tion name we gave it.

Then after we’re done writ­ing our migra­tion, we can check it into our pro­jec­t’s repos­i­to­ry like any oth­er code.

The skele­ton migra­tion it cre­at­ed for us does­n’t do any­thing at the moment:

<?php

namespace craft\contentmigrations;

use Craft;
use craft\db\Migration;

/**
 * m240430_143914_seed_db_data migration.
 */
class m240430_143914_seed_db_data extends Migration
{
    /**
     * @inheritdoc
     */
    public function safeUp(): bool
    {
        // Place migration code here...

        return true;
    }

    /**
     * @inheritdoc
     */
    public function safeDown(): bool
    {
        echo "m240430_143914_seed_db_data cannot be reverted.\n";
        return false;
    }
}

The safeUp() method is called when the migra­tion is run. The safeDown() method is option­al­ly imple­ment­ed, and is called when the migra­tion is reverted.

Link Migration code overview

We’re going to show the code sec­tion by sec­tion, with expla­na­tions as we go. But if you’d like to see the full migra­tion in one copy & paste­able place, you can find it here in a Gist.

We’re going to flesh out the skele­ton migra­tion we cre­at­ed ear­li­er by adding code that:

  • Cre­ates Fields
  • Cre­ates EntryTypes
  • Cre­ates Sections
  • Adds the Fields we cre­ate to the EntryTypes
  • Cre­ates a large num­ber of Entries with ran­dom seed data

We’ll also imple­ment code to undo all of this so we can remove our seed data seamlessly.

While what we’re doing here may not be exact­ly your use case, the tech­niques we’ll delve into should be use­ful regard­less of what changes you want to make to the data­base and where the data is com­ing from.

N.B. As of Craft 5.0.0, changes to Project Con­fig (such as we’re mak­ing here by adding Fields, Sec­tions, etc.) are now safe to make with­in migra­tions, even when allowAdminChanges is false.

If you just want to cre­ate or import data from some source via a con­tent migra­tion, you can eschew mak­ing schema changes in it, and just skip down to the createEntryData() method.

Link Faking it with Faker

Before we jump into the code, we’re going to be using a PHP pack­age called Fak­er to gen­er­ate ran­dom data for us.

Even though Fak­er has been sun­set­ted, it’s still a very use­ful pack­age to use if you need to gen­er­ate some ran­dom but use­ful data.

But first we’ll need to require it for our project with Composer:

composer require fakerphp/faker

Now that we’ve added Fak­er to our project, let’s jump in and have a look at the code we’re adding to our migra­tion class.

Link Constants

We define a num­ber of con­stants that define what we’re going to create:

    // Handle to the user that should own the entries
    private const USER_NAME = 'admin';
    // The number of entries to create
    private const NUM_ENTRIES = 10000;
    // Array of configs for the Fields to create
    private const FIELD_CONFIGS = [
        [
            'class' => PlainText::class,
            'name' => 'Demo Data',
            'handle' => 'demoData',
            'translationMethod' => Field::TRANSLATION_METHOD_NONE,
            'multiline' => 0,
            'columnType' => Schema::TYPE_STRING,
        ],
    ];
    // Array of configs for the EntryTypes to create
    private const ENTRY_TYPE_CONFIGS = [
        [
            'class' => EntryType::class,
            'name' => 'Demo',
            'handle' => 'demo',
            'customFields' => [
                'demoData'
            ]
        ],
    ];
    // Array of configs for the Sections to create
    private const SECTION_CONFIGS = [
        [
            'class' => Section::class,
            'name' => 'Demo',
            'handle' => 'demo',
            'type' => Section::TYPE_CHANNEL,
            'enableVersioning' => false,
            'entryTypes' => [
                'demo',
            ]
        ],
    ];

The _CONFIGS con­stants are arrays of arrays that define the object con­fig­u­ra­tion that we pass to the Yii2 method createObject().

Think of createObject() as an enhanced ver­sion of the new oper­a­tor in PHP. It allows you to pass in the class of the object to cre­ate, along with any default val­ues for properties.

We define them as con­stants so it’s easy to add what­ev­er Fields, Sec­tions, etc. by adding them to the con­stant dec­la­ra­tion, instead of hav­ing to mod­i­fy our code.

Link safeUp()

The safeUp() method is called when your migra­tion is run via craft migrate/up:

    /**
     * @inheritdoc
     */
    public function safeUp(): bool
    {
        try {
            $this->createFields(self::FIELD_CONFIGS);
            $this->createEntryTypes(self::ENTRY_TYPE_CONFIGS);
            $this->createSections(self::SECTION_CONFIGS);
            $this->addFieldsToEntryTypes(self::SECTION_CONFIGS, self::ENTRY_TYPE_CONFIGS, self::FIELD_CONFIGS);
            $this->createEntryData(self::FIELD_CONFIGS, self::SECTION_CONFIGS[0]['handle'], self::ENTRY_TYPE_CONFIGS[0]['handle'], self::USER_NAME, self::NUM_ENTRIES);
        } catch (Throwable $e) {
            Craft::error($e->getMessage(), __METHOD__);
            return false;
        }

        return true;
    }

Here we’re call­ing meth­ods to cre­ate the Fields, Entry­Types & Sec­tions defined in our con­stants, then adding the Fields to the Entry­Types, and final­ly pop­u­lat­ing a whole bunch of new Entries with seed data.

In our case, we’re pass­ing in the var­i­ous con­fig via con­stants, but they could come from a sep­a­rate JSON file, or an API end­point, or what­ev­er makes sense for your use-case.

Hav­ing the meth­ods accept an array of con­fig­u­ra­tion arrays makes it pret­ty gener­ic and re-usable.

Link safeDown()

The safeDown() method is called when your migra­tion is run via craft migrate/down:

    /**
     * @inheritdoc
     */
    public function safeDown(): bool
    {
        try {
            $this->deleteEntryData();
            $this->removeFieldsFromEntryTypes();
            $this->deleteFields(self::FIELD_CONFIGS);
            $this->deleteSections(self::SECTION_CONFIGS);
            $this->deleteEntryTypes(self::ENTRY_TYPE_CONFIGS);
        } catch (Throwable $e) {
            Craft::error($e->getMessage(), __METHOD__);
            return false;
        }

        return true;
    }

Here, we do the inverse of what the safeUp() method does, remov­ing the things we added in the reverse order that we added them via safeUp().

The meth­ods deleteEntryData() and removeFieldsFromEntryTypes() don’t actu­al­ly do any­thing, because the Entry data and the Field asso­ci­a­tions with Entry­Types are both delet­ed when we delete the Entry­Types via the deleteEntryTypes() method.

I includ­ed them here just for instruc­tion­al pur­pos­es, to show that we’re undo­ing what we did in safeUp(). Nor­mal­ly I’d just omit these meth­ods (since they do noth­ing) and just put a com­ment in the code not­ing that deleteEntryTypes() would take care of it.

Note that not all migra­tions need to imple­ment safeDown() at all; none of the Craft CMS migra­tions do.

I want­ed to imple­ment safeDown() so that I could eas­i­ly remove all traces of the seed­ed data, but if that does­n’t make sense for your migra­tion, don’t feel com­pelled to imple­ment this method (and the oth­er meth­ods called by it).

Link Creating & Deleting Fields

Here, we have two rec­i­p­ro­cal meth­ods that cre­ate and delete the Fields defined in the passed-in array of configs:

    /**
     * Create Fields based on $fieldConfigs
     *
     * @param array $fieldConfigs
     * @return void
     * @throws InvalidConfigException
     * @throws Throwable
     */
    protected function createFields(array $fieldConfigs): void
    {
        $fields = Craft::$app->getFields();
        foreach ($fieldConfigs as $fieldConfig) {
            $handle = $fieldConfig['handle'];
            if ($fields->getFieldByHandle($handle)) {
                Console::outputWarning("Field $handle already exists");
                continue;
            }
            // Create & save each Field
            $field = Craft::createObject(array_merge($fieldConfig, [
            ]));
            $fields->saveField($field);
        }
    }

Here we check to see if the field already exists, and skip cre­at­ing it in that case.

Then we call createObject() for each passed-in con­fig­u­ra­tion array, and then save that field out.

Note that we’re call­ing array_merge() to merge in the passed-in con­fig with any­thing we might want to add to the con­fig. In this case, we’re not adding any­thing, but it’s there for easy future expansion.

    /**
     * Delete Fields based on $fieldConfigs
     *
     * @param array $fieldConfigs
     * @return void
     * @throws Throwable
     */
    protected function deleteFields(array $fieldConfigs): void
    {
        $fields = Craft::$app->getFields();
        foreach ($fieldConfigs as $fieldConfig) {
            $handle = $fieldConfig['handle'];
            // Get and delete each field
            $field = $fields->getFieldByHandle($handle);
            if ($field) {
                Craft::$app->getFields()->deleteField($field);
            }
        }
    }

In the rec­i­p­ro­cal method, we iter­ate through and delete each field in the passed-in con­fig­u­ra­tion array

Link Creating & Deleting EntryTypes

Here, we have two rec­i­p­ro­cal meth­ods that cre­ate and delete the Entry­Types defined in the passed-in array of configs:

    /**
     * Create EntryTypes based on $entryTypeConfigs
     *
     * @param array $entryTypeConfigs
     * @return void
     * @throws EntryTypeNotFoundException
     * @throws InvalidConfigException
     * @throws Throwable
     */
    protected function createEntryTypes(array $entryTypeConfigs): void
    {
        $entries = Craft::$app->getEntries();
        foreach ($entryTypeConfigs as $entryTypeConfig) {
            $handle = $entryTypeConfig['handle'];
            if ($entries->getEntryTypeByHandle($handle)) {
                Console::outputWarning("EntryType $handle already exists");
                continue;
            }
            // We use the custom field handles later on, to add them to the EntryType's layout
            unset($entryTypeConfig['customFields']);
            // Create & save each EntryType
            $entryType = Craft::createObject(array_merge($entryTypeConfig, [
            ]));
            if (!$entries->saveEntryType($entryType)) {
                $entryType->validate();
                Console::outputWarning("EntryType $handle could not be saved" . PHP_EOL . print_r($entryType->getErrors(), true));
                return;
            }
        }
    }

Note that we’re doing some­thing spe­cial here, in that we’re call­ing unset() on the customFields prop­er­ty from the con­fig­u­ra­tion array before pass­ing it in to createObject().

This is because customFields is not actu­al­ly a prop­er­ty for an Entry­Type; we’re using it because we need to know what cus­tom field han­dles to add to the entry type lat­er on, and this is a con­ve­nient place to stash that data.

    /**
     * Delete EntryTypes based on the $entryTypeConfigs
     *
     * @param array $entryTypeConfigs
     * @return void
     * @throws Throwable
     */
    protected function deleteEntryTypes(array $entryTypeConfigs): void
    {
        $entries = Craft::$app->getEntries();
        foreach ($entryTypeConfigs as $entryTypeConfigConfig) {
            $handle = $entryTypeConfigConfig['handle'];
            $entryType = $entries->getEntryTypeByHandle($handle);
            if ($entryType) {
                $entries->deleteEntryType($entryType);
            }
        }
    }

In this rec­i­p­ro­cal method, we’re just iter­at­ing through the con­fig­u­ra­tion arrays and delet­ing the Entry­Types by handle.

Link Creating & Deleting Sections

Here, we have two rec­i­p­ro­cal meth­ods that cre­ate and delete the Sec­tions defined in the passed-in array of configs:

    /**
     * Create Sections based on the $sectionConfigs
     *
     * @param array $sectionConfigs
     * @return void
     * @throws InvalidConfigException
     * @throws SectionNotFoundException
     * @throws SiteNotFoundException
     * @throws Throwable
     */
    protected function createSections(array $sectionConfigs): void
    {
        $entries = Craft::$app->getEntries();
        foreach ($sectionConfigs as $sectionConfig) {
            $handle = $sectionConfig['handle'];
            if ($entries->getSectionByHandle($handle)) {
                Console::outputWarning("Section $handle already exists");
                continue;
            }
            // Get all of the entry types by handle
            $entryTypes = [];
            $entryTypeHandles = $sectionConfig['entryTypes'];
            foreach ($entryTypeHandles as $entryTypeHandle) {
                $entryType = $entries->getEntryTypeByHandle($entryTypeHandle);
                if ($entryType) {
                    $entryTypes[] = $entryType;
                }
            }
            // Create & save each Section
            /** @var Section $section */
            $section = Craft::createObject(array_merge($sectionConfig, [
                'siteSettings' => array_map(
                    fn(Site $site) => new Section_SiteSettings([
                        'siteId' => $site->id,
                        'hasUrls' => true,
                        'uriFormat' => "$handle/{slug}",
                        'template' => "$handle/_entry",
                    ]),
                    Craft::$app->getSites()->getAllSites(true),
                ),
                'entryTypes' => $entryTypes,
            ]));
            if (!$entries->saveSection($section)) {
                $section->validate();
                Console::outputWarning("Section $handle could not be saved" . PHP_EOL . print_r($section->getErrors(), true));
            }
        }
    }

We once again skip any Sec­tion han­dles that already exist, then we loop through all of the entryTypes to accu­mu­late them, because we’ll need them lat­er on when we cre­ate our Sec­tion object.

Then we cre­ate our Sec­tion via createObject(), merg­ing the passed-in con­fig­u­ra­tion array with the required prop­er­ties siteSettings & entryTypes, which we cre­ate dynam­i­cal­ly, and then we save the new Section.

    /**
     * Delete Sections based on the $sectionConfigs
     *
     * @param array $sectionConfigs
     * @return void
     * @throws Throwable
     */
    protected function deleteSections(array $sectionConfigs): void
    {
        $entries = Craft::$app->getEntries();
        foreach ($sectionConfigs as $sectionConfig) {
            $handle = $sectionConfig['handle'];
            $section = $entries->getSectionByHandle($handle);
            if ($section) {
                $entries->deleteSection($section);
            }
        }
    }

In this rec­i­p­ro­cal method, we iter­ate through the passed-in con­fig­u­ra­tion array, and delete all of the Sections.

Link Adding and Removing Fields

Here, we have two rec­i­p­ro­cal meth­ods that add and remove the Fields we’ve cre­at­ed to the Entry­Types we’ve created:

    /**
     * Add the Fields to the EntryTypes for each $sectionConfigs
     *
     * @param array $sectionConfigs
     * @param array $entryTypeConfigs
     * @param array $fieldConfigs
     * @return void
     * @throws EntryTypeNotFoundException
     * @throws InvalidConfigException
     * @throws Throwable
     */
    protected function addFieldsToEntryTypes(array $sectionConfigs, array $entryTypeConfigs, array $fieldConfigs): void
    {
        $entries = Craft::$app->getEntries();
        foreach ($sectionConfigs as $sectionConfig) {
            // Iterate through each Section
            $sectionHandle = $sectionConfig['handle'];
            $section = $entries->getSectionByHandle($sectionHandle);
            if (!$section) {
                Console::outputWarning("Section $sectionHandle doesn't exist");
                return;
            }
            // Iterate through each EntryType
            foreach ($entryTypeConfigs as $entryTypeConfig) {
                $entryTypeHandle = $entryTypeConfig['handle'];
                if (!in_array($entryTypeHandle, $sectionConfig['entryTypes'], true)) {
                    continue;
                }
                $entryType = $entries->getEntryTypeByHandle($entryTypeHandle);
                if (!$entryType) {
                    Console::outputWarning("EntryType $entryTypeHandle doesn't exist");
                    return;
                }
                // Iterate through each Field
                $elements = [];
                foreach ($fieldConfigs as $fieldConfig) {
                    $fieldHandle = $fieldConfig['handle'];
                    if (!in_array($fieldHandle, $entryTypeConfig['customFields'], true)) {
                        continue;
                    }
                    $field = Craft::$app->getFields()->getFieldByHandle($fieldHandle);
                    if (!$field) {
                        Console::outputWarning("Field $fieldHandle doesn't exist");
                        continue;
                    }
                    $elements[] = Craft::createObject([
                        'class' => CustomField::class,
                        'fieldUid' => $field->uid,
                        'required' => false,
                    ]);
                }
                // Just assign the fields to the first tab
                $layout = $entryType->getFieldLayout();
                $tabs = $layout->getTabs();
                $tabs[0]->setElements(array_merge($tabs[0]->getElements(), $elements));
                $layout->setTabs($tabs);
                $entryType->setFieldLayout($layout);
                $entries->saveEntryType($entryType);
            }
        }
    }

Here we iter­ate through all of the Sec­tions in the passed-in con­fig­u­ra­tion array, skip­ping any Sec­tions that don’t exist.

Then for each Sec­tion, we iter­ate through all of the Entry­Types in the passed-in con­fig­u­ra­tion array, skip­ping any Entry­Types that our Sec­tion does­n’t use, and also skip­ping any Entry­Types that don’t exist.

Then for each Entry­Type, we iter­ate through all of the Fields in the passed-in con­fig­u­ra­tion array, skip­ping any Fields that our Entry­Type does­n’t use, and also skip­ping any Fields that don’t exist.

We then cre­ate a CustomField class to rep­re­sent the Field instance in the Field­Lay­out, and add those Cus­tom­Fields to the first tab in our default Field­Lay­out for the Entry­Type, and save EntryType.

    /**
     * Remove the Fields from the EntryTypes
     *
     * @return void
     */
    protected function removeFieldsFromEntryTypes(): void
    {
        // Do nothing, these will be destroyed along with the EntryType
    }

This rec­i­p­ro­cal method does noth­ing, since the field instances will be destroyed when we delete our EntryType.

Link Creating & Deleting Entries

Here, we have two rec­i­p­ro­cal meth­ods that cre­ate and delete Entries pop­u­lat­ed with seed data from Faker:

    /**
     * Create the Entry data
     *
     * @param array $fieldConfigs
     * @param string $sectionHandle
     * @param string $entryTypeHandle
     * @param string $userName
     * @param int $numEntries
     * @return void
     * @throws InvalidConfigException
     * @throws Throwable
     * @throws ElementNotFoundException
     * @throws Exception
     */
    protected function createEntryData(array $fieldConfigs, string $sectionHandle, string $entryTypeHandle, string $userName, int $numEntries): void
    {
        $faker = Factory::create();
        $entries = Craft::$app->getEntries();
        $section = $entries->getSectionByHandle($sectionHandle);
        if (!$section) {
            Console::outputWarning("Section $sectionHandle doesn't exist");
            return;
        }
        $entryType = $entries->getEntryTypeByHandle($entryTypeHandle);
        if (!$entryType) {
            Console::outputWarning("EntryType $entryTypeHandle doesn't exist");
            return;
        }
        $user = Craft::$app->users->getUserByUsernameOrEmail($userName);
        if (!$user) {
            Console::outputWarning("User $userName doesn't exist");
            return;
        }
        // Do it as a bulk operation, see: https://github.com/craftcms/cms/pull/14032
        $bulkKey = Craft::$app->elements->beginBulkOp();
        for ($i = 0; $i < $numEntries; $i++) {
            $name = $faker->unique()->name();
            /* @var Entry $entry */
            $entry = Craft::createObject([
                'class' => Entry::class,
                'sectionId' => $section->id,
                'typeId' => $entryType->id,
                'authorId' => $user->id,
                'title' => $name,
            ]);
            // Just the essentials for bulk import/creation
            $entry->setScenario(Element::SCENARIO_ESSENTIALS);
            foreach ($fieldConfigs as $fieldConfig) {
                $handle = $fieldConfig['handle'];
                $entry->setFieldValue($handle, $name);
            }
            if (Craft::$app->elements->saveElement($entry)) {
                Console::output("#$i - Added entry " . Console::ansiFormat($name, [Console::FG_YELLOW]));
            } else {
                Console::outputWarning("#$i - Failed to add entry $name");
            }
        }
        Craft::$app->elements->endBulkOp($bulkKey);
    }

Here we cre­ate a Fac­to­ry for Fak­er, then ensure that the passed in Sec­tion, Entry­Type, and User exist.

Next, we tell Craft & plu­g­ins that we’re per­form­ing a bulk oper­a­tion via beginBulkOp() & endBulkOp(), so that they can wait until the bulk oper­a­tion is done before doing addi­tion­al processing.

Then, we loop $numEntries times, cre­at­ing a new Entry ele­ment via createObject(). We then loop through the Fields con­fig­u­ra­tion array to set the cus­tom field val­ue for each cus­tom field with seed data from Fak­er and save the Entry element.

Note that we also set the Entry ele­men­t’s sce­nario to Element::ESSENTIALS so that min­i­mal val­i­da­tion is done when sav­ing the element.

Final­ly, to make things look pret­ty, we use the built-in Yii2 Con­sole helper class to add some col­or to the con­sole out­put (and also make warn­ings stand out).

    /**
     * Delete all entries from $sectionHandle
     *
     * @return void
     */
    protected function deleteEntryData(): void
    {
        // Do nothing, these will be destroyed along with the EntryType
    }

This rec­i­p­ro­cal method does noth­ing, since the Entries will be destroyed when we delete our EntryType.

Link The Entire Migration Class

Here’s the entire migra­tion you can copy & paste into a file named m240430_143914_seed_db_data.php in your migrations/ direc­to­ry (you can also get it from the Gist):

<?php
/**
 * Craft CMS 5 content migration that creates & deletes Fields, EntryTypes & Sections, adds the Fields to the
 * EntryTypes, adds the EntryTypes to the Sections, and then seeds the newly created Section with faked data.
 * Also has a `safeDown()` method to revert what the migration adds
 *
 * @licence   MIT
 * @link      https://nystudio107.com
 * @copyright Copyright (c) nystudio107
 */

namespace craft\contentmigrations;

use Craft;
use craft\base\Element;
use craft\base\Field;
use craft\db\Migration;
use craft\elements\Entry;
use craft\errors\ElementNotFoundException;
use craft\errors\EntryTypeNotFoundException;
use craft\errors\SectionNotFoundException;
use craft\errors\SiteNotFoundException;
use craft\fieldlayoutelements\CustomField;
use craft\fields\PlainText;
use craft\helpers\Console;
use craft\models\EntryType;
use craft\models\Section;
use craft\models\Section_SiteSettings;
use craft\models\Site;
use Faker\Factory;
use Throwable;
use yii\base\Exception;
use yii\base\InvalidConfigException;
use yii\db\Schema;

/**
 * m240430_143914_seed_db_data migration.
 */
class m240430_143914_seed_db_data extends Migration
{
    // Handle to the user that should own the entries
    private const USER_NAME = 'admin';
    // The number of entries to create
    private const NUM_ENTRIES = 10000;
    // Array of configs for the Fields to create
    private const FIELD_CONFIGS = [
        [
            'class' => PlainText::class,
            'name' => 'Demo Data',
            'handle' => 'demoData',
            'translationMethod' => Field::TRANSLATION_METHOD_NONE,
            'multiline' => 0,
            'columnType' => Schema::TYPE_STRING,
        ],
    ];
    // Array of configs for the EntryTypes to create
    private const ENTRY_TYPE_CONFIGS = [
        [
            'class' => EntryType::class,
            'name' => 'Demo',
            'handle' => 'demo',
            'customFields' => [
                'demoData'
            ]
        ],
    ];
    // Array of configs for the Sections to create
    private const SECTION_CONFIGS = [
        [
            'class' => Section::class,
            'name' => 'Demo',
            'handle' => 'demo',
            'type' => Section::TYPE_CHANNEL,
            'enableVersioning' => false,
            'entryTypes' => [
                'demo',
            ]
        ],
    ];

    /**
     * @inheritdoc
     */
    public function safeUp(): bool
    {
        try {
            $this->createFields(self::FIELD_CONFIGS);
            $this->createEntryTypes(self::ENTRY_TYPE_CONFIGS);
            $this->createSections(self::SECTION_CONFIGS);
            $this->addFieldsToEntryTypes(self::SECTION_CONFIGS, self::ENTRY_TYPE_CONFIGS, self::FIELD_CONFIGS);
            $this->createEntryData(self::FIELD_CONFIGS, self::SECTION_CONFIGS[0]['handle'], self::ENTRY_TYPE_CONFIGS[0]['handle'], self::USER_NAME, self::NUM_ENTRIES);
        } catch (Throwable $e) {
            Craft::error($e->getMessage(), __METHOD__);
            return false;
        }

        return true;
    }

    /**
     * @inheritdoc
     */
    public function safeDown(): bool
    {
        try {
            $this->deleteEntryData();
            $this->removeFieldsFromEntryTypes();
            $this->deleteFields(self::FIELD_CONFIGS);
            $this->deleteSections(self::SECTION_CONFIGS);
            $this->deleteEntryTypes(self::ENTRY_TYPE_CONFIGS);
        } catch (Throwable $e) {
            Craft::error($e->getMessage(), __METHOD__);
            return false;
        }

        return true;
    }

    /**
     * Create Fields based on $fieldConfigs
     *
     * @param array $fieldConfigs
     * @return void
     * @throws InvalidConfigException
     * @throws Throwable
     */
    protected function createFields(array $fieldConfigs): void
    {
        $fields = Craft::$app->getFields();
        foreach ($fieldConfigs as $fieldConfig) {
            $handle = $fieldConfig['handle'];
            if ($fields->getFieldByHandle($handle)) {
                Console::outputWarning("Field $handle already exists");
                continue;
            }
            // Create & save each Field
            $field = Craft::createObject(array_merge($fieldConfig, [
            ]));
            $fields->saveField($field);
        }
    }

    /**
     * Delete Fields based on $fieldConfigs
     *
     * @param array $fieldConfigs
     * @return void
     * @throws Throwable
     */
    protected function deleteFields(array $fieldConfigs): void
    {
        $fields = Craft::$app->getFields();
        foreach ($fieldConfigs as $fieldConfig) {
            $handle = $fieldConfig['handle'];
            // Get and delete each field
            $field = $fields->getFieldByHandle($handle);
            if ($field) {
                Craft::$app->getFields()->deleteField($field);
            }
        }
    }

    /**
     * Create EntryTypes based on $entryTypeConfigs
     *
     * @param array $entryTypeConfigs
     * @return void
     * @throws EntryTypeNotFoundException
     * @throws InvalidConfigException
     * @throws Throwable
     */
    protected function createEntryTypes(array $entryTypeConfigs): void
    {
        $entries = Craft::$app->getEntries();
        foreach ($entryTypeConfigs as $entryTypeConfig) {
            $handle = $entryTypeConfig['handle'];
            if ($entries->getEntryTypeByHandle($handle)) {
                Console::outputWarning("EntryType $handle already exists");
                continue;
            }
            // We use the custom field handles later on, to add them to the EntryType's layout
            unset($entryTypeConfig['customFields']);
            // Create & save each EntryType
            $entryType = Craft::createObject(array_merge($entryTypeConfig, [
            ]));
            if (!$entries->saveEntryType($entryType)) {
                $entryType->validate();
                Console::outputWarning("EntryType $handle could not be saved" . PHP_EOL . print_r($entryType->getErrors(), true));
                return;
            }
        }
    }

    /**
     * Delete EntryTypes based on the $entryTypeConfigs
     *
     * @param array $entryTypeConfigs
     * @return void
     * @throws Throwable
     */
    protected function deleteEntryTypes(array $entryTypeConfigs): void
    {
        $entries = Craft::$app->getEntries();
        foreach ($entryTypeConfigs as $entryTypeConfigConfig) {
            $handle = $entryTypeConfigConfig['handle'];
            $entryType = $entries->getEntryTypeByHandle($handle);
            if ($entryType) {
                $entries->deleteEntryType($entryType);
            }
        }
    }

    /**
     * Create Sections based on the $sectionConfigs
     *
     * @param array $sectionConfigs
     * @return void
     * @throws InvalidConfigException
     * @throws SectionNotFoundException
     * @throws SiteNotFoundException
     * @throws Throwable
     */
    protected function createSections(array $sectionConfigs): void
    {
        $entries = Craft::$app->getEntries();
        foreach ($sectionConfigs as $sectionConfig) {
            $handle = $sectionConfig['handle'];
            if ($entries->getSectionByHandle($handle)) {
                Console::outputWarning("Section $handle already exists");
                continue;
            }
            // Get all of the entry types by handle
            $entryTypes = [];
            $entryTypeHandles = $sectionConfig['entryTypes'];
            foreach ($entryTypeHandles as $entryTypeHandle) {
                $entryType = $entries->getEntryTypeByHandle($entryTypeHandle);
                if ($entryType) {
                    $entryTypes[] = $entryType;
                }
            }
            // Create & save each Section
            /** @var Section $section */
            $section = Craft::createObject(array_merge($sectionConfig, [
                'siteSettings' => array_map(
                    fn(Site $site) => new Section_SiteSettings([
                        'siteId' => $site->id,
                        'hasUrls' => true,
                        'uriFormat' => "$handle/{slug}",
                        'template' => "$handle/_entry",
                    ]),
                    Craft::$app->getSites()->getAllSites(true),
                ),
                'entryTypes' => $entryTypes,
            ]));
            if (!$entries->saveSection($section)) {
                $section->validate();
                Console::outputWarning("Section $handle could not be saved" . PHP_EOL . print_r($section->getErrors(), true));
            }
        }
    }

    /**
     * Delete Sections based on the $sectionConfigs
     *
     * @param array $sectionConfigs
     * @return void
     * @throws Throwable
     */
    protected function deleteSections(array $sectionConfigs): void
    {
        $entries = Craft::$app->getEntries();
        foreach ($sectionConfigs as $sectionConfig) {
            $handle = $sectionConfig['handle'];
            $section = $entries->getSectionByHandle($handle);
            if ($section) {
                $entries->deleteSection($section);
            }
        }
    }

    /**
     * Add the Fields to the EntryTypes for each $sectionConfigs
     *
     * @param array $sectionConfigs
     * @param array $entryTypeConfigs
     * @param array $fieldConfigs
     * @return void
     * @throws EntryTypeNotFoundException
     * @throws InvalidConfigException
     * @throws Throwable
     */
    protected function addFieldsToEntryTypes(array $sectionConfigs, array $entryTypeConfigs, array $fieldConfigs): void
    {
        $entries = Craft::$app->getEntries();
        foreach ($sectionConfigs as $sectionConfig) {
            // Iterate through each Section
            $sectionHandle = $sectionConfig['handle'];
            $section = $entries->getSectionByHandle($sectionHandle);
            if (!$section) {
                Console::outputWarning("Section $sectionHandle doesn't exist");
                return;
            }
            // Iterate through each EntryType
            foreach ($entryTypeConfigs as $entryTypeConfig) {
                $entryTypeHandle = $entryTypeConfig['handle'];
                if (!in_array($entryTypeHandle, $sectionConfig['entryTypes'], true)) {
                    continue;
                }
                $entryType = $entries->getEntryTypeByHandle($entryTypeHandle);
                if (!$entryType) {
                    Console::outputWarning("EntryType $entryTypeHandle doesn't exist");
                    return;
                }
                // Iterate through each Field
                $elements = [];
                foreach ($fieldConfigs as $fieldConfig) {
                    $fieldHandle = $fieldConfig['handle'];
                    if (!in_array($fieldHandle, $entryTypeConfig['customFields'], true)) {
                        continue;
                    }
                    $field = Craft::$app->getFields()->getFieldByHandle($fieldHandle);
                    if (!$field) {
                        Console::outputWarning("Field $fieldHandle doesn't exist");
                        continue;
                    }
                    $elements[] = Craft::createObject([
                        'class' => CustomField::class,
                        'fieldUid' => $field->uid,
                        'required' => false,
                    ]);
                }
                // Just assign the fields to the first tab
                $layout = $entryType->getFieldLayout();
                $tabs = $layout->getTabs();
                $tabs[0]->setElements(array_merge($tabs[0]->getElements(), $elements));
                $layout->setTabs($tabs);
                $entryType->setFieldLayout($layout);
                $entries->saveEntryType($entryType);
            }
        }
    }

    /**
     * Remove the Fields from the EntryTypes
     *
     * @return void
     */
    protected function removeFieldsFromEntryTypes(): void
    {
        // Do nothing, these will be destroyed along with the EntryType
    }

    /**
     * Create the Entry data
     *
     * @param array $fieldConfigs
     * @param string $sectionHandle
     * @param string $entryTypeHandle
     * @param string $userName
     * @param int $numEntries
     * @return void
     * @throws InvalidConfigException
     * @throws Throwable
     * @throws ElementNotFoundException
     * @throws Exception
     */
    protected function createEntryData(array $fieldConfigs, string $sectionHandle, string $entryTypeHandle, string $userName, int $numEntries): void
    {
        $faker = Factory::create();
        $entries = Craft::$app->getEntries();
        $section = $entries->getSectionByHandle($sectionHandle);
        if (!$section) {
            Console::outputWarning("Section $sectionHandle doesn't exist");
            return;
        }
        $entryType = $entries->getEntryTypeByHandle($entryTypeHandle);
        if (!$entryType) {
            Console::outputWarning("EntryType $entryTypeHandle doesn't exist");
            return;
        }
        $user = Craft::$app->users->getUserByUsernameOrEmail($userName);
        if (!$user) {
            Console::outputWarning("User $userName doesn't exist");
            return;
        }
        // Do it as a bulk operation, see: https://github.com/craftcms/cms/pull/14032
        $bulkKey = Craft::$app->elements->beginBulkOp();
        for ($i = 0; $i < $numEntries; $i++) {
            $name = $faker->unique()->name();
            /* @var Entry $entry */
            $entry = Craft::createObject([
                'class' => Entry::class,
                'sectionId' => $section->id,
                'typeId' => $entryType->id,
                'authorId' => $user->id,
                'title' => $name,
            ]);
            // Just the essentials for bulk import/creation
            $entry->setScenario(Element::SCENARIO_ESSENTIALS);
            foreach ($fieldConfigs as $fieldConfig) {
                $handle = $fieldConfig['handle'];
                $entry->setFieldValue($handle, $name);
            }
            if (Craft::$app->elements->saveElement($entry)) {
                Console::output("#$i - Added entry " . Console::ansiFormat($name, [Console::FG_YELLOW]));
            } else {
                Console::outputWarning("#$i - Failed to add entry $name");
            }
        }
        Craft::$app->elements->endBulkOp($bulkKey);
    }

    /**
     * Delete all entries from $sectionHandle
     *
     * @return void
     */
    protected function deleteEntryData(): void
    {
        // Do nothing, these will be destroyed along with the EntryType
    }
}

Link Running the Migration

So now that we have gone through the con­tent migra­tion sec­tion by sec­tion, let’s run it and see what it does.

Run the migra­tion with:

php craft migrate m240430_143914_seed_db_data --interactive=0

The --interactive=0 flag just caus­es it to skip the prompts that asks you if you real­ly want to run the migration.

You’ll see it log­ging each entry that it cre­ates, and then once it is done, if you vis­it your CP, you’ll see that it’s cre­at­ed the Field demoData, the Entry­Type demo, the Sec­tion demo, and it’s pop­u­lat­ed the demo Sec­tion with a whole lot of Entries:

The seed­ed Entry data

Now let’s say you’re play­ing around with your migra­tion, and you want to redo the migra­tion based on some changes you’ve made to your migra­tion code. You can redo the last migra­tion you ran with this command:

php craft migrate/redo --interactive=0

This will cause it to run the safeDown() method to revert the migra­tion, and then run the safeUp() method again to re-run the migra­tion. This is espe­cial­ly use­ful when you’re work­ing on devel­op­ing your migra­tion, and want to test it out, fix bugs, etc.

Final­ly, assum­ing your migra­tion was a tem­po­rary one, and you want to revert the changes it made, you can revert the last migra­tion you ran with:

php craft migrate/down --interactive=0

Link Migrating Away

While you may not have the need to seed your data­base with a bunch of ran­dom data as I did here (it was for per­for­mance test­ing) hope­ful­ly, this explain­er has giv­en you the basis for writ­ing your own con­tent migrations!

If you want to learn more about con­tent migra­tions, check out the CraftQuest course Con­tent Migra­tions in Craft.

Hap­py migrat­ing, don’t for­get to pack your sunscreen!