cakephp/migrations
CakePHP plugin that brings Laravel-style database migrations to CakePHP. Generate, run, roll back, and track schema changes with a familiar migrations shell, supporting MySQL, PostgreSQL, SQLite, and SQL Server across CakePHP 3, 4, and 5.
CakePHP Migrations is not a drop-in for Laravel's migrator — it is Phinx wrapped in CakePHP's ORM Table API, shipped with commands under bin/cake. If you're a Laravel developer, treat it as "Phinx with CakePHP conventions" and the mental mapping gets cheap fast.
First, confirm what you actually have, because in CakePHP 4/5 the plugin is a Composer dependency of cakephp/cakephp and there is often nothing to install:
composer show cakephp/migrations # version + constraint
bin/cake migrations --help # authoritative command list for your version
bin/cake migrations status # what's applied, what's pending
If it's genuinely absent (CakePHP 3 apps, stripped-down skeletons, plugins split out): composer require cakephp/migrations, then bin/cake plugin load Migrations if the app doesn't autoload it.
Where to look first:
config/Migrations/ — PHP migration classes, one file per change, named YYYYMMDDHHMMSS_ClassName.php. The class name must match the filename or the command silently skips it.config/migrations.php (CakePHP ≥4.2) or the Migrations key in config/app.php — connection mapping, backend, plugin paths.First use case for a Laravel brain: your legacy CakePHP app has a hand-maintained schema.sql and no history. You want a baseline plus a diff loop. Write (or generate via bin/cake bake migration CreateUsers, which bakes from existing ORM models) a single baseline migration that reproduces the current schema, then run bin/cake migrations migrate --fake? — no, the honest path is: apply the baseline to a fresh dev DB, then bin/cake migrations diff against the ORM models to prove the baseline matches reality. That diff-clean state is your starting gun. Once there, day-to-day is create → migrate → status → rollback.
Migration skeleton. CakePHP 5-era plugin uses Migrations\BaseMigration; 4.x-era code uses Migrations\AbstractMigration. Both expose change()/up()/down():
<?php
declare(strict_types=1);
use Migrations\BaseMigration;
class CreateOrders extends BaseMigration
{
public function change(): void
{
$this->table('orders')
->addColumn('user_id', 'integer', ['signed' => false, 'null' => false])
->addColumn('reference', 'string', ['limit' => 32, 'null' => false])
->addColumn('total', 'decimal', ['precision' => 10, 'scale' => 2, 'default' => 0])
->addColumn('created', 'datetime', ['null' => false])
->addColumn('modified', 'datetime', ['null' => false])
->addIndex(['reference'], ['unique' => true, 'name' => 'UQ_orders_reference'])
->addForeignKey('user_id', 'users', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
->create();
}
}
The chain terminates in ->create() for new tables and ->update() for changes. Unlike Laravel's Schema::table closure, each migration file can hold many tables — do that, it's far fewer round-trips than Laravel's one-op-per-file habit.
Laravel → Phinx/CakePHP translation. Keep this on a sticky note:
| Laravel | CakePHP Migrations |
|---|---|
$table->id() |
$table->addColumn('id', 'integer', ['autoIncrement' => true]) (default for table()), or biginteger + signed => false |
$table->string('x', 190) |
addColumn('x', 'string', ['limit' => 190]) |
$table->boolean() |
addColumn('x', 'boolean', ['default' => false]) |
$table->timestamps() |
two datetime columns named created / modified |
$table->json() |
addColumn('x', 'json') |
$table->softDeletes() |
datetime nullable named deleted (CakePHP SoftDelete convention) |
$table->foreignId('user_id')->constrained() |
addColumn('user_id','integer',['signed'=>false]) + addForeignKey(...) |
$table->enum(...) |
adapter-dependent; often a string + app-level validation is saner |
Schema::dropIfExists |
$this->table('x')->drop()->save() |
change() vs up()/down(). Use change() for creates, additive columns, and indexes — Phinx reverses those automatically. Drop change() and write explicit up()/down() the moment you remove, rename, or retype anything; reverse engineering is not guaranteed and a bad rollback mid-incident is the expensive kind of mistake.
Schema-driven changes. bin/cake migrations diff compares your ORM Table::getSchema() definitions to the live DB and emits a migration. This is the closest thing to Laravel's model-first comfort: change the entity/table class, run diff, review the generated file. It's a draft generator, not an oracle — always read the output before committing. Some versions also expose a dump-style command; confirm with --help rather than assuming flags from a blog post.
Seeds and data. Seeds live in config/Seeds/ and extend Migrations\AbstractSeed. They're the natural home for reference data (roles, countries), not user data. For small fixture inserts inside a migration, Phinx's insert()/inserts() on the Table object exists, but I'd rather see a separate idempotent seeder you can re-run with bin/cake migrations seed -s ReferenceData than data welded into a schema change.
Multi-connection and plugins. CakePHP apps routinely have default, test, and reporting datasources. --connection reporting targets one; migrations are tracked in that connection's phinxlog table independently. Plugin migrations stay in plugins/<Name>/config/Migrations and run with -p <Name> — good for shipping a plugin with its own schema.
CI. Two lines: bin/cake migrations migrate --no-interaction against the test DSN, then the suite. Prefer a real migration run over loading a schema dump; that's how drift gets caught.
change() cannot reverse column drops, renames, or type narrowing. It will happily generate a migration and then refuse (or, worse, mangle) the rollback. Write down().addColumn on MySQL is not transactional. MySQL implicitly commits DDL, so beginTransaction around a multi-statement migration gives you a false sense of atomicity. Keep migrations small and single-purpose so a failure is re-runnable.int(10) unsigned will not reference int(10) signed, and MyISAM silently ignores FKs. Set ['engine' => 'InnoDB', 'collation' => 'utf8mb4_unicode_ci'] (or your DB default) explicitly on tables that carry FKs.migrations status is your debugger. Most "it didn't run" reports are a filename/classname mismatch or a migration already recorded in phinxlog from a branch you forgot about. bin/cake migrations mark_migrated -t <version> fixes the latter — use it deliberately, it's the equivalent of stuffing the migrations table by hand.phinxlog version row makes the temptation louder because versions are timestamped filenames, not a checksum.migrations.lock file (and the related lock/unlock commands in recent releases) blocks accidental runs — that's the production safety belt. Know where it lives; a stale lock is the reason migrate refuses to do anything and prints something unhelpfully terse.bake migration bakes from the ORM, not the DB. If your Table classes don't enumerate columns in getSchema(), the generated migration will be wrong or empty. Fix the model layer before blaming the tool.'name' => 'IDX_...' yourself.json support varies by adapter and Phinx version. Raw $this->execute('ALTER TABLE ...') is legitimate for the 5% the abstraction won't do — just mirror it in down().NOASSERTION on Packagist. Before shipping in a commercial/closed product, open the LICENSE file in the repo and confirm terms with whoever signs off — don't rely on the package metadata.BaseMigration vs AbstractMigration differs between them); mixing a CakePHP 4 app with a migrations 5-era plugin produces confusing class-not-found errors. Pin cakephp/migrations to the same major branch as cakephp/cakephp.Migrations\Db\Table to add team-standard helpers (e.g. a addAuditColumns() that stamps created/modified/created_by), or write a custom seed base class for idempotent upserts. This is where you claw back Laravel's Blueprint ergonomics.migrations-style tables and mutual clobbering.How can I help you explore Laravel packages today?