Migrations
Migrations keep the schema in sync with your models and apply the structural changes an evolving database needs. They are configured in a config file and run from the CLI.
Running migrations
./framework migrate
A single command runs the whole pipeline below in order. Data migrations are recorded by name in a tracking table once they run, so every one is applied exactly once.
How a migration runs
Each migrate executes four phases, in this order:
1. Schema sync from the models
The schema models are the source of truth. Each is compared to the database:
missing tables are created, changed ones are updated (new columns and keys
added), and tables or columns no longer backed by a model are deleted. Destructive drops are
only printed as manual steps unless you opt in with the --canDelete flag, which also
removes columns:
./framework migrate --canDelete
2. Table & column renames
As part of that same schema step — and before the sync compares models to the database — the renames from your config files are applied. This preserves the existing data: the table or column is renamed to match your model instead of being dropped and recreated.
3. Discovery migrations
Next, every class implementing DiscoveryMigration runs its migrateData(). The
framework ships a few that keep its own tables in sync with your config and code:
SettingData— syncs the settings table with your registered settings.EmailContent— loads the translated email copy into its table.NotificationContent— loads the notification copy.
After the framework's, your app's own DiscoveryMigration classes run — none by default,
so this phase is skipped until you add one.
4. Data migrations
Finally the dated data-migration files in your folder run — the ones for moving or
transforming rows. Each is applied once and recorded by name, so re-running migrate only picks up
the new ones.
The tracking table
MigrationData owns the table that records which
data migrations have run. It is a
model like any other, holding one row per applied migration name:
use Framework\Core\MigrationData;
MigrationData::getAppliedNames(); // the migrations already run
Before running anything, the runner compares the files in your
migrations folder against that list and applies only the difference — which is what makes
migrate safe to run repeatedly. setLastApplied() pre-fills the
table up to a point, for a database that was migrated by hand before it adopted the framework.
Adding a Discovery migration
A DiscoveryMigration runs on every migrate (phase 3 above) — reach for it
to keep a table in sync with your code or config, rather than for a one-off change. Implement the interface's
single migrateData() method; it is discovered and wired in on the next
build, with no registration:
use Framework\Discovery\Type\DiscoveryMigration;
class RoleData extends RoleSchema implements DiscoveryMigration {
public static function migrateData(): void {
// Runs on every migrate — e.g. sync this table with your config
foreach (RoleConfig::getRoles() as $role) {
// insert, update or delete the row for $role…
}
}
}
Your app's discovery migrations run after the framework's built-in ones
(SettingData, EmailContent and NotificationContent), which sync the
settings and internationalization tables the same way.
Folder & last applied
Point the runner at your migrations folder in the main config file. When adopting migrations on a database that already has data, set the last migration that was applied by hand — everything up to and including it is then skipped:
use Framework\Database\Migration;
Migration::setPath("migrations");
Migration::setLastApplied("2024_01_initial");Renames
Table and column renames live in their own config files — MigrationTables.config.php and
MigrationColumns.config.php — and run before the schema is synced, so the sync matches
your models against the new names instead of dropping and recreating. Table names may be snake_case
or PascalCase, and ID columns SNAKE_CASE:
Migration::renameTable("old_table", "new_table");Migration::renameColumn("TableName", "oldColumn", "newColumn");Creating a data migration
For a one-off change to your data — backfilling a column, moving rows — scaffold a dated migration file. Pass a title or you are prompted for one, and the new file opens for editing:
./framework migration "Backfill user slugs"
The file lands under your migrations folder, grouped into year/month
sub-directories and named after the timestamp, so migrations from different branches never collide:
config/migrations/ # the default, set with Migration::setPath()
└── 2026/
└── 08/
└── 2026-08-05-143022.php
Each file holds a class implementing DataMigration: a getTitle() and a
migrate() that receives the Database. The class name matches the
timestamp so it is unique too — fill in the body with your changes:
use Framework\Database\DataMigration;
use Framework\Database\Database;
class M20260805T143022 implements DataMigration {
public static function getTitle(): string {
return "Backfill user slugs";
}
public static function migrate(Database $db): void {
// Move or transform rows here…
}
}
When migrate runs, each file is applied once and its name stored in the Migrations
table. Pending files are found by comparing the folder against that table, so an applied migration is never run
again — and setLastApplied() pre-marks everything up to a point when adopting the system.