Migrations
Documentation
- Migrations: https://craftcms.com/docs/5.x/extend/migrations.html
- Content migrations: https://craftcms.com/docs/5.x/extend/migrations.html#content-migrations
Common Pitfalls
- Forgetting
muteEventsonProjectConfig::set()inside migrations -- triggers handlers that may depend on state that doesn't exist yet. - Generating random UIDs when project config YAML already ships from dev -- check if the config path exists first.
- Non-idempotent steps -- always check column/table existence before adding.
- Using
createIndex()/addForeignKey()on a code path that can run twice (notablyInstall::safeUp()under a test harness) -- MySQL accepts duplicate indexes over the same columns, so they accumulate silently until the table hits the 64-key ceiling and installs fail withToo many keys specified. UsecreateIndexIfMissing()and aDb::findForeignKey()check. See Re-runnable index creation. - Using
nullfor FK name but specifying one for index -- usenullfor both, Craft generates deterministic names. - Missing
TRUNCATE cacheafter failed migrations on Craft Cloud -- stale mutex locks block retries. See thecraft-cloudskill (commands-and-cron.md) for running the truncate via the Console command runner. - Not thinking about ON DELETE behavior --
CASCADEfor owned data,SET NULLfor references,RESTRICTfor protected refs. - Running raw SQL without checking column/table existence first --
addColumnon an existing column throws,dropColumnon a missing one throws. - Creating project config entries in migrations AND in YAML -- double-apply causes UID collisions or duplicate structures.
- Not wrapping data transformations in transactions -- partial updates corrupt data if the migration fails halfway through.
- Using Craft element APIs (
Entry::find(),Craft::$app->getElements()->saveElement()) in schema-phase migrations (pluginInstall.php/update migrations) -- the schema may not be ready yet, and element types may depend on columns/tables that don't exist until later in the migration sequence. (In a content migration, which runs after project config is applied, the element API is the correct way to set field values -- e.g. relation fields must go throughsetFieldValue()+saveElement(), never rawrelationsSQL. Seeelements.md→ "Field value storage".) - Flushing the whole cache from a migration (
Craft::$app->getCache()->flush(),clear-caches/all) -- invalidate only what changed instead. On atomic-deploy hosts the old release is still serving live traffic against the same cache while the migration runs; a global wipe contends with it, and on Craft Cloud (where the cache falls back to a single MySQL table when Redis isn't provisioned) it can deadlock (MySQL 1205) and fail the deploy. See thecraft-cloudskill'sdeploy-pipeline.md. - Forgetting to handle both MySQL and PostgreSQL syntax differences --
renameColumn()is safe, but raw SQL (e.g.ALTER TABLE ... MODIFY COLUMN) is MySQL-only.
Contents
- Migration Types
- Scaffold
- Safety Rules
- Table Creation
- Foreign Keys
- Indexes
- Common Schema Patterns
- Content Migrations -- Creating Sections, Fields, Entry Types
- Multi-Site Migrations
- Project Config Interaction
- safeDown() and Rollback Patterns
- Migration Ordering
- craft migrate vs craft up
- Deployment
Migration Types
| Type | File / Location | Trigger | Use Case |
|---|---|---|---|
| Plugin install | Install.php in plugin's migrations/ dir |
craft plugin/install or first craft up after adding plugin |
Create tables, seed project config, set up initial state |
| Plugin update | m240101_000000_description.php in plugin's migrations/ dir |
craft up when plugin version changes |
Schema changes, data transformations between plugin versions |
| Content migration | m240101_000000_description.php in project root migrations/ dir |
craft migrate/all or craft up |
Module-level or project-level changes -- anything not owned by a plugin |
When to use which type
- Plugin install migration: One-time setup. Runs exactly once per environment when the plugin is installed. Handles table creation, foreign keys, indexes, and initial project config seeding. Has a corresponding
safeDown()that tears everything down on uninstall. - Plugin update migration: Incremental changes between versions. The plugin's
schemaVersionincomposer.jsoncontrols when these run -- Craft compares the installed version to the declared version and runs any new migrations. - Content migration: For changes that belong to the project, not a plugin. Module schema changes, one-time data fixes, content structure modifications. Created with
ddev craft migrate/create my_migration_name. These share a single global track.
Scaffold
ddev craft make migration --with-docblocksThe Install.php migration runs on plugin install. Numbered migrations run on ddev craft up.
Modules don't have Install.php -- use content migrations created with ddev craft migrate/create my_migration_name. These go in the project's migrations/ directory and share a global track.
Schema-change discipline. Every change to plugin schema must land in BOTH Install.php (canonical fresh shape) AND a dated migration (idempotent upgrade path). The two failure directions are symmetric:
- Editing only
Install.phpleaves existing installs — including yourdb_testafter the first test run — on the prior schema. See thecraft-pestskill'sshared-state.md→ "Schema drift:Install.phpvs migrations in the test database". - Editing only the dated migration leaves fresh installs without the change: on plugin install, Craft runs
Install.phpand then marks every dated migration as applied without running it. Anything a dated migration does thatInstall.phpdoesn't (a column, an index, a data seed) simply never happens on a new install — and nothing errors, because the migration is recorded as done.
Safety Rules
1. Always Mute Events in Migrations
Craft::$app->getProjectConfig()->muteEvents = true;
Craft::$app->getProjectConfig()->set($path, $data);
Craft::$app->getProjectConfig()->muteEvents = false;2. Every Step Must Be Idempotent
if (!$this->db->columnExists(Table::MY_ELEMENTS, 'categoryId')) {
$this->addColumn(Table::MY_ELEMENTS, 'categoryId', $this->integer()->after('id'));
}3. Check Before Generating UIDs
$existingConfig = Craft::$app->getProjectConfig()->get($path);
if ($existingConfig !== null) {
return; // Already seeded from YAML
}Table Creation
$this->createTable(Table::MY_ELEMENTS, [
'id' => $this->integer()->notNull(),
'externalId' => $this->string()->notNull(),
'categoryId' => $this->integer()->notNull(),
'postDate' => $this->dateTime(),
'expiryDate' => $this->dateTime(),
'dateCreated' => $this->dateTime()->notNull(),
'dateUpdated' => $this->dateTime()->notNull(),
'uid' => $this->uid(),
'PRIMARY KEY(id)',
]);Foreign Keys
// Element table -> elements.id with CASCADE delete
$this->addForeignKey(null, Table::MY_ELEMENTS, ['id'], CraftTable::ELEMENTS, ['id'], 'CASCADE', null);
// Reference to config table -- SET NULL on delete
$this->addForeignKey(null, Table::MY_ELEMENTS, ['categoryId'], Table::CATEGORIES, ['id'], 'SET NULL', null);Use null for the FK name -- Craft generates a deterministic name. Always decide: CASCADE for owned data, SET NULL for references, RESTRICT for protected references.
There is no addForeignKeyIfMissing(). When a migration (or an Install.php) can run more than once against the same database, check first:
use craft\helpers\Db;
// Db::findForeignKey() returns the existing constraint name, or null.
if (Db::findForeignKey(Table::MY_ELEMENTS, 'categoryId') === null) {
$this->addForeignKey(null, Table::MY_ELEMENTS, ['categoryId'], Table::CATEGORIES, ['id'], 'SET NULL', null);
}Indexes
// Compound unique for external ID scoping
$this->createIndex(null, Table::MY_ELEMENTS, ['categoryId', 'externalId'], true);
// Performance indexes
$this->createIndex(null, Table::MY_ELEMENTS, ['postDate']);
$this->createIndex(null, Table::MY_ELEMENTS, ['expiryDate']);Re-runnable index creation, and MySQL's 64-key ceiling
createIndex() is not idempotent, and MySQL will not stop you: it accepts any number of indexes over the same columns as long as the names differ, and null names are generated per call. So a migration path that executes twice adds a second index; ten times adds ten.
Nothing fails while this happens — until the table reaches MySQL's hard limit of 64 keys per table, at which point the next createIndex() (or addForeignKey(), since each FK carries an index) aborts with Too many keys specified; max 64 keys allowed. The error names the index that finally crossed the line, not the duplicate accumulation that consumed the budget, so it reads as a spurious failure on a line that has always worked.
Use Craft's guarded variant, which delegates to Db::findIndex() and skips when an equivalent index already exists:
$this->createIndexIfMissing(Table::MY_ELEMENTS, ['handle'], true);
$this->createIndexIfMissing(Table::MY_ELEMENTS, ['categoryId']);craft\db\Migration::createIndexIfMissing(string $table, array|string $columns, bool $unique = false) — verified against craftcms/cms 5.10.11. Note the signature takes no name argument; matching is by columns and uniqueness.
Where the double-execution comes from. Normal craft up runs each numbered migration once (tracked in the migrations table), so this rarely bites production. The two paths that do re-run:
Install.php— re-invoked whenever plugin-install detection misses, which is routine in a standalone test harness that boots a fresh process per run. GuardInstall::safeUp()exactly as above.- Manual re-application — someone reruns a migration after clearing its history row, or a botched deploy replays one.
Diagnosing an existing table: SHOW INDEX FROM myplugin_items (or $schema->getTableIndexes()), and look for several differently-named indexes over identical columns. The test-harness angle and a regression test that asserts index/FK counts stay flat across two safeUp() calls are in the craft-pest skill's shared-state.md → "Plugin Install migrations must be idempotent".
Common Schema Patterns
Adding columns (with idempotency)
if (!$this->db->columnExists('{{%my_table}}', 'newColumn')) {
$this->addColumn('{{%my_table}}', 'newColumn', $this->string()->after('existingColumn'));
}Always check columnExists() first. Without this guard, re-running a migration (e.g., after a failed batch) throws a "column already exists" exception.
Renaming columns
$this->renameColumn('{{%my_table}}', 'oldName', 'newName');Craft's renameColumn() is database-agnostic -- it generates the correct SQL for both MySQL and PostgreSQL. Do NOT use raw ALTER TABLE ... CHANGE COLUMN SQL, which is MySQL-only.
Changing column types
$this->alterColumn('{{%my_table}}', 'myColumn', $this->text());Be careful with type changes that lose data -- text to string(255) truncates long values. For large tables, ALTER locks the table for its duration.
Data transformations
$transaction = Craft::$app->getDb()->beginTransaction();
try {
$this->update('{{%my_table}}', ['status' => 'active'], ['status' => 'enabled']);
$transaction->commit();
} catch (\Throwable $e) {
$transaction->rollBack();
throw $e;
}Always wrap data transformations in transactions. Partial updates from a failed migration corrupt data.
Dropping columns and tables safely
if ($this->db->columnExists('{{%my_table}}', 'deprecatedColumn')) {
$this->dropColumn('{{%my_table}}', 'deprecatedColumn');
}
$this->dropTableIfExists('{{%my_table}}');dropTableIfExists() is built into Craft's migration base class -- preferred over checking tableExists() then calling dropTable().
Dropping indexes and foreign keys
// Drop index by generated name
$indexName = $this->db->getIndexName('{{%my_table}}', ['columnA', 'columnB']);
$this->dropIndex($indexName, '{{%my_table}}');
// Drop foreign key by generated name
$fkName = $this->db->getForeignKeyName('{{%my_table}}', ['parentId']);
$this->dropForeignKey($fkName, '{{%my_table}}');Content Migrations -- Creating Sections, Fields, Entry Types
Use these service-layer calls for any programmatic schema authoring — migrations, setup scripts, or AI/MCP tools — not just migrations. Craft validates, assigns UIDs, and writes the project-config YAML for you; don't hand-write YAML and project-config/apply to author schema (that's for deploys), and remember runtime writes are refused when allowAdminChanges is off. See the craft-content-modeling skill's infrastructure.md → "Authoring schema from code".
Creating a section
use craft\models\Section;
use craft\models\Section_SiteSettings;
$section = new Section([
'name' => 'News',
'handle' => 'news',
'type' => Section::TYPE_CHANNEL,
'siteSettings' => array_map(
fn($site) => new Section_SiteSettings([
'siteId' => $site->id,
'hasUrls' => true,
'uriFormat' => 'news/{slug}',
'template' => 'news/_entry',
'enabledByDefault' => true,
]),
Craft::$app->getSites()->getAllSites(),
),
]);
if (!Craft::$app->getEntries()->saveSection($section)) {
throw new \RuntimeException('Could not save section: ' . implode(', ', $section->getFirstErrors()));
}Creating a field
use craft\fields\PlainText;
$field = new PlainText([
'groupId' => Craft::$app->getFields()->getAllGroups()[0]->id,
'name' => 'Subtitle',
'handle' => 'subtitle',
'translationMethod' => 'none',
]);
if (!Craft::$app->getFields()->saveField($field)) {
throw new \RuntimeException('Could not save field: ' . implode(', ', $field->getFirstErrors()));
}Assigning fields to a field layout
use craft\fieldlayoutelements\CustomField;
use craft\models\FieldLayout;
use craft\models\FieldLayoutTab;
$fieldLayout = new FieldLayout();
$fieldLayout->setTabs([
new FieldLayoutTab([
'name' => 'Content',
'elements' => [
['type' => craft\fieldlayoutelements\entries\EntryTitleField::class],
['type' => CustomField::class, 'fieldUid' => $field->uid],
],
]),
]);
$section->setFieldLayout($fieldLayout);
Craft::$app->getEntries()->saveSection($section);Creating entry types
In Craft 5, sections and entry types are decoupled. A section can have multiple entry types, each with its own field layout:
use craft\models\EntryType;
$entryType = new EntryType();
$entryType->name = 'Document';
$entryType->handle = 'document';
$entryType->icon = 'file-lines';
$entryType->color = 'blue';
// Assign field layout to the entry type, not the section
$entryType->setFieldLayout($fieldLayout);
if (!Craft::$app->getEntries()->saveEntryType($entryType)) {
throw new \RuntimeException('Could not save entry type: ' . implode(', ', $entryType->getFirstErrors()));
}
// Assign entry type to the section
$section->setEntryTypes([$entryType->id]);
Craft::$app->getEntries()->saveSection($section);Warning: project config coordination
Content migrations that create sections, fields, or entry types write to project config. If your project also has YAML files in config/project/ that define the same structures, you get a conflict. The rule:
- In dev: Create structures via the CP (with
allowAdminChangeson), then export as YAML. Migrations are for data transformations only. - In CI/automated setups: If you create structures programmatically, do NOT also ship YAML for those same structures. One source of truth.
- On environments where
allowAdminChangesis off (production, Craft Cloud): don't build project-config-managed schema (sections, fields, entry types, field layouts) imperatively in a migration at all. Build it locally with admin changes on, let project config capture it to YAML, and letproject-config/applycreate it deterministically with matching UIDs. Imperative schema in a migration risks UID divergence against the committed YAML and double-applies. Reserve migrations for what project config can't hold — data transformations, and content (Formie forms, entries) seeded in a content migration after YAML is applied. craft upruns plugin/Craft migrations, then applies project config YAML, then content migrations (see Execution order incraft up). If a migration and the YAML both create the same section, the apply step fails or creates duplicates.
Multi-Site Migrations
Iterating all sites
$sites = Craft::$app->getSites()->getAllSites();
foreach ($sites as $site) {
$this->update(
'{{%content}}',
['field_subtitle' => 'Default subtitle'],
['siteId' => $site->id, 'field_subtitle' => null],
);
}Propagating content across sites
When adding a new site, existing entries may need content populated. Query rows from the primary site and insert for the new site, checking existence first to avoid duplicates. Use Craft::$app->getSites()->getPrimarySite() for the source and getSiteByHandle() for the target.
Site-specific data transformations
$site = Craft::$app->getSites()->getSiteByHandle('de');
if ($site) {
$this->update(
'{{%my_table}}',
['locale' => 'de_DE'],
['siteId' => $site->id],
);
}Always null-check the site -- it may not exist in every environment (e.g., a staging environment with fewer sites than production).
Project Config Interaction
When to use project config vs direct DB writes
| Scenario | Approach | Reason |
|---|---|---|
| Creating sections, fields, entry types | Project config (via service APIs) | These are config-managed -- direct DB writes get overwritten by craft up |
| Adding custom plugin tables/columns | Direct DB writes (addColumn, createTable) |
Custom tables are not project config managed |
| Data transformations (UPDATE rows) | Direct DB writes | Data is not config -- project config only tracks structure |
| Setting relation/custom field values | Element API (setFieldValue + saveElement) in a content migration |
Field values live in elements_sites.content (JSON, keyed by layout-element UID); raw relations/content SQL does not update the rendered value. See the craftcms skill's elements.md → "Field value storage". |
| Seeding plugin settings | Project config with muteEvents |
Settings sync across environments via YAML |
The muteEvents pattern
When writing to project config inside a migration, always mute events. Without this, project config handlers fire immediately, potentially depending on database state that doesn't exist yet. See Safety Rules for the code pattern.
Never call ProjectConfig::flush() inside a migration
Two independent reasons:
flush()callssaveModifiedConfigData(), which releases the shared project-config mutex when it finishes (craftcms/cms5.10.12,src/services/ProjectConfig.php:873) — including a lock the surrounding process (Craft's own migration apply, a test harness) acquired and still believes it holds. The lock state is desynced for the rest of the process; later project-config writes then fail withBusyResourceException/StaleResourceExceptionin places far from the migration that caused it.- With automatic YAML writing enabled,
flush()rewritesconfig/project/*.yaml— files the developer has committed to VCS. A plugin upgrade that mutates a project's tracked YAML out from under its owner is wrong on its own terms, independent of the lock problem.
If a plugin stored a bad settings value that needs correcting, coerce it at read time (in the settings model or a getter) rather than rewriting stored config from a migration.
Execution order in craft up
craft up is not "migrations, then project config" — the content track runs last, after project config has been applied. The precise sequence, from craftcms/cms UpController::actionIndex():
migrate/all --no-content— Craft + plugin migrations. The content track is deliberately excluded here.- Save / reset modified project config data (
saveModifiedConfigData()+reset()). project-config/apply— applies pending YAML changes (diffed against the database), if any.migrate/up --track=content— content migrations (project rootmigrations/).clear-caches/compiled-templates.- Write YAML files — conditional, only if automatic YAML writing is enabled and config isn't read-only.
The ordering has a practical consequence:
- Content migrations (step 4) can rely on YAML-defined schema already existing — sections, fields, and entry types from
config/project/have been applied in step 3. This is the right place to seed entries, Formie forms, or other content that depends on the schema. - Plugin and Craft migrations (step 1) cannot — they run before project config is applied. Don't reference YAML-defined sections/fields from a plugin migration.
If your migration creates a section and the YAML also defines that section, the apply step conflicts. Rule of thumb: let one system own each piece of config. Migrations own data transformations. YAML owns structural config.
safeDown() and Rollback Patterns
Reversible schema changes
Schema changes (adding/dropping columns, tables, indexes) can typically be reversed:
public function safeDown(): bool
{
$this->dropTableIfExists('{{%my_table}}');
// or: $this->dropColumn('{{%my_table}}', 'newColumn');
return true;
}Non-reversible migrations
Data transformations (UPDATE, DELETE, INSERT) are generally not reversible. You can't un-transform data without storing the original values somewhere. For these, return false:
public function safeDown(): bool|false
{
// Data transformation is not reversible -- original values are lost.
return false;
}Returning false tells Craft this migration cannot be rolled back. craft migrate/down will refuse to undo it.
Mixed migrations
If a migration has both schema changes and data transformations, the data transformation makes the whole migration non-reversible. Either return false from safeDown(), or reverse only the schema portions and document clearly that data changes are not restored.
Migration Ordering
Timestamp format
Migrations are named m{YYMMDD}_{HHMMSS}_description.php and execute in timestamp order. The timestamp is the creation time, not the intended execution time.
Track isolation
Each plugin has its own migration track, triggered when the plugin's schemaVersion changes. Plugin A's migrations never block Plugin B's. Content migrations (project root migrations/) have their own track, independent of all plugin tracks.
Modules and library-shipped modules have no CLI track
craft migrate/up --track=module:<handle> fails with Invalid migration track — MigrateController resolves only craft, content, and plugin:<handle> (plus an EVENT_REGISTER_MIGRATOR escape hatch nothing registers for you; craftcms/cms 5.10.12, src/console/controllers/MigrateController.php:467). A project-level module uses content migrations instead (see above). A module shipped inside a Composer library is worse: its migrations run only when a consumer plugin calls the module's own migrator from a dated migration of its own —
// In the consumer plugin's dated migration
\acme\kit\Kit::getInstance()->getMigrator()->up();— and that consumer must also bump its own schemaVersion, or the dated migration never runs. Bumping the library's version constraint alone applies nothing. See the craft-plugin-release skill for the release-ordering hazards this creates.
Running all tracks
| Command | Tracks |
|---|---|
craft up |
All plugin tracks + content track + project config |
craft migrate/all |
All plugin tracks + content track (no project config) |
craft migrate/up |
Content track only |
craft migrate/up --track=plugin:my-plugin |
Specific plugin track only |
craft migrate vs craft up
| Command | Runs Migrations | Applies Project Config | Use When |
|---|---|---|---|
craft up |
Yes (all tracks) | Yes | Deployment -- the standard deploy command |
craft migrate/all |
Yes (all tracks) | No | Need migrations without project config changes |
craft migrate/up |
Yes (content track only) | No | Running only content migrations |
craft migrate/down |
Rolls back last migration (content track) | No | Undoing a content migration in development |
craft up is the correct command for deployments. It runs Craft + plugin migrations, applies project config YAML, then runs content migrations last (see Execution order in craft up for the exact sequence). Running craft migrate/all alone in a deploy script means project config changes from other developers never get applied.
Deployment
These apply to every Craft deployment -- Craft Cloud, Servd, Forge, bare metal, or any CI/CD pipeline. (On Craft Cloud specifically, migrations run automatically during the Migrate phase via php craft cloud/up — see the craft-cloud skill's deploy-pipeline.md for the full sequence.)
- Always run
ddev craft uplocally before deploying -- this runs pending migrations and applies project config changes in one command. - Migrations should run before the application serves traffic. Most hosting platforms and CI/CD pipelines handle this as a separate deploy step.
- Failed migrations can leave mutex locks in the
cachetable --TRUNCATE cacheto recover. craft clear-caches/allafter deployment if caches aren't automatically invalidated by your hosting platform.- The project config YAML files in
config/project/are the source of truth for configuration. Never edit theprojectConfigdatabase table directly.