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:
maat make:migration create_posts_table --create=postsmaat migratemake: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
mbecause a Dart identifier cannot begin with a digit. The migration’snamegetter 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
--tablehas an emptydown(): the stub emits aschema.table(...)block with nothing in it.migrate:rollbackwill report the migration as rolled back and delete its repository row while changing nothing in the schema. Laravel’s--tablestub 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 batchawait migrator.rollback(steps: 1); // undo the last batchawait migrator.reset(); // undo everythingawait migrator.refresh(); // reset + runfor (final s in await migrator.status()) print(s); // name: batch | PendingEach 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:
dart run bin/console.dart migratedart run bin/console.dart migrate:rollback --step=2dart run bin/console.dart migrate:statusTables
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 throwDatabaseException.drop columnandrename columnneed SQLite 3.35+ / 3.25+. - PostgreSQL gets
bigserial,varchar(n),numeric(p, s),jsonband native booleans. - Unique and index modifiers always compile to separate
create indexstatements so the same code path servescreateandalter.