All skills

Craft CMS 5 plugin and module development — extending Craft with PHP. Covers elements, element queries, services, models, records, controllers, migrations, queue jobs, console commands, field types, native fields, events, behaviors, Twig extensions, widgets, filesystems, permissions, project config, GraphQL, testing, and debugging. Triggers on: beforePrepare()/afterSave()/defineSources()/defineTableAttributes()/attributeHtml(), MemoizableArray, BaseNativeField, EVENT_REGISTER_*/DEFINE_*/BEFORE_*/AFTER_*, CraftVariable, custom element or field type (normalizeValue/serializeValue/inputHtml), webhook, API endpoint, queue/batch job, CP section, dashboard widget, utility page, element action/exporter/condition, registerUserPermissions, requirePermission vs requireAdmin, kebab-case permission handles, allowAdminChanges, canView/canSave/canDelete, defineRules, elevated session, project-config/apply, drafts/revisions, element edit sidebar (EVENT_DEFINE_SIDEBAR_HTML) + toolbar buttons, metaFieldsHtml, VueAdminTable, GeneralConfig, getIsMultiSite/refreshSites stale after creating a site, deleteSite phantom sites, element query stops filtering by siteId, naive UTC datetime columns and strtotime drift, element-query ['like'] tuple matches nothing, configWarning config-file overrides. Always use when writing, editing, or reviewing Craft plugin/module PHP — even when no API is named. For plugin-specific work also load craft-plugins. Do NOT trigger for front-end Twig (craft-site) or content modeling (craft-content-modeling).

Use this Skill: https://skilld.dev/gh/michtio/craftcms-claude-skills/craftcms

This session only. Nothing lands on disk.

referencesmigrations.md

≈6.7k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Migrations

Documentation

Common Pitfalls

  • Forgetting muteEvents on ProjectConfig::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 (notably Install::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 with Too many keys specified. Use createIndexIfMissing() and a Db::findForeignKey() check. See Re-runnable index creation.
  • Using null for FK name but specifying one for index -- use null for both, Craft generates deterministic names.
  • Missing TRUNCATE cache after failed migrations on Craft Cloud -- stale mutex locks block retries. See the craft-cloud skill (commands-and-cron.md) for running the truncate via the Console command runner.
  • Not thinking about ON DELETE behavior -- CASCADE for owned data, SET NULL for references, RESTRICT for protected refs.
  • Running raw SQL without checking column/table existence first -- addColumn on an existing column throws, dropColumn on 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 (plugin Install.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 through setFieldValue() + saveElement(), never raw relations SQL. See elements.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 the craft-cloud skill's deploy-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

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 schemaVersion in composer.json controls 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-docblocks

The 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.php leaves existing installs — including your db_test after the first test run — on the prior schema. See the craft-pest skill's shared-state.md → "Schema drift: Install.php vs migrations in the test database".
  • Editing only the dated migration leaves fresh installs without the change: on plugin install, Craft runs Install.php and then marks every dated migration as applied without running it. Anything a dated migration does that Install.php doesn'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. Guard Install::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 allowAdminChanges on), 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 allowAdminChanges is 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 let project-config/apply create 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 up runs plugin/Craft migrations, then applies project config YAML, then content migrations (see Execution order in craft 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() calls saveModifiedConfigData(), which releases the shared project-config mutex when it finishes (craftcms/cms 5.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 with BusyResourceException/StaleResourceException in places far from the migration that caused it.
  • With automatic YAML writing enabled, flush() rewrites config/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():

  1. migrate/all --no-content — Craft + plugin migrations. The content track is deliberately excluded here.
  2. Save / reset modified project config data (saveModifiedConfigData() + reset()).
  3. project-config/apply — applies pending YAML changes (diffed against the database), if any.
  4. migrate/up --track=content — content migrations (project root migrations/).
  5. clear-caches/compiled-templates.
  6. 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 up locally 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 cache table -- TRUNCATE cache to recover.
  • craft clear-caches/all after 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 the projectConfig database table directly.

Source: SKILL.md on GitHub

No alerts16d3 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The craftcms skill is a comprehensive reference guide for Craft CMS 5 plugin and module development. It provides extensive documentation on security best practices, including authorization layers, data handling, and common architectural pitfalls. No malicious patterns or security risks were identified.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

Signed by skilld at 614a89e. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 2 weeks ago

README badge

README badge for michtio/craftcms-claude-skills/craftcms