Database & Migrations
Last updated: 04/17/2026 · Written by Agent0
Database & Migrations
StackCTL uses a straightforward file-based migration system. Each migration is a plain PHP file with an up function (to apply the change) and a down function (to roll it back). The Stack CLI tracks which migrations have run and ensures they only ever run once.
For querying the database inside controllers, see the Query doc. This doc covers the migration system specifically.
Migration Files
Migration files live in build/database/ and are numbered sequentially so they always run in the correct order.
build/database/ ├── 001_create_users.php ├── 002_create_password_resets.php ├── 003_create_email_verifications.php ├── 004_create_mfa_methods.php ├── 005_create_mfa_challenges.php ├── 006_trusted_browsers.php └── 007_create_articles.php ← your migrations go here
The framework ships with migrations 001–006 for the built-in auth system. Your own migrations start from 007 onwards.
Creating a Migration
Use the Stack CLI to generate a new migration file with the correct number prefix:
php stack db:create create_articles
This creates build/database/007_create_articles.php with a ready-to-fill template:
<?php
return [
'up' => function ($db) {
$db->exec("
CREATE TABLE articles (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
-- Add your columns here
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
},
'down' => function ($db) {
$db->exec("DROP TABLE IF EXISTS articles");
}
];
Fill in your columns in the up function, and make sure down cleanly reverses whatever up does.
A Real Migration Example
Here's a complete, realistic migration for an articles table with foreign keys and common column patterns:
<?php
return [
'up' => function ($db) {
$db->exec("
CREATE TABLE articles (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
-- Core content
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
content LONGTEXT NULL,
status ENUM('draft', 'published') DEFAULT 'draft',
-- Ownership
created_by BIGINT UNSIGNED NULL,
-- Timestamps
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Foreign key
CONSTRAINT fk_articles_created_by
FOREIGN KEY (created_by)
REFERENCES users(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
},
'down' => function ($db) {
$db->exec("DROP TABLE IF EXISTS articles");
}
];
Adding Columns to Existing Tables
Never edit an already-applied migration to add columns — create a new migration instead. This keeps the history intact and ensures the change can be applied cleanly on other environments.
php stack db:create add_excerpt_to_articles
<?php
return [
'up' => function ($db) {
$db->exec("
ALTER TABLE articles
ADD COLUMN excerpt VARCHAR(500) NULL AFTER title;
");
},
'down' => function ($db) {
$db->exec("
ALTER TABLE articles
DROP COLUMN excerpt;
");
}
];
The Migration Workflow
db:apply — Run pending migrations
Runs all migration files that haven't been applied yet, in sequential order. Safe to run multiple times — already-applied migrations are tracked in the schema_migrations table and skipped.
php stack db:apply
db:status — Check what's been run
Displays every migration file and whether it has been applied (✔) or is still pending (○).
php stack db:status // Output: // [✔] 001_create_users.php // [✔] 002_create_password_resets.php // [○] 007_create_articles.php
db:rollback — Undo a specific migration
Runs the down function of a specific migration and removes it from the applied log. Useful during development when you need to tweak a table you just created.
php stack db:rollback 007_create_articles.php
After rolling back, edit the migration file and run db:apply again to re-apply it.
db:describe — Inspect a table
Prints the column structure of any table — a quick way to check a table without opening a database client.
php stack db:describe articles // Output: // Field Type Null Key Default // id bigint(20) NO PRI // title varchar(255) NO // slug varchar(255) NO UNI // status enum(...) YES draft // created_at datetime YES CURRENT_TIMESTAMP
How Migrations Are Tracked
StackCTL creates a schema_migrations table in your database the first time db:apply runs. Each applied migration's filename is recorded there with a timestamp. You never need to interact with this table directly.
Best Practices
- Always write a
downfunction — Even a simpleDROP TABLE. It makes rollbacks possible during development. - Never edit an applied migration — Create a new one instead. Editing an applied migration won't re-run it and will cause your migration history to drift out of sync.
- Keep migrations small and focused — One table or one change per file. It's easier to roll back a targeted change than a large one.
- Use
utf8mb4— All tables should useENGINE=InnoDB DEFAULT CHARSET=utf8mb4for full Unicode support including emoji. - Run
db:applyafter pulling changes — Get into the habit of running migrations whenever you pull new code from a teammate.