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

The framework's own models are compared alongside yours, and a model of yours that extends one of them is merged into it first — so the two are one table with one set of columns, rather than two passes taking turns adding and dropping.

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\Database;
use Framework\Database\Migration\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\Database;
use Framework\Database\Migration\DataMigration;

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.

A step for after the deploy

A deploy usually migrates the database first and swaps the code afterwards, so for a while the old code keeps writing to a schema that is already the new one. A migration that fills a new column from existing data fills what exists at that moment — the rows the old code writes in between are left empty, and the new code never goes back for them.

A migration that needs to close that gap also implements DeployMigration, which adds a postDeploy() to the same file. The migrate() runs as before, and the second step runs once the code is live, with a command of its own:

./framework postDeploy
use Framework\Database\Database;
use Framework\Database\Migration\DataMigration;
use Framework\Database\Migration\DeployMigration;

class M20260805T143022 implements DataMigration, DeployMigration {
    public static function getTitle(): string {
        return "Store the full name of the users";
    }

    public static function migrate(Database $db): void {
        $db->execute("UPDATE user SET fullName = CONCAT(firstName, ' ', lastName)");
    }

    public static function postDeploy(Database $db): void {
        // Only the users the old code wrote after the migrate are still empty
        $db->execute("UPDATE user SET fullName = CONCAT(firstName, ' ', lastName) WHERE fullName = ''");
    }
}

The command runs the postDeploy() of every migration that already ran and was not deployed yet, in name order, and records the time in the tracking table. A migration that did not run yet waits for its migrate, and one without the interface is never picked up. Write the step so it only touches what is still missing: then it is safe to run again, and a deploy that swaps the code first can run both commands back to back.

A change with nothing to do before the code implements DeployMigration alone, and the whole of it runs on the post deploy.