Skip to content

Database Migrations

Migrations are version control for your schema. Each is a class with up and down; the migrator records what ran in a migrations table, in batches, so a rollback undoes exactly the last run.

Generating and Registering Migrations

Inside a Maat application, Sesh generates the class and updates the registry:

Terminal window
maat make:migration create_posts_table --create=posts
maat migrate

make:migration writes database/migrations/m2026_09_02_115436_create_posts_table.dart and adds its import and instance to database/migrations.dart.

  • The file name starts with m because a Dart identifier cannot begin with a digit. The migration’s name getter keeps the unprefixed timestamp stored in the database.
  • The registry is the source of truth. Dart does not scan the directory with reflection, so migrations run in list order, not timestamp order. Add a hand-written migration to the list yourself.

The command refuses to overwrite an existing migration unless you pass --force.

The up and down Methods

class CreatePostsTable extends Migration {
@override
Future<void> up(SchemaBuilder schema) async {
await schema.create('posts', (table) {
table.id();
table.foreignId('user_id').constrained().onDelete('cascade');
table.string('title');
table.text('body');
table.timestamps();
});
}
@override
Future<void> down(SchemaBuilder schema) async {
await schema.dropIfExists('posts');
}
}

A migration’s name defaults to its class name. Keep the list of migrations in one place, in order:

final migrations = <Migration>[CreatePostsTable()];

A migration generated with --table has an empty down(): the stub emits a schema.table(...) block with nothing in it. migrate:rollback will report the migration as rolled back and delete its repository row while changing nothing in the schema. Laravel’s --table stub does the same. Write the reversing statements yourself if the rollback needs to mean something.

See Database for the full command set, the production guard, and the MySQL implicit-commit hazard.

Running Migrations

Outside an application — a script, a package test — drive the Migrator directly:

final migrator = Migrator(db, migrations, log: print);
await migrator.run(); // pending migrations, one new batch
await migrator.rollback(steps: 1); // undo the last batch
await migrator.reset(); // undo everything
await migrator.refresh(); // reset + run
for (final s in await migrator.status()) print(s); // name: batch | Pending

Each migration runs in its own transaction: a failing up() leaves neither its tables nor its repository row behind. On MySQL, DDL statements commit implicitly, so a migration that fails halfway may leave tables behind; the repository row is still not written, so fixing and re-running is safe once you drop what was created. Laravel has the same limitation.

runMigrationConsole(args, migrator) turns that into the familiar commands with no extra dependencies:

Terminal window
dart run bin/console.dart migrate
dart run bin/console.dart migrate:rollback --step=2
dart run bin/console.dart migrate:status

Tables

await schema.create('posts', (table) { ... });
await schema.table('posts', (table) { // alter
table.string('slug').nullable();
table.index(['slug']);
table.dropColumn('legacy');
table.renameColumn('body', 'content');
});
await schema.rename('posts', 'articles');
await schema.drop('articles');
await schema.dropIfExists('articles');
await schema.hasTable('posts');
await schema.hasColumn('posts', 'slug');

Schema.create(...), Schema.table(...) and friends do the same on the default connection.

Columns

Method Meaning
id([name]) auto-incrementing big integer primary key
string(name, [length]), text(name), uuid(name) text columns
integer, bigInteger, unsignedBigInteger integers
boolean, double_/float, decimal(name, precision:, scale:) numbers
date, dateTime, timestamp temporal
json(name) jsonb on PostgreSQL, text on SQLite
foreignId(name) big integer; add .constrained([table], [column]), .onDelete('cascade'), .onUpdate(...)
timestamps() nullable created_at and updated_at
softDeletes([name]) nullable deleted_at

Modifiers: nullable(), defaultValue(value) (bool, num, String or RawSql), useCurrent(), unique(), index(), primary(), unsigned().

Table-level: unique([...]), index([...]), primary([...]), dropColumn, dropIndex, renameColumn.

Every name is validated as an identifier before it reaches SQL; defaults are literal-quoted, never interpolated from request data.

Dialect Notes

  • SQLite cannot change a column’s type or add a primary key with alter table; both throw DatabaseException. drop column and rename column need SQLite 3.35+ / 3.25+.
  • PostgreSQL gets bigserial, varchar(n), numeric(p, s), jsonb and native booleans.
  • Unique and index modifiers always compile to separate create index statements so the same code path serves create and alter.