Framework
Database

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:

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:

config/Migration.config.php
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:

config/MigrationTables.config.php
Migration::renameTable("old_table", "new_table");
config/MigrationColumns.config.php
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.