Migrations
Migrations are schema changes as versioned, ordered files. Seeders
populate a database with fixed data. Factories generate realistic rows on
demand for tests and seeders. All three live in @mahiframework/database.
./artisan make:migration create_posts_table
./artisan migrate
./artisan migrate:status
Where migrations live
Two sources, both collected on every migration command:
- The app's own migrations: the
database/migrationsdirectory by default, configurable via thedatabase.migrationsPathconfig key, or a static list underdatabase.migrationSources(see Static migration sources). - Every registered provider's migrations: via its
migrationSources()hook, or itsmigrations()hook returning an absolute directory path.
export function collectMigrationSources(app: Application): MigrationSource[] {
const sources: MigrationSource[] = [];
const appSources = app.config.get<RegisteredMigration[] | undefined>(
"database.migrationSources",
undefined,
);
if (appSources && appSources.length > 0) {
sources.push(...appSources);
} else {
sources.push(app.config.get<string>("database.migrationsPath", "database/migrations"));
}
for (const provider of app.getProviders()) {
const registered = provider.migrationSources?.();
if (registered && registered.length > 0) {
sources.push(...registered);
continue;
}
const dir = provider.migrations?.();
if (dir) sources.push(dir);
}
return sources;
}
This is how @mahiframework/auth ships personal_access_tokens, sessions and
password_reset_tokens, @mahiframework/queue ships jobs and failed_jobs, and
@mahiframework/notifications ships notifications, none of them are copied into
your app, and all of them run alongside your own.
To contribute a directory from your own package or provider:
export class BillingServiceProvider extends ServiceProvider {
migrations(): string {
return fileURLToPath(new URL("../database/migrations", import.meta.url));
}
}
Return an absolute path. A published package should point at its
compiled dist/, not src/, the discovery filter handles that (it
excludes .d.ts explicitly, which matters when .js and .d.ts sit side
by side).
Only files matching {digits}{_ or -}{name}.{ts,js,mts,mjs,cts,cjs} are
imported. A helpers.ts of shared blueprint code or an index.ts
re-export can sit in the same directory without being run as a migration
under its own filename.
Migrations from all sources are merged and sorted by name byte-wise
(not localeCompare, which is locale-dependent, under ICU it ignores
punctuation, so 2024_01_01_a and 2024-01-01-a collate as equal and
their relative order varies by machine). A provider's migration and yours
therefore interleave by timestamp, identically on a laptop, in CI and in
production. Name yours with a later timestamp than anything they depend
on. Names are deduplicated across sources, first occurrence winning, so a
directory and a static registry that overlap do not run anything twice.
Static migration sources
Directory discovery is readdir plus a dynamic import() of the file it
finds. Neither survives bundling: a single-file executable has no
database/migrations directory to read and no path to import. Worse, the
runner treats an unreadable directory as nothing to discover rather than
an error, so a compiled app prints "Nothing to migrate" and then
happily runs against an empty database.
An app or package that intends to be compiled supplies its migrations
explicitly instead. A RegisteredMigration is just a name paired with the
migration itself:
// database/registry.ts
import type { RegisteredMigration } from "@mahiframework/database";
import createPostsTable from "./migrations/2026_01_01_000000_create_posts_table.js";
export const MIGRATIONS: RegisteredMigration[] = [
{ name: "2026_01_01_000000_create_posts_table", migration: createPostsTable },
];
// config/database.ts
import { MIGRATIONS } from "../database/registry.js";
export function databaseConfig(env: Env) {
return {
default: "sqlite",
migrationSources: MIGRATIONS,
connections: { sqlite: { filename: env.DB_FILENAME } },
};
}
A provider does the same through migrationSources():
export class BillingServiceProvider extends ServiceProvider {
migrationSources(): RegisteredMigration[] {
return [{ name: "0001_create_invoices_table", migration: createInvoicesTable }];
}
}
@mahiframework/auth, @mahiframework/queue and @mahiframework/notifications all do this, so a
bundled app gets their tables without any filesystem access. They still
implement migrations() too, for older consumers; when a provider has
both, migrationSources() wins.
name is the compatibility surface. It is what lands in the
migrations table and what orders execution, so keep it byte-identical to
the filename-without-extension the directory form produced. Change it and
every existing database re-runs that migration against tables that already
exist.
The two forms mix freely. An app can keep migrationsPath for its own
migrations while consuming providers that register statically, or pass
both during a migration to the static form.
Writing a migration
A migration is a default-exported object with up() and down():
export interface Migration {
up(): Promise<void>;
down(): Promise<void>;
}
import { Schema, type Migration, type Blueprint } from "@mahiframework/database";
const migration: Migration = {
async up(): Promise<void> {
await Schema.create("posts", (table: Blueprint) => {
table.string("id").primary();
table.string("user_id").index();
table.string("parent_id").nullable().index();
table.text("body");
table.timestamp("created_at");
table.timestamp("updated_at");
table.softDeletes();
});
},
async down(): Promise<void> {
await Schema.drop("posts");
},
};
export default migration;
The discovery loader accepts mod.default ?? mod, so a module exporting
up/down as named exports also works, but the default export is the
convention every generator and every shipped migration uses.
The filename without its extension is the unique id and the ordering
key. make:migration prefixes a YYYYMMDDHHmmss timestamp; the shipped
app migrations use a 2026_01_03_000000_ style. Either sorts correctly,
just be consistent within a directory.
Multiple statements in one migration are fine, and order matters for foreign keys:
async up(): Promise<void> {
await Schema.create("hashtags", (table: Blueprint) => {
table.string("id").primary();
table.string("name").unique();
table.timestamp("created_at");
});
await Schema.create("post_hashtag", (table: Blueprint) => {
table.string("post_id");
table.string("hashtag_id").index();
table.primary(["post_id", "hashtag_id"]);
});
},
async down(): Promise<void> {
await Schema.drop("post_hashtag"); // reverse order
await Schema.drop("hashtags");
},
Atomicity and the migration lock
Each migration runs in its own transaction, together with the
migrations row recording it, on SQLite and Postgres, which have
transactional DDL. A migration that throws halfway therefore undoes its
own tables and columns and records nothing, so the next run retries it
cleanly rather than skipping work that never happened (or re-running DDL
against a schema that already has it).
MySQL is the exception. Every CREATE/ALTER TABLE there causes an
implicit commit, so a transaction around a migration would look atomic
without being it. Rather than pretend, MySQL runs each migration
unwrapped. A failed migration leaves partial DDL to clean up by hand,
exactly as it does in Laravel.
Migrations within one run are separately atomic, not collectively: if the third of four fails, the first two stay applied and recorded. That's the useful granularity, re-running picks up where it stopped.
A run holds a lock for its duration, in a migrations_lock table with
a single fixed-id row. Two migrate processes racing (a deploy starting
two app instances, a CI job overlapping a manual run) would otherwise both
read the same pending list and the same max(batch), then both run
everything, the loser's DDL failing halfway and leaving the schema in a
state neither process's migrations rows describe.
The second process gets a clear error instead:
Another migration run holds the lock (acquired by pid:4821 at
2026-01-04T09:12:44.001Z). Wait for it to finish, or — if that process
died mid-run — inspect the schema and clear it with:
delete from "migrations_lock".
The lock is released on success and on failure. A hard crash (SIGKILL,
a lost host) leaves the row behind and the next run refuses to start,
deliberately the safer failure: a stale lock costs one manual DELETE,
while a lock that vanished with a dead connection costs a corrupted
schema.
The migrations table
Tracking lives in a table the runner creates on demand:
await schema.create("migrations", (table) => {
table.id();
table.string("name").unique();
table.integer("batch");
table.timestamp("migrated_at");
});
| Column | Meaning |
|---|---|
id | Auto-increment surrogate key. |
name | Filename without extension. Unique. |
batch | Which migrate run applied it. |
migrated_at | UTC ISO-8601 timestamp. |
ensureMigrationsTable() runs at the start of up(), rollback() and
status() and no-ops if the table exists. migrations_lock (see
Atomicity and the migration lock) is
created the same way, and is empty except while a run is in progress.
Batches are how rollback knows what to undo. Every migration applied
by a single migrate run gets the same batch number, computed as
max(batch) + 1. migrate:rollback undoes exactly the highest batch, so
if you ran three migrations in one go, rollback undoes all three; if you
ran them one at a time, rollback undoes only the last (pass
--step 3 to undo all three).
Within a batch, rollback runs in reverse filename order, so dependencies unwind correctly.
If a migration recorded in the table has no matching source file, rollback throws rather than skipping:
Cannot roll back migration "2026_01_03_000000_create_posts_table": source file not found.
MigrationRunner
import { MigrationRunner, DatabaseManager, DATABASE_TOKEN } from "@mahiframework/database";
const db = app.make<DatabaseManager>(DATABASE_TOKEN);
const runner = new MigrationRunner(db.driver().kysely);
| Method | Returns | Behaviour |
|---|---|---|
up(dirs, onEach?) | Promise<string[]> | Runs every pending migration. Returns the names that ran. |
rollback(dirs, onEach?) | Promise<string[]> | Undoes the highest batch. Returns the names rolled back. |
fresh(dirs, onEach?) | Promise<string[]> | dropAllTables(), then up(). |
status(dirs) | Promise<MigrationStatus[]> | Per-migration { name, ran, batch }. |
interface MigrationStatus {
name: string;
ran: boolean;
batch: number | null;
}
onEach(name, run) wraps the execution of each individual migration, the
CLI passes Tui.task to print a per-migration status line. It must
call and await run() itself.
fresh() bypasses every down() entirely and does a direct schema wipe,
so it works even when a down() is missing or broken.
SchemaBuilder.dropAllTables() suspends foreign-key enforcement, drops
every non-view table, then restores it in a finally, with each engine
using its own mechanism: the foreign_keys pragma on SQLite,
FOREIGN_KEY_CHECKS on MySQL (pinned to one pooled connection, since it
is a session variable), and DROP ... CASCADE on Postgres.
On Postgres this only drops the current schema's tables, resolved
from current_schema(), a database shared with another schema is left
alone.
Schema
Schema is a facade over SchemaBuilder, bound at SCHEMA_TOKEN.
| Method | Purpose |
|---|---|
Schema.create(table, callback) | Create a table. |
Schema.table(table, callback) | Alter a table. |
Schema.drop(table) | Drop it. Errors if absent. |
Schema.dropIfExists(table) | Drop it if present. |
Schema.rename(from, to) | Rename a table. |
Schema.hasTable(table) | Promise<boolean> |
Schema.hasColumn(table, column) | Promise<boolean> |
Schema.dropAllTables() | Every user table. Used by migrate:fresh. |
The callback receives a Blueprint, collects definitions, and compiles
them to Kysely schema statements on execute().
For a second connection, construct the builder yourself:
const schema = DB.schema("analytics");
await schema.create("events", (table) => { /* ... */ });
Blueprint: column types
Every method below exists. The right column shows the SQLite affinity it
compiles to (SQLite has five storage classes, so length and precision
arguments are recorded but ignored there). On MySQL and Postgres the
same definitions compile to that engine's real types. string(col, 64)
is varchar(64), decimal(col, 12, 4) is decimal(12, 4), and
timestamp() carries its fractional-second precision.
Auto-incrementing keys
| Method | Affinity |
|---|---|
id(column = "id") | integer: alias for bigIncrements |
increments(column = "id") | integer: alias for integerIncrements |
integerIncrements(column = "id") | integer |
tinyIncrements(column = "id") | integer |
smallIncrements(column = "id") | integer |
mediumIncrements(column = "id") | integer |
bigIncrements(column = "id") | integer |
All seven set autoIncrement, primary and unsigned on the definition.
Strings and text
| Method | Affinity |
|---|---|
string(column, length = 255) | text |
char(column, length = 255) | text |
text(column) | text |
tinyText(column) | text |
mediumText(column) | text |
longText(column) | text |
Integers
| Method | Affinity |
|---|---|
integer(column) | integer |
tinyInteger(column) | integer |
smallInteger(column) | integer |
mediumInteger(column) | integer |
bigInteger(column) | integer |
unsignedInteger(column) | integer (.unsigned()) |
unsignedTinyInteger(column) | integer (.unsigned()) |
unsignedSmallInteger(column) | integer (.unsigned()) |
unsignedMediumInteger(column) | integer (.unsigned()) |
unsignedBigInteger(column) | integer (.unsigned()) |
foreignId(column) | integer: alias for unsignedBigInteger |
Numbers and booleans
| Method | Affinity |
|---|---|
boolean(column) | integer: SQLite has no native boolean |
float(column, precision?) | real: precision ignored |
double(column, total?, places?) | real: args ignored |
decimal(column, total = 8, places = 2) | numeric |
Pair a boolean column with Cast.boolean(), and a decimal column with
Cast.decimal(places) from @mahiframework/database. See
Models. A boolean attribute without a cast is a
compile error, precisely because the column comes back as 0/1.
Dates and times
| Method | Affinity |
|---|---|
date(column) | text |
dateTime(column, precision?) | text |
dateTimeTz(column, precision?) | text |
time(column, precision?) | text |
timeTz(column, precision?) | text |
timestamp(column, precision?) | text |
timestampTz(column, precision?) | text |
year(column) | text |
On SQLite all of these are ISO-8601 text and the Tz variants compile
identically. On MySQL/Postgres they compile to the engine's real
temporal types, and the Tz variants become timestamp with time zone
/ time with time zone.
precision defaults to 3 (milliseconds), not Laravel's 0. SQLite
stores whatever text it is handed and the framework stamps timestamps
with millisecond precision, so a timestamp(0) column on MySQL/Postgres
would round that away, the same create() would round-trip exactly on
SQLite and lose its milliseconds elsewhere. Pass 0 explicitly for
whole-second columns.
Structured and specialised
| Method | Affinity |
|---|---|
json(column) | text |
jsonb(column) | text |
uuid(column) | text |
ulid(column) | text |
binary(column) | blob |
enum(column, allowed) | text: allowed recorded, not enforced |
ipAddress(column) | text |
macAddress(column) | text |
rememberToken() | string("remember_token", 100).nullable() |
enum() does not generate a CHECK constraint. Validate in the
application layer. See Validation.
Helper groups
| Method | Adds |
|---|---|
timestamps(precision?) | Nullable created_at + updated_at. Alias for nullableTimestamps. |
nullableTimestamps(precision?) | Same. |
timestampsTz(precision?) | Nullable created_at + updated_at as timestampTz. |
datetimes(precision?) | Alias for timestamps. |
softDeletes(column = "deleted_at", precision?) | Nullable timestamp. |
softDeletesTz(column = "deleted_at", precision?) | Nullable timestampTz. |
softDeletesDatetime(column = "deleted_at", precision?) | Nullable dateTime. |
timestamps() returns void, not a ColumnDefinition. You can't chain
modifiers onto it. Declare the columns individually if you need to (the
shipped posts migration does exactly that for non-nullable timestamps).
Morph groups
| Method | Adds |
|---|---|
morphs(name, indexName?) | {name}_type string + {name}_id unsignedBigInteger + composite index |
nullableMorphs(name, indexName?) | Same, both nullable |
uuidMorphs(name, indexName?) | {name}_type string + {name}_id uuid + index |
ulidMorphs(name, indexName?) | {name}_type string + {name}_id ulid + index |
numericMorphs(name, indexName?) | Same as morphs |
These are the only place in the framework that does derive column names from a base name, the relation declarations themselves require explicit column names. See Relationships.
Blueprint: column modifiers
Every modifier returns the ColumnDefinition for chaining.
| Modifier | Effect |
|---|---|
nullable(value = true) | Omits NOT NULL. Columns are non-null by default. |
default(value) | DEFAULT ?. Booleans normalise to 1/0. |
useCurrent() | DEFAULT CURRENT_TIMESTAMP. Beats default(). |
primary() | Marks it the primary key. |
autoIncrement() | Sets auto-increment and primary. |
from(startingValue) | Seeds sqlite_sequence. Create only. |
unsigned() | Recorded; no effect on SQLite affinity. |
unique(indexName?) | Creates a unique index after the table. |
index(indexName?) | Creates a non-unique index after the table. |
comment(text) | Recorded; not emitted. |
after(column) | Recorded; not emitted (SQLite can't reposition). |
first() | Recorded; not emitted. |
storedAs(expression) | GENERATED ALWAYS AS (expr) STORED. |
virtualAs(expression) | GENERATED ALWAYS AS (expr). |
change() | Marks for modification. Schema.table() only. |
references(column) | Starts a foreign key. Returns ForeignKeyDefinition. |
constrained(table?, column = "id") | references(column).on(table ?? inferred). |
Columns are NOT NULL unless you call nullable(). That's the
opposite of raw SQL's default and matches Laravel.
constrained() infers the table from the column name, user_id →
users, by stripping _id and appending s unless it already ends in
s. This is the framework's one bit of naming inference outside morphs,
and it's naive: person_id infers persons, category_id infers
categorys. Pass the table explicitly when the guess is wrong.
comment(), after() and first() are recorded on the definition but
never emitted. They exist for API parity, not effect.
Blueprint: indexes
| Method | Notes |
|---|---|
primary(columns, name?) | Composite primary key constraint. |
unique(columns, name?) | |
index(columns, name?) | |
fullText(columns, name?) | MySQL only: throws on SQLite and Postgres. |
spatialIndex(columns, name?) | Throws on every dialect. |
columns is a string or a string array.
fullText indexes are not supported on sqlite.
spatialIndex is not supported on postgres.
They throw at compile time, before any DDL runs.
Index names default to Laravel's scheme, {table}_{col1}_{col2}_{type},
lowercased, with - and . replaced by _:
posts_user_id_index
users_email_unique
post_hashtag_post_id_hashtag_id_primary
Declaring a composite primary() switches off per-column primary key
emission, so the constraint is added once as a table-level constraint:
await Schema.create("post_hashtag", (table) => {
table.string("post_id");
table.string("hashtag_id").index();
table.primary(["post_id", "hashtag_id"]);
});
Indexes are created as separate CREATE INDEX statements after the
table, both for .unique()/.index() column modifiers and for
table-level unique()/index() calls.
Blueprint: foreign keys
table.foreign(columns, name?): ForeignKeyDefinition
Or start from a column: .references(column) / .constrained(table?).
| Method | Effect |
|---|---|
references(columns) | Referenced column(s). Defaults to ["id"]. |
on(table) | Referenced table. Required. |
name(name) | Constraint name. |
onDelete(action) / onUpdate(action) | |
cascadeOnDelete() / cascadeOnUpdate() | |
restrictOnDelete() / restrictOnUpdate() | |
nullOnDelete() / nullOnUpdate() | SET NULL |
noActionOnDelete() / noActionOnUpdate() |
type ReferentialAction = "cascade" | "restrict" | "set null" | "set default" | "no action";
await Schema.create("posts", (table) => {
table.id();
table.foreignId("user_id").constrained().cascadeOnDelete();
table.text("body");
});
// Or explicitly:
await Schema.create("posts", (table) => {
table.string("id").primary();
table.string("user_id");
table.foreign("user_id").references("id").on("users").cascadeOnDelete();
});
Missing .on() throws:
Foreign key on posts(user_id) is missing .on(table).
Foreign keys are actually enforced because SqliteDriver sets
PRAGMA foreign_keys = ON. Without it they'd be decorative.
Blueprint: drop and rename
| Method | Notes |
|---|---|
dropColumn(...columns) | Accepts strings and arrays; flattened. |
dropColumns(...columns) | Alias. |
renameColumn(from, to) | |
dropIndex(index) | Array → derives the conventional name. |
dropUnique(index) | Same, with the unique suffix. |
dropPrimary(index?) | Throws on SQLite; supported on MySQL/Postgres. |
dropForeign(index) | Throws on SQLite; supported on MySQL/Postgres. |
dropTimestamps() | dropColumn("created_at", "updated_at"). |
dropSoftDeletes(column = "deleted_at") | |
dropMorphs(name, indexName?) | Drops the index and both columns. |
dropRememberToken() | |
rename(to) | Renames the table. |
SQLite limitations in Schema.table()
These restrictions are SQLite-only, MySQL and Postgres support all
of them natively through real ALTER TABLE statements.
compileAlter() rejects several operations up front, before touching the
database:
| Operation | Error |
|---|---|
| Adding a foreign key | Adding foreign keys via Schema.table() is not supported on SQLite. Define foreign keys in Schema.create() instead. |
| Dropping a foreign key | Dropping foreign keys via Schema.table() is not supported on SQLite (requires a table rebuild). |
| Dropping a primary key | Dropping primary keys via Schema.table() is not supported on SQLite. |
| Adding a primary key | Adding a primary key via Schema.table() is not supported on SQLite. |
| Adding a primary/auto-increment column | Adding a primary key column via Schema.table() is not supported on SQLite. |
.change() in Schema.create() | Column.change() is only valid inside Schema.table(), not Schema.create(), for "x". |
The workaround for all of them is the standard SQLite one: create a new table with the shape you want, copy the data across, drop the old one, rename. Write that explicitly in a migration.
change() rebuilds the table
.change() is supported, and it triggers a full table rebuild,
introspect the current schema, create __temp__{table} with the modified
columns, INSERT ... SELECT every row across, drop the original, rename
the temp table, then recreate any non-constraint indexes:
await Schema.table("posts", (table) => {
table.text("body").nullable().change();
});
Foreign keys are turned off for the rebuild and restored in a finally.
Existing unique constraints, foreign keys and primary keys are carried
over from introspection.
Changing a column that doesn't exist throws:
Cannot change column "body" on "posts": column does not exist.
On a large table this rewrites every row. Budget for it.
Operation order in Schema.table()
Regardless of the order you call them, compileAlter() runs:
- Add columns
- Rebuild for
.change()columns - Rename columns
- Drop indexes
- Drop columns
- Create indexes (including a composite primary key)
- Add foreign keys
- Drop foreign keys
- Drop primary keys
- Rename the table
Split into separate migrations if you need a different order.
Indexes are dropped before columns so the mirror-image down() works:
async down() {
await Schema.table("jobs", (table) => {
table.dropIndex(["queue", "available_at"]);
table.dropColumn("queue"); // the index covered this
});
}
Until recently this ran the other way round and the above failed on SQLite with
error in index ... after drop column: no such column. MySQL and Postgres drop a covering index implicitly with its column, so the same migration passed there. Which is why the cross-engine test for it matters more than the SQLite one.
Commands
The production guard
Every command on this page that writes to the database (migrate,
migrate:fresh, migrate:refresh, migrate:reset, migrate:rollback,
db:wipe, db:seed) is
guarded when APP_ENV=production. So are the destructive queue commands,
queue:clear and queue:flush.
| Situation | Behaviour | Exit code |
|---|---|---|
| Not production | Runs. No prompt, a local migrate:fresh stays one keystroke. | 0 |
| Production, terminal attached | Prompts Do you really wish to run this command?, defaulting to no. | 0 either way |
| Production, no terminal (CI, deploy script) | Refuses. Pass --force. | 1 |
--force | Runs, no prompt. | 0 |
Failing closed without a TTY is the actual safety property: an
unattended pipeline should not be able to drop a production schema
because nobody was watching the terminal. Note the app treats an
unknown APP_ENV as production, so the guard defaults to on.
The two refusals differ in exit code on purpose. No terminal exits 1, because nobody was asked and a deploy that continues against an unmigrated schema is worse than one that stops. A human answering "no" exits 0. That is a decision, not a failure.
"Terminal attached" means stdin and stdout, so ./artisan migrate | tee deploy.log counts as unattended.
--pretend is exempt. It cannot change anything.
migrate
./artisan migrate
./artisan migrate --pretend
| Flag | Effect |
|---|---|
--pretend | List what would run, run nothing. |
--force | Skip the production confirmation. |
Runs every pending migration across all collected directories. Prints a
per-migration task line. Nothing to migrate. when up to date.
--pretend lists the pending migration names; it does not print SQL.
A migration here is arbitrary TypeScript rather than a declarative list
of statements, so the only way to know its SQL would be to run it,
and a migration that branches on a query result would take a different
path under a pretend connection, or do real non-DDL work. Reporting the
names is the part that is both useful and true.
migrate:fresh
./artisan migrate:fresh
./artisan migrate:fresh --seed
| Flag | Effect |
|---|---|
--seed | Run db:seed afterwards. |
--force | Skip the production confirmation. |
Drops every table, bypassing down() entirely, then re-runs
everything from scratch. Works even if a down() is missing or broken.
Destroys all data.
migrate:refresh
./artisan migrate:refresh
./artisan migrate:refresh --seed
| Flag | Effect |
|---|---|
--seed | Run db:seed afterwards. |
--force | Skip the production confirmation. |
Rolls back every batch, then re-runs everything. Unlike fresh,
this exercises your down() methods. Which is the point, and also the
risk: a broken down() stops it partway.
migrate:reset
./artisan migrate:reset
./artisan migrate:reset --pretend
| Flag | Effect |
|---|---|
--pretend | List what would roll back, run nothing. |
--force | Skip the production confirmation. |
Rolls back every migration, newest batch first, and stops there,
migrate:refresh without the re-migrate. Like refresh and unlike
fresh, it runs each migration's down(), so it exercises them and
correspondingly fails partway on one that is broken.
The migrations table itself survives; the migrations are simply marked
un-run. Use db:wipe when you want the ledger gone too.
db:wipe
./artisan db:wipe
| Flag | Effect |
|---|---|
--force | Skip the production confirmation. |
Drops every table and stops, migrate:fresh without the re-migrate. No
down() is involved, so nothing in the migrations can object, and it
works on a schema whose migrations no longer exist.
This removes the migrations table too, so afterwards there is no
record that anything ever ran. That is the point when you are about to
restore a dump, and a trap otherwise: a subsequent migrate re-runs
everything from the beginning.
| Command | Empties via | Keeps history | Re-migrates |
|---|---|---|---|
migrate:reset | down() | yes | no |
migrate:refresh | down() | yes | yes |
db:wipe | drop table | no | no |
migrate:fresh | drop table | yes* | yes |
* fresh drops the ledger too, but immediately re-migrates, so the
history is rebuilt as a single fresh batch.
migrate:rollback
./artisan migrate:rollback
./artisan migrate:rollback --step 3
./artisan migrate:rollback --pretend
| Flag | Effect |
|---|---|
--step <count> | Roll back this many batches, newest first. Default 1. |
--pretend | List what would roll back, roll back nothing. |
--force | Skip the production confirmation. |
Rolls back the most recent batch by default. Nothing to roll back.
when the table is empty.
--step counts batches, not individual migrations, matching Laravel:
a batch is what was applied together, so it is what can be undone
together without leaving a half-applied deploy. A --step larger than
the number of batches rolls back everything rather than erroring.
Within the rollback, order is newest batch first and, inside each batch,
the reverse of the order it was applied, a down() can depend on
everything applied before it still existing.
migrate:status
./artisan migrate:status
Migration Status
2026_01_01_000000_create_users_table Ran (batch 1)
2026_01_03_000000_create_posts_table Pending
db:seed
./artisan db:seed
| Flag | Effect |
|---|---|
--force | Skip the production confirmation. |
Runs every seeder returned by every provider's seeders() hook, in
provider registration order. There is no --class flag, run a single
seeder from a custom command if you need to.
migrate:fresh --seed and migrate:refresh --seed call this directly
and pass their own confirmation through, so you are asked once for the
whole operation rather than twice.
db:show
./artisan db:show
Every table with its column count and row count, via Kysely's introspection.
db:table
./artisan db:table posts
Column Type Nullable Auto-increment
id TEXT no no
user_id TEXT no no
body TEXT no no
Generators
| Command | Default directory | Flags |
|---|---|---|
make:migration <name> | database/migrations | -d, --dir <dir> |
make:model <name> | src/models | -d, --dir, -m, --migration, -f, --factory |
make:factory <name> | database/factories | -d, --dir <dir> |
make:seeder <name> | database/seeders | -d, --dir <dir> |
make:resource <name> | src/http/resources | -d, --dir <dir> |
make:request <name> | src/http/requests | -d, --dir <dir> |
make:policy <name> | src/policies | -d, --dir <dir> |
make:event <name> | src/events | -d, --dir <dir> |
make:listener <name> | src/listeners | -d, --dir <dir> |
make:job <name> | src/jobs | -d, --dir <dir> |
make:provider <name> | src | -d, --dir <dir> |
./artisan make:model Post -m -f
Scaffolds src/models/post.model.ts, a
database/migrations/<timestamp>_create_posts_table.ts, and
database/factories/post-factory.ts. Note -m/-f always write to the
default directories, ignoring -d.
make:migration derives the table name from a create_{table}_table
name; anything else gets a "..." placeholder. Names are normalised to
StudlyCase with the expected suffix appended if absent, make:factory post and make:factory PostFactory both produce PostFactory.
Seeders
export abstract class Seeder {
constructor(protected app: Application) {}
abstract run(): Promise<void>;
}
import { Seeder } from "@mahiframework/database";
import { User } from "../../src/models/user.model.js";
export class DatabaseSeeder extends Seeder {
async run(): Promise<void> {
await User.factory().times(10).create();
}
}
Register in a provider:
export class AppServiceProvider extends ServiceProvider {
seeders() {
return [DatabaseSeeder];
}
}
db:seed instantiates each with the Application and awaits run().
Seeders run in provider registration order, then declaration order within
each provider's array. There's no dependency graph, order your array so
parents come before children.
Composing seeders is a plain instantiation:
export class DatabaseSeeder extends Seeder {
async run(): Promise<void> {
await new UserSeeder(this.app).run();
await new PostSeeder(this.app).run();
}
}
Keep each seeder focused on one table or feature. For a large demo dataset, a dedicated console command is often clearer than a seeder. See Console.
Factories
A Factory generates realistic model instances on demand. Seeders and
tests are the audience.
import { Factory } from "@mahiframework/database";
import { Post, type PostAttributes } from "../../src/models/post.model.js";
export class PostFactory extends Factory<typeof Post> {
protected model = Post;
protected definition(): Partial<PostAttributes> {
return {
user_id: "",
parent_id: null,
body: `Post ${Math.random().toString(36).slice(2, 8)}`,
deleted_at: null,
};
}
}
Two required members: protected model and protected definition().
definition() is synchronous and returns a model-shape row, the
same shape the instance accessors deal in, which is what its type
(Partial<Post>) says. It goes through forceFill(), so each column's
cast is applied on the way in: write published: true and
meta: { … }, not 1 and a JSON string.
(Casts are idempotent, so a DB-shape value still works if you have one
in hand. forceFill rather than fill means fillable/guarded are
deliberately bypassed. A factory is trusted fixture code and must be
able to set a guarded id.)
Note what's omitted: id (filled by the model's keyType key strategy on
insert) and the timestamps (stamped automatically). Only include them if
you need a specific value.
No faker library is bundled. definition() is a plain function
returning a row; add @faker-js/faker yourself if you want it.
Wiring it to the model
export class Post extends Model<PostAttributes>()({
table: "posts",
primaryKey: "id",
}) {
static override factory(): PostFactory {
return new PostFactory();
}
}
await Post.factory().times(10).create();
The override's return type is the declaration, createOne() narrows
to Post, create() to Post[], with no companion marker to keep in
sync.
This creates a model → factory → model import cycle, which is safe here:
the factory is referenced only inside factory()'s body (call time), and
protected model = Post is an instance field initializer (construction
time). Neither side touches the other during module evaluation.
A static field would not be safe. static Factory = PostFactory
evaluates at class-definition time and hits a TDZ ReferenceError when
the factory module is the entry into the cycle. This is exactly why
factory() is an overridable method rather than a static property.
Without an override, Model.factory() throws:
Post has no factory — override "static factory()" to return a Factory instance.
The API
| Method | Returns | Notes |
|---|---|---|
times(n) | this | Ignored by makeOne/createOne. |
state(partial | resolver) | this | Composes in call order. |
afterMaking(callback) | this | Stacks. |
afterCreating(callback) | this | Stacks. |
make(overrides?) | Promise<M[]> | In-memory. Always an array. |
makeOne(overrides?) | Promise<M> | One instance, ignores times(). |
create(overrides?) | Promise<M[]> | Build + insert. Always an array. |
createOne(overrides?) | Promise<M> | One row, ignores times(). |
createQuietly(overrides?) | Promise<M[]> | create() with events suppressed. |
createOneQuietly(overrides?) | Promise<M> |
make() and create() always return an array even for times(1). Use
the *One variants when you want a single instance.
Attribute precedence
definition() → each state() in call order → the overrides argument
The overrides argument always wins.
States
export class PostFactory extends Factory<typeof Post> {
protected model = Post;
protected definition(): PostTable { /* ... */ }
deleted(): this {
return this.state({ deleted_at: DateTime.now("UTC").toISOString() });
}
replyTo(parent: Post): this {
return this.state({ parent_id: parent.id, user_id: parent.user_id });
}
}
await Post.factory().deleted().createOne();
A resolver form receives the attributes built so far:
this.state((attributes) => ({ slug: Str.slug(attributes.title) }));
Lifecycle callbacks
await Post.factory()
.afterMaking((post) => { post.body = post.body.trim(); })
.afterCreating(async (post) => {
await PostHashtag.create({ post_id: post.id, hashtag_id: tagId });
})
.createOne();
afterMaking runs on every instance right after it's built, including
for make()/makeOne(), before any DB write. afterCreating runs
after insert.
Both may be sync or async, and both stack rather than replacing.
Insert behaviour
insertRows() mirrors Model.create() exactly, so factory rows aren't a
special case:
- Stamp timestamps (unless already supplied).
- Fire
savingper row. assignGeneratedPrimaryKey(): run the model's key strategy if the key is client-generated and still empty.- Fire
creatingper row. - Insert.
markPersisted():exists = true,wasRecentlyCreated = true, and the dirty snapshot synced, so a factory-made model reports itself exactly as acreate()d one does.- Fire
createdthensavedper row, then runafterCreating.
The insert strategy depends on the model's keyType:
- a client-generated key,
keyType: "uuid",snowflake(), or any customKeyStrategy(the common factory case), one batch insert for the whole set.times(50).create()is one round trip. keyType: "increment"(the default, DB-generated): row by row. Kysely'sInsertResult.insertIdonly reports the last row's generated id for a multi-rowVALUES, so there's no way to read back every key from a batched insert. Correctness wins over the batching guarantee.
If you're generating thousands of rows, a client-generated primary key (UUID or Snowflake) makes seeding dramatically faster.
createQuietly()
await Post.factory().times(50).createQuietly();
Wraps in Model.withoutEvents(), so no observers, no on() listeners, no
dispatchesEvents, no generic lifecycle events.
Rows still get their timestamps and their generated keys, suppression
covers event dispatch only. afterMaking/afterCreating still run;
they're Factory's own hooks, not model lifecycle events.
Use it when seeding a large dataset whose observers would queue jobs, send notifications, or hit a search index.
The pre-computed hash trick
The generated UserFactory does this, and you should copy the pattern:
export const TEST_PASSWORD = "password";
export const TEST_PASSWORD_HASH =
"$argon2id$v=19$m=65536,t=3,p=4$pZO4pLrdNfk2w2KmDzQeSw$EHiOq+hyokw8u+vyKYGeCjxNq2GXIsdKdm7bS9PpRzM";
export class UserFactory extends Factory<typeof User> {
protected model = User;
protected definition(): UserTable {
const token = randomUUID();
return {
name: `User ${token.slice(0, 8)}`,
email: `user-${token}@example.com`,
password: TEST_PASSWORD_HASH,
deleted_at: null,
} as UserTable;
}
}
Two reasons:
definition()is synchronous.Hash.make()is async. You cannot await it here.- argon2 is deliberately slow. ~100ms per hash by design. A test creating 10 users would pay a full second of pure hashing for a value nothing asserts on.
The constant is the argon2 hash of "password". Tests that need to log in
import TEST_PASSWORD and post that:
const user = await User.factory().createOne();
await client.post("/auth/login", { email: user.email, password: TEST_PASSWORD });
When a test needs a different password, hash it in the test (where
await is available) and pass it as an override:
const user = await User.factory().createOne({ password: await Hash.make("hunter2") });
The same trick applies to any expensive-but-uninteresting default: compute it once as a module constant, override when a test cares.
See Testing for the wider testing story.