Skip to content

Pramnos Migration Guide

The Migration System provides version control for your database schema. Migrations are PHP files that define schema changes and can be rolled back to any previous state.

Classes: - Pramnos\Database\Migration — Base migration class - Pramnos\Database\MigrationRunner — Execution engine - Pramnos\Database\MigrationLoader — File discovery and loading

Migration Structure

The Migration Base Class

<?php

use Pramnos\Database\Migration;

class CreateUsersTable extends Migration
{
    public string $feature      = 'auth';       // Feature key ('auth', 'queue', ...)
    public string $scope        = 'framework';  // 'app' or 'framework'
    public int    $priority     = 20;           // Lower runs first
    public array  $dependencies = ['create_roles_table']; // Must run before this
    public $description         = 'Creates the users table'; // untyped, as in the base class
    public $autoExecute         = true;         // false = requires --force (untyped)
    public bool   $transactional = false;       // true = wrap in BEGIN/COMMIT on PostgreSQL
    public bool   $conditional   = false;       // true = creates nothing on some engines

    public function up(): void
    {
        $this->schema()->createTable('users', function ($table) {
            $table->bigIncrements('userid');
            $table->string('username', 100)->unique();
            $table->string('email', 255)->unique();
            $table->string('password_hash', 255);
            $table->boolean('active')->default(true);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        $this->schema()->dropIfExists('users');
    }
}

Migration Metadata Fields

Property Type Default Purpose
$feature string '' Feature key; empty = app migration
$scope string 'app' 'app' or 'framework'. Every migration under database/migrations/framework/ declares 'framework', and a test fails if one does not
$priority int 50 Lower number runs first
$dependencies array [] Slugs of migrations that must run before this one
$description untyped '' Human-readable description. Redeclare it without a type — a typed redeclaration of an untyped parent property is a fatal error
$autoExecute untyped true false = runs only with --force. Redeclare it without a type, for the same reason
$transactional bool false Wrap up() in BEGIN/COMMIT/ROLLBACK on PostgreSQL
$conditional bool false This migration legitimately creates nothing on some engines — see below

$conditional — a migration that correctly creates nothing

A few migrations are conditional by design. pramnos.framework_policies exists on MySQL and plain PostgreSQL and must not exist on TimescaleDB, which manages its own policies: the migration runs, records itself applied, and creates nothing — correctly.

From the outside that is indistinguishable from a migration whose table somebody dropped by hand: the history says applied and the table is not there. That is the most alarming thing a drift check can report, and reporting it about a migration behaving exactly as designed is how a check stops being read. So the migration says so, and schema-drift lists it apart.

It is declared, not detected: "does this return depend on the engine" is not a question to answer by pattern-matching somebody's source.

Protected Helpers

Migration subclasses have access to:

// Get the database connection
$this->DB();

// Get a SchemaBuilder bound to the current connection
$this->schema();

// Schema-qualified builder (PostgreSQL)
$this->schema('public')->createTable('users', fn($t) => ...);
$this->schema('analytics')->createTable('events', fn($t) => ...);

Slug and Timestamp Derivation

getSlug() and getTimestamp() check the migration file's basename first:

File:  app/Migrations/2024_03_15_143022_create_users_table.php
Class: CreateUsersTable

getSlug()      → 'create_users_table'   (from filename)
getTimestamp() → '2024_03_15_143022'    (from filename)

For a file without a timestamp prefix, the class name is used.

Running Migrations

Manual Execution

# Run all pending migrations
php vendor/bin/pramnos migrate

# Rollback the last batch
php vendor/bin/pramnos migrate:rollback

# Rollback all migrations
php vendor/bin/pramnos migrate:reset

# Run a single migration, by slug or class name
php vendor/bin/pramnos migrate create_users_table

The CLI and the request lifecycle answer the same question

migrate, migrate:status, the MCP status tools and the dev panel's migration card all read the same answer to "which migrations apply to this installation" as auto-run does: Application::migrationScope(), reached through MigrationLoader::scopeFor($app). Two filters come from app.php, and both apply on every one of those paths:

  • features gates the framework's per-feature migration directories. A feature the application has not enabled contributes no migrations.
  • migration_cutoff excludes every migration whose filename timestamp is at or before it. In app.php it is a migration timestamp ('2020_01_02_000000') or a date-time ('2020-01-02 00:00:00'); a value that is neither leaves no cutoff. A migration with no timestamp in its name is never excluded by a cutoff, on either path.

This matters most on an installation whose schema predates the migration system. Such an installation sets a cutoff precisely so the baseline epoch is skipped — and a reader that ignored it would report the whole epoch as pending work and, worse, attempt it.

Overrides. --path and --cutoff still win, because running one directory or one epoch deliberately is what they are for. Note that they are independent: --path names where to look and does not switch the cutoff off, so migrate --path=app/Migrations on an installation with a cutoff still respects it. Pass --cutoff to change that.

What migrate:status shows. Out-of-scope migrations are listed, not omitted, with the reason in the Status column:

 create_sessions_table            | framework | core         | Skipped (cutoff)
 create_broadcast_events_table    | framework | broadcasting | Skipped (feature: broadcasting)

followed by a count and, when one is in force, the cutoff that decided it. The report is the only place to find out why something is never going to run, so a row that is out of scope says so rather than disappearing — a CLI that hides pending migrations is as unhelpful as one that invents them. --path applies no feature gate, because the directory was named explicitly.

Without a database connection, migrate:status says so and still lists what it found on disk. The console reaches an application without initialising it, so that is an ordinary state for the command; what is on disk does not depend on a connection, and only the Ran/Pending distinction does.

Automatic Migration (Application Startup)

Pending autoExecute = true migrations run automatically during the request lifecycle (Application::exec()), guarded by a fingerprint fast-path so an up-to-date schema costs a single indexed lookup. By default auto-run scans the framework's per-feature migration directories (gated by the enabled features), which is all a standard app needs.

An application can extend or narrow this through its app.php descriptor:

// app.php
return [
    'namespace' => 'MyApp',
    'features'  => [],           // gates which framework feature dirs run

    // Auto-run configuration (all keys optional):
    'migrations' => [
        // Application-owned migration directories, scanned IN ADDITION to the
        // framework dirs (absolute paths). An app baseline placed here auto-runs
        // on the same fingerprint fast-path as framework migrations.
        'paths'     => [__DIR__ . '/Migrations'],

        // Set false to skip the framework feature directories entirely. Use this
        // when the app manages a schema that collides with a framework table
        // (e.g. its own `sessions` layout) — only the app's own `paths` run.
        // Defaults to true (unchanged behaviour for existing apps).
        'framework' => false,

        // Framework migrations this installation never runs, by slug — for a table the
        // application manages itself. See "Leaving a framework table alone" below.
        'exclude'   => ['create_mailtemplates_table'],
    ],

    // Skip every migration at or before this timestamp. Set it to the baseline
    // timestamp so auto-run only applies genuinely new, post-baseline migrations
    // (the baseline itself is applied once by the explicit `migrate` command).
    'migration_cutoff' => '2025-01-01 00:00:01',
];

Leaving a framework table alone: migrations.exclude

When an application manages one framework table itself — its own mailtemplates layout, a usertokens table it shares with another writer — it names the migrations to leave alone:

'migrations' => [
    'exclude' => ['create_mailtemplates_table'],
],
migration_cutoff 'framework' => false exclude
Skips every migration up to a date every framework migration the slugs listed
Typical use a schema that predates the migration system an application that owns the whole schema one table the application owns

The slug is the file name without its timestamp, as migrate:status prints it. Matching is case-insensitive.

Every path obeys it: migrate, the per-request auto-run, migrate:status, the DevPanel's migration card and the MCP status tools. An excluded migration is never run, never recorded in the ledger and never counted as pending. Take a slug off the list and it is pending again, with nothing to repair.

An excluded migration counts as satisfied for its dependents. Excluding it is the application saying the table exists and is its own, so a migration that depends on it still runs, and the dependency pool does not pull it back in. If the table does not actually exist, those dependents fail; that is the application's side of the promise.

Excluding the migration that creates a table does not exclude the ones that alter it. migrate:status names them:

usertokens is excluded (create_usertokens_table), but add_token_lookup_to_usertokens,
add_oidc_context_to_usertokens still change it. Exclude them too if the table is yours.

This is read from each migration's schema-builder calls (createTable, alterTable, table, dropTableIfExists) with a literal table name, so it is advice: a change made in raw SQL is not seen.

A slug that matches no migration is reported by migrate and migrate:status, because a typo excludes nothing. A slug belonging to a feature this installation has switched off is not reported, since that migration exists. migrate <slug> for an excluded migration refuses and says why, instead of printing Nothing to migrate.

What the fingerprint covers

The fast path is a single indexed lookup for a key like __fw_auto_55_2026_09_20_000001_1f3a9c02b4de, recorded in the ledger once a request has found everything applied. Its four parts are the number of eligible migration files, the latest timestamp among them, a digest of the whole set, and the migration_cutoff when one is configured.

The digest is what makes it correct; the count and the timestamp are kept because this string ends up as a row somebody eventually reads. A count alone misses a deploy that removes one migration and adds another, and a latest timestamp misses anything added behind it — so the set on disk and the set that ran could differ while the key stayed the same, and the fast path would go on answering "up to date" indefinitely.

Every *.php file in the scanned directories counts, not only YYYY_MM_DD_HHmmss_*.php ones. This matters for an application on the MigrationNNNN convention: counting only timestamped files, adding Migration0151.php would change nothing in the key, every request would take the fast path, and the migration would stay pending. A directory holding only untimestamped migrations would produce an empty set and skip the check entirely.

Deciding this from filenames is the point — the check loads no PHP, which is what lets it run on every request. For an untimestamped file that means the slug is taken from the filename where it properly comes from the class short name. The two agree whenever the file is named after the class it declares, and where they do not, the derived slug fails to match the ledger and reads as pending: that costs one request the full load, after which the fingerprint records and the fast path resumes. Wrong in the direction of more checking, not less.

Applying migrations by hand: migrations.auto

An installation that applies its migrations during a watched deploy window — a large index build, anything somebody wants to see happen — says so with one key:

// app.php
'migrations' => [
    'auto' => false,        // do not migrate on your own
],

«Do not run by yourself» is not «do not run at all». With auto => false:

Path Runs?
exec()'s checkversion() / upgrade() pair no
runAutoMigrations(), and migrate() which reaches it no
pramnos migrate, migrate:status yes, and they see everything
Application::runPendingMigrations() yes, both systems
Application::upgrade() called directly yes
// a deploy script, in the window
$app = \Pramnos\Application\Application::getInstance();
$app->runPendingMigrations();   // legacy ledger + framework migrations

runPendingMigrations() is the counterpart to migrate(): that one is the automatic path and stands down, this one is somebody asking. It runs both systems, because an installation that switched the automatic run off has two halves to catch up and no reason to have to remember that they are two. It also lets a failure out, where migrate() swallows it — a migration failing in a watched window is what the operator is standing there to see.

What "lets a failure out" means precisely: if any migration fails, it throws a RuntimeException naming each failed slug and the first line of its error, and it records no fingerprint, so the next call tries again. A failure stops whatever depends on the failed migration, so on an empty database the run can end far short of the full schema, and the exception is the only thing that says so. The test bootstrap (TestEnvironment) runs migrations this way and prints the message to STDERR.

The automatic path in a request does not throw. It logs the same message and keeps the fingerprint, so a migration that fails is not attempted again on every request. pramnos migrate retries it, because only successful migrations count as run.

migrations.framework is not this switch, and it is the one that looks like it. That key answers which directories are in scope, and migrationScope() is read by the CLI as well as by auto-run — so turning it off to stop the automatic run also empties the pramnos migrate it was turned off in order to perform.

Where the gate goes, and why it is not inside upgrade()

The gate sits on the automatic call site in exec(), not inside upgrade(). Putting it inside the method turns the switch into «never run», and that breaks every bootstrap calling upgrade() on purpose. MigrationsAutoKeyTest pins the distinction from both directions, and its probe deliberately does not override upgrade(), so it cannot pass against a framework gated in the wrong place.

Standalone auto-migration (Application::migrate())

Applications that do not go through the full init()/exec() request lifecycle — for example a front controller using attribute routing, or a console bootstrap — can still get auto-migration on every execution by calling the public migrate() method:

$app = \Pramnos\Application\Application::getInstance(); // resolves app.php
$app->migrate(); // connects the DB if needed, then runs pending migrations

migrate() connects the database if it is not already wired (without starting a session, booting addons or running session tracking), honours the same app.php migrations/migration_cutoff/features config as the lifecycle path, and never throws — auto-migration is best-effort infrastructure, so failures are logged rather than allowed to take down a request or a command.

Depending on a framework migration you don't otherwise run

An application that opts out of the framework directories ('framework' => false) — or simply doesn't enable a feature — can still pull in individual framework migrations on demand, by declaring them as $dependencies:

// app/Migrations/2026_03_01_000001_use_delayed_jobs.php
class UseDelayedJobs extends \Pramnos\Database\Migration
{
    // Pull in the framework's delayed_jobs table before this migration runs,
    // even though this app does not run the framework migrations wholesale.
    public array $dependencies = ['create_delayed_jobs_table'];

    public function up(): void { /* ... uses the delayed_jobs table ... */ }
}

When a declared dependency is neither in the current batch nor already applied, the MigrationRunner resolves it from an on-demand pool of all framework migrations (Application::frameworkMigrationPool() — not feature-gated, keyed by slug), pulls it in transitively (its own dependencies too), and orders it before its dependent. Only the migrations actually depended upon are executed — so you adopt exactly the framework table/feature you need, and nothing else. A slug that resolves from neither the batch nor the pool still fails with "unknown dependency", so genuine misconfiguration is not masked. The pool is loaded lazily, only when a missing dependency is actually encountered, so apps with no cross dependencies pay nothing.

This works on both paths: the standalone/auto-run (Application::migrate()) and the explicit migrate console command wire the same pool.

An adopter can be empty, and that is the point. A migration whose entire body is a $dependencies list is a legitimate and supported way to say «this application needs that framework migration, and nothing else»:

// app/Migrations/2026_09_05_000001_adopt_delayed_jobs.php
class AdoptDelayedJobs extends \Pramnos\Database\Migration
{
    public array $dependencies = ['create_delayed_jobs_table'];

    // No up(). The base class's is a no-op, and there is nothing to add:
    // the dependency does the work and is ordered before this.
}

The runner pulls the dependency from the pool, runs it first, and records both — so neither runs again, and the adopter is a permanent record of why that framework table exists in an application that does not run the framework's migrations wholesale. Verified by LegacyLedgerAdoptionTest::testAnEmptyMigrationCanAdoptAFrameworkMigrationByDependency().

A ledger an application made itself

The legacy Application::runMigration() path writes only key, and the table it writes to was created by each application's own installer. So an installation that came from it can have a schemaversion with key and nothing else: no when, no extra, no unique index. The runner completes it on its first run: - it adds every column it records, when and extra included; - it adds a unique index on key when there is none, because PostgreSQL's ON CONFLICT refuses to run without one.

A ledger whose keys already repeat cannot take that index. On MySQL, where the upsert works without it, the ledger is left as it is and migrations still run. On PostgreSQL the repeats have to be removed first:

DELETE FROM schemaversion a USING schemaversion b
 WHERE a.ctid < b.ctid AND a."key" = b."key";

The ledger's name, on an installation with a prefix

schemaversion is resolved through the installation's table prefix, so on a site configured with pramnos_ the ledger is pramnos_schemaversion — the same name the Application::runMigration() path writes. Every statement the runner issues — the raw ones and hasColumn() alike — resolves the same name, so the two never address different tables.

A bare schemaversion beside a prefixed one needs nothing from you. The runner renames a bare ledger into the prefixed name when there is no prefixed one yet — an installation whose history is in the bare table keeps it rather than replaying every migration — and leaves a real prefixed ledger untouched when a stray empty bare table sits beside it. Dropping the stray is then safe, and nothing will recreate it.

Coming from the legacy version ledger

Both migration systems write to schemaversion and key it differently. The legacy path — Application::runMigration() — stores a migration's $version (0.010); the runner stores its slug (migration0010). An application that migrated for years through the old path therefore has a full ledger that the runner cannot read a single row of: migrate:status reports every one of those migrations as pending, and migrate would run them again against a schema that already has their changes.

migration_cutoff does not help, and it is worth knowing why: it filters on the filename timestamp, and a legacy MigrationNNNN class has none — filterCutoff() lets a migration with no timestamp through by design.

migrate does this on its own, before deciding anything is pending. You do not have to know the command below exists — the person who needs it is the person who has just upgraded and does not yet know anything is wrong.

A replay of already-applied migrations is not harmless. Re-running old DDL mostly fails on arrival and is recorded as a success with failed statements; the dangerous ones are the idempotent ones, because they succeed — re-adding, say, a policy a later migration removed, so the recorded state and the real state disagree with nothing to indicate it.

The command is still there, for looking before running:

php pramnos migrate:adopt-legacy --dry-run   # what would be recorded
php pramnos migrate:adopt-legacy             # record it

For each of the application's migrations it looks up the $version the object declares, and where the ledger already holds that version it records the object's slug. It records; it never executes — the work was done by the other path years ago, and running it again is the outcome this exists to prevent.

Two things it will not do, both deliberate:

  • A migration the ledger has never heard of stays pending. The only evidence that it ran would be this inventing it, and a migration wrongly marked as applied is a schema change that silently never happens.
  • A slug the runner already knows is left alone, so running it twice is a no-op.

A migration with no $version — every modern, timestamped one — is skipped, so pointing this at a mixed directory is safe.

The schema version an application reports

A slug sorts by the day somebody wrote the file. It is not a number a footer can print, and applicationInfo['database_version'] — the settings key the legacy path checked — is a declaration: the version the code expects, correct only while somebody keeps editing it by hand. The two drift on the first deploy that forgets to, and then the site reports a schema version several migrations behind its schema.

So the ledger stores the version as well, and it can be read back:

$applied = $this->application->databaseVersion();   // '0.150', or null

Application::databaseVersion() returns the highest $version among the migrations the ledger records as successfully applied, for the app scope by default — pass 'framework' to ask about the framework's own, which are numbered independently and must not be folded in. MigrationRunner::latestVersion() is the same answer without an Application.

Both conventions are read. The legacy path writes the version as the row's key and nothing else — INSERT INTO schemaversion (key) VALUES ('0.147') — so an installation that has never run this runner has its whole version history there and nowhere else. A row with no version is therefore read through its key, and the answer is the highest of both sets.

A key counts as a version only when it is digits and dots, with at least one dot: 0.147, 2.0.1. That is deliberately narrower than "looks like a version". Every slug this runner writes carries a letter or an underscore — 2026_09_28_000001_add_thing, migration0148, __fw_auto_3_… — so the two sets cannot overlap by accident, and a bare 2026 stays out because it is a number rather than a version.

Two exclusions:

  • Anything but a success. A migration recorded as having run with errors is retried on the next run, so it has not been applied; reporting its version would name a schema whose columns may not exist.
  • String ordering. Versions are compared with version_compare(), because 0.100 is above 0.099 and a string comparison — an SQL ORDER BY included — puts it below.

null means neither source holds a version: a fresh install, or an application whose migrations are all timestamped files that declare none. It is deliberately not a '0', because a wrong number in a footer reads like an answer.

Adopted rows carry their version too — adoption goes through the same recording path — so an installation coming off the legacy ledger keeps reporting the number it has been reporting for years.

Rows recorded before the column existed are filled in. recordHistory() writes the version of a migration it is recording now, and a migration that has already run is excluded from every later run — so adding the column would otherwise leave an entire existing history at null, on exactly the installations that have a version worth reporting. adoptLegacyVersions() therefore also supplies the version of a slug the ledger already holds without one, and since migrate calls it before deciding anything is pending, one php pramnos migrate after upgrading is enough even when nothing is pending. It annotates the row; it does not re-run the migration.

The migration object is the only place that number exists — the row holds a slug, and nothing maps one to the other — which is why this happens where the objects are in hand and not as a migration of the ledger itself.

This does not write applicationInfo['database_version']. That key is the gate checkversion() opens, and it only opens when the key is set; filling it from the ledger would switch the legacy upgrade() path back on for every installation that had switched it off by leaving the key alone. An application that wants the value in its own settings array can assign it — knowing that it is asking for the legacy path as well.

A whole history is refused on a database that is not new

Adoption handles the case where the history is somewhere else in the same table. It cannot handle a history that is not there at all — moved to another engine, replaced by a dump, or never kept. So there is a net under it.

migrate refuses to run ten or more migrations when the ledger is empty and the database already has tables. A runner that finds none of its migrations recorded on a database that has tables is not looking at a fresh database. The cost of being wrong in that direction is a message; in the other direction it is a whole history replayed against live data.

Three ways past it, in the order worth trying:

when what it does
migrate:adopt-legacy --dry-run the old history is version-keyed shows what would be recorded; migrate already does this automatically, so an empty ledger here means there was nothing to match
migration_cutoff in settings these migrations predate this database skips them by timestamp, permanently
migrate --adopt-baseline this database really should have every one applied runs them

--adopt-baseline is its own option rather than a second meaning for --force, which means include autoExecute = false migrations. An operator reaching for one must not silently get the other, least of all this one.

The same refusal applies to the automatic path. Application::exec() → checkversion() → upgrade() keys the ledger by version and runs inside a web request, where a history of heavy migrations — a hypertable decompression, say — would run behind maintenance mode while migrate:status, which reads slugs, reports nothing pending. upgrade() applies the same threshold (MigrationRunner::WHOLE_HISTORY, ten), logs, and stops rather than auto-running a history. Below the threshold it runs normally — deploying an application with one or two new migrations needs no shell.

Check the data before you change it

A statement that validates existing rows — ADD CONSTRAINT, CREATE UNIQUE INDEX — succeeds on a fresh database and fails on the one the migration was written for. A database that has run without a foreign key is exactly where a deleted parent left a child row behind; a table that predates a unique index is exactly where two rows share a value, because that is why the index is being added.

Failing there is bad enough. Failing halfway is worse: a migration that drops the old index before creating the new one leaves the installation with neither, on a column every read uses.

So check first, and decline when the data is not ready.

public function up(): void
{
    $duplicates = $this->duplicateGroups('#PREFIX#settings', 'setting');

    if ($duplicates !== []) {
        $named = [];
        foreach ($duplicates as $group) {
            $named[] = "'" . $group['value'] . "' (" . $group['rows'] . ' rows)';
        }

        $this->decline(
            'settings.setting has duplicate values, so a unique index cannot be created: '
            . implode(', ', $named)
            . '. Decide which row is correct, delete the others, and run migrate again.'
        );

        return;                 // nothing was changed
    }

    // … safe to proceed
}

What the base class gives you

Method Answers
orphanCount($table, $column, $refTable, $refColumn) how many rows would violate a foreign key
duplicateGroups($table, $column, $limit = 5) which values repeat, and how often
duplicateCount($table, $column) how many values repeat
decline($reason) refuse, and record why

NULL is not a violation in either: a nullable foreign key means «no parent» and a unique index accepts any number of NULLs, so counting them would decline a migration that would have worked.

A count that cannot be taken is zero, not a refusal. A missing table, a permission, a view standing in for a table — declining on any of those would make an unrelated fault look like dirty data and send an operator hunting rows that do not exist. The statement itself is the check of last resort.

A decline is loud, and it comes back

This is the part that separates a guard from a silence. decline() records RESULT_DECLINED with the reason in error_message, so:

  • migrate prints Declined: with the reason and a closing line saying these stay pending;
  • migrate:status shows Declined in the Status column and prints the reason under the table;
  • and because getRanSlugs() counts only RESULT_OK, the migration is attempted again by the next migrate — repair the rows and it applies itself.

Declined is not the same as the Skipped (cutoff) or Skipped (feature: …) that migrate:status computes from scope. Those never ran. This one ran, looked at the data, and said no.

Decline; do not repair. Deleting rows is not a migration's decision to take on an operator's behalf. Two rows for sitename may mean the wrong one has been in effect for months, and an orphaned audit row is a record of something that happened. Name what is wrong and let a person choose.

Backfills: scope them, bound them, and let SQL do what SQL can

A migration that fills a new column from an old one is where deploys die. Three things, in ascending order of what they cost:

  • Say what you do not need. A digest for a token that is revoked or expired is work for an answer nobody asks for, and on a long-lived installation those rows are most of the table.
  • Add the WHERE that makes it re-runnable. Without WHERE new_column IS NULL, the second deploy pays the whole cost again.
  • Do not read the table into PHP. Selecting every row before writing anything is what turns «slow» into «out of memory» — measured at 48 MB for 50 000 rows, so around a gigabyte at a million. Use a keyset cursor on the primary key, in batches, so the buffer stays flat and a table changing underneath cannot make an offset walk skip rows.

And when the value needs no PHP, one statement does it:

// 130 ms where row-by-row PHP took 5 617 ms, on 50 000 rows
$digest = $caps->isPostgreSQL()
    ? "encode(sha256(token::bytea), 'hex')"
    : 'LOWER(SHA2(token, 256))';

Whatever expression you use, assert in a test that it equals what PHP computes. A digest the database writes and the application does not match on is an authentication outage, and nothing else in the suite would notice.

Queued statements run when up() returns

addQuery() queues; the runner empties the queue as soon as up() (or down()) returns. So this is complete, and nothing else is needed:

public function up(): void
{
    $this->addQuery('ALTER TABLE articles MODIFY author VARCHAR(500) NULL');
}

Calling executeQueries() yourself still works and is still the way to read the number of rejections at the point they happen. The queue is cleared each time, so the runner's own call finds nothing left — a migration that flushes its own queue does not run twice.

This holds on both paths that run a migration: MigrationRunner, and Application::runMigration() — the legacy loader that upgrade() uses and that exec() reaches on its own when the version check fails.

A recorded no-op cannot be re-run

A migration recorded as run is never run again, so correcting its file helps nobody who already has the row: the repair is a second migration. That is why the queue is flushed for you — a migration that queues statements and returns must not be recorded as applied having changed nothing.

When a statement is refused

addQuery() queues statements and executeQueries() runs them tolerantly: a statement the database rejects does not stop the ones behind it. That is deliberate and has to stay. A re-run of a migration whose ALTER TABLE … ADD COLUMN is already applied must not abandon the eleven statements after it, and installations exist with a hundred-odd numbered migrations relying on exactly that.

What is not deliberate is losing the fact that it happened. If up() returning were the only thing the ledger looked at, a migration whose every statement was rejected would be indistinguishable from one that worked, with the only trace in var/logs/upgradeerrors.log, which nothing points at.

The third state

MigrationRunner records one of these results:

Constant result Meaning
RESULT_OK 1 completed, every statement accepted
RESULT_RAN_WITH_ERRORS 2 up() returned, but statements were rejected
RESULT_FAILED 0 up() threw; the migration did not complete
RESULT_DECLINED 3 the migration checked the data and refused with a reason; it stays pending and the next migrate retries it

migrate:status shows the middle one as Ran with errors and prints what was rejected underneath the table. migrate prints Migrated* for it and lists the statements in its summary. The error_message column carries the detail, so it is reachable from the ledger rather than from a log file.

A migration in this state is still recorded and still counted as run, and migrate still exits 0. Both are deliberate: a redundant statement must not make a migration re-run for ever, and must not break a deploy script that has been re-running the same migrations for years. What the report does not do is claim something that did not happen.

Deciding for yourself

A migration can inspect its own outcome instead of verifying its work against the schema afterwards to find out whether the framework's report of it was true:

public function up(): void
{
    $this->addQuery('DROP MATERIALIZED VIEW reports.usage CASCADE');
    $failures = $this->executeQueries();   // number rejected

    if ($this->hasFailedStatements()) {
        // Every entry is ['query' => …, 'error' => …, 'benign' => bool]
        foreach ($this->getFailedStatements() as $failure) {
            if (!$failure['benign']) {
                throw new \RuntimeException(
                    'reports.usage was not dropped: ' . $failure['error']
                );
            }
        }
    }
}

failedStatementSummary() is the one-line form the runner puts in the ledger, and is empty when nothing failed — so it works as the condition.

benign is a label, not a decision

benign marks a failure that looks like work already applied — a duplicate column, a table that exists. Nothing is skipped or failed on the strength of it. It exists so a report can separate the eleven redundant ADD COLUMNs of a re-run from the one statement naming a table nobody created.

It cannot be more than a label, because it cannot be trusted enough to be one. Only MySQL supplies an error code: its mysqli_sql_exception propagates with the real errno (1050, 1060, 1061, 1091, …). PostgreSQL failures arrive as a plain Exception whose code is 0 — Database::setError() is called with error number 0 on that driver and no SQLSTATE is captured anywhere — so on that side the only discriminator is the message text, and message text is localisable. Gate on it and a database running a non-English lc_messages would start failing migrations whose statements were merely redundant, which is the one outcome the tolerance exists to prevent.

Two ways a statement fails

Database::query() throws for an execution error, but it also has one path that returns false without throwing — a statement that cannot be prepared. executeQueries() counts both. A check that only caught exceptions kept missing the quieter one.

Changing an existing column

Use the schema builder, not hand-written ALTER TABLE:

public function up(): void
{
    $schema = new \Pramnos\Database\SchemaBuilder($this->application->database);

    $schema->alterTable('articles', function ($table) {
        $table->modifyColumn('author', 'string', ['length' => 500]);   // widen
        $table->modifyColumn('body', 'text');                          // retype
        $table->modifyColumn('score', 'integer')->nullable()->default(0);
    });
}

modifyColumn(string $name, string $type, array $attrs = []) rewrites the definition of an existing column and returns a ColumnDefinition, so the same fluent modifiers as column creation are available. It compiles to MODIFY COLUMN on MySQL and to the right set of ALTER COLUMN … TYPE / SET NOT NULL / SET DEFAULT sub-statements on PostgreSQL — and only for the attributes you actually set, so retyping a column does not silently reset its nullability or its default.

Schema Builder Guide has the whole alterTable() vocabulary: adding, dropping and renaming columns, indexes and foreign keys.

Migration Features

Conditional DDL (Capabilities Check)

public function up(): void
{
    $schema = $this->schema();

    $schema->create('metrics', function ($table) {
        $table->bigIncrements('id');
        $table->timestamp('time');
        $table->float('value');
        $table->index('time');
    });

    // The table is the work; the hypertable is an extra on TimescaleDB.
    $schema->whenCapable(
        \Pramnos\Database\DatabaseCapabilities::TIMESCALEDB,
        fn(\Pramnos\Database\SchemaBuilder $s) => $s->createHypertable('metrics', 'time')
    );
}

whenCapable() does nothing when the capability is absent; ifCapable() without a fallback declines the migration instead — see ifCapable() or whenCapable().

Raw SQL Migrations

Use the schema builder wherever it reaches — createTable(), ->foreign()->on(), index()->where(), createTrigger(), createOrReplaceView() — because it names every table as the installation has it: with the table prefix, and on MySQL with a schema folded into the name (authserver.roles is {prefix}authserver_roles). That includes the table a foreign key references.

What it does not model — a view's body, a PL/pgSQL function, a named CHECK, a DESC index — stays raw, and every table in it is named through the builder all the same:

public function up(): void
{
    $schema = $this->application->database->schema();

    // Raw: a view's body is SQL; the tables in it are named through the builder.
    $schema->createOrReplaceView('authserver.active_roles', 'SELECT r.roleid, r.name FROM '
        . $schema->quoteTable('authserver.roles') . ' r JOIN '
        . $schema->quoteTable('users') . ' u ON u.userid = r.created_by');
}

quoteTable() gives the quoted reference for SQL; resolveTableName() gives the bare name, for information_schema, pg_indexes or pg_get_serial_sequence(). A name written out — public.users, `usertokens`, applications.oauth2_application_grants — is correct only on an installation without a prefix, and TablePrefixInQueriesTest refuses it in the framework's own migrations. Leave a one-line comment saying why a statement stays raw.

Data Migrations

public function up(): void
{
    $db = $this->DB();

    // Copy data in pages; chunk() is a QueryBuilder method, ordered for determinism
    $db->queryBuilder()
        ->from('old_table')
        ->orderBy('old_id')
        ->chunk(100, function (array $rows) use ($db) {
            foreach ($rows as $row) {
                $db->queryBuilder()
                    ->table('new_table')
                    ->insert([
                        'id' => $row['old_id'],
                        'name' => strtoupper($row['old_name']),
                    ]);
            }
        });
}

A migration is not the first thing to touch the schema

The framework's migrations run inside applications that have their own. Every object a framework migration creates may already be there — built by an app migration, by a predecessor of the migration system, or by an operator. Written on the assumption of an empty schema, a migration fails partway through: the statements before the failure are committed, the ones after it never run, the runner records result = 0, and it retries and fails again on every deploy that changes the fingerprint.

The checks below cover almost all of it.

1. Ask what kind of relation owns a name before you drop it. DROP VIEW IF EXISTS does not protect you from a materialized view under the same name — PostgreSQL raises "x" is not a view and the IF EXISTS is no help, because the relation does exist.

// Replaces the framework's own view; leaves a consumer's materialization alone.
if ($schema->getRelationKind('reports.usage') === 'v') {
    $this->DB()->query('DROP VIEW IF EXISTS reports.usage CASCADE');
    $this->DB()->query('CREATE VIEW reports.usage AS ...');
}

Note what this deliberately does not do: pick DROP MATERIALIZED VIEW when the kind is m. Completing the migration is the easy half. An application that made the name a materialized view chose a cached read over a live one and refreshes it on a schedule, so replacing it swaps a cached read for a full aggregation under an installation serving traffic, and leaves the refresh job pointed at a relation that no longer exists — failing on its own schedule, far from the deploy that caused it. Skip, and say so in the log. A plain view is the framework's own and is replaced as usual, so the framework never loses the ability to update what it created.

When the materialization is in the way — it selects a column an ALTER has to change — drop it and recreate it from its own definition (pg_matviews.definition, plus its pg_indexes) after the ALTER, rather than replacing it. widen_applications_callback does this for a legacy applications.usage_statistics.

2. Do not rely on CREATE OR REPLACE VIEW on PostgreSQL. It refuses a view whose columns change name or order ("cannot change name of view column"), and an older database may hold an earlier shape under the same name. Drop the view first — DROP VIEW IF EXISTS … CASCADE, after the kind check above — then create it. MySQL's OR REPLACE takes any shape.

3. Cast, do not assume a column's type. The same logical column can be VARCHAR on one installation and something narrower on another, when the application created the table first and picked a type that validates its contents. Pick an expression defined for both:

-- SPLIT_PART has no INET overload; HOST() has no VARCHAR one; ::text is defined for both
SPLIT_PART(ip_address::text, '.', 1)

4. Prefer the builder method that already guards. createContinuousAggregate() returns quietly when the aggregate exists, and hasIndex() / hasTable() / hasColumn() / hasView() exist so a guard can be asked rather than inferred from a caught driver error.

Say what you skipped. A migration that quietly does nothing is indistinguishable from one that worked. Log to the migrations channel with enough detail for an operator to act on:

\Pramnos\Logs\Logger::log(
    'reports.usage already exists as a materialized view and was left as it is. '
    . "To adopt the framework's live view, drop the relation together with whatever "
    . 'refreshes it and re-run this migration.',
    'migrations'
);

Comparing an installation with the framework's shape: schema:diff

A table that existed before the framework's migration for it keeps its own shape: the migration finds it and returns, and nothing says the two differ. schema:diff says how.

php bin/pramnos schema:diff                       # report; exits 1 while differences remain
php bin/pramnos schema:diff -v                    # …and what the installation has beyond the framework
php bin/pramnos schema:diff --apply               # add the missing columns and indexes
php bin/pramnos schema:diff --json                # every difference, for a test or a script
php bin/pramnos schema:diff --reference=empty_db  # build the reference in a database you made
php bin/pramnos schema:diff --reference=ref --fresh  # …emptying it first

The reference is built, not stored. The framework's migrations, for the features this application has on, run into an empty database on the same server with the same prefix. Both sides are then described by the same engine (MariaDB against MariaDB), and the temporary database is dropped (--keep keeps it). While the reference is built, the framework's database singleton points at it, so nothing a migration reaches lands in the installation.

  • migrations.exclude is built too. On an installation that adopted tables the framework would have made, the excluded migrations are the CREATEs of exactly those tables. Left out, those tables would be missing from the reference and never compared, and every migration that alters them would fail there. --honour-exclude leaves them out, as migrate does.
  • The database user needs CREATE, ALTER, INDEX, DROP and REFERENCES on the reference. A user granted CREATE DATABASE is not always granted ALTER in the database it made. The command tries each on a probe table first and names the first it may not run, before any migration. A user that may not create databases names an empty one it has those privileges on with --reference.
  • A named reference has to be empty. One that is not is refused, because an interrupted run leaves tables every migration then finds and skips. --fresh empties it first: every table and view on MySQL, and every schema on PostgreSQL, with public made again.

What is compared, per table of the reference:

Kind Compared Key
table present sessions.table.sessions.missing
column present; type (integer display widths left out); NULL or not; default; charset on MySQL users.column.mobile.type
index by the columns it covers, in order, and whether it is unique — the name is not the index tokenactions.index.tokenid,urlid.missing
foreign key by column, the table and column it leads to, and the delete rule usertokens.foreign.applicationid>applications.appid.rule

A column, index or key only the installation has is information (extra, shown with -v), and so is the framework's index under another name (name). A latin1 or utf8mb3 column where the framework's is utf8mb4 is a charset difference.

Intentional differences go in an allow-list of globs over the keys:

// app/app.php
'schema' => ['allow' => [
    'users.column.mobile.type',      // ours is longer, on purpose
    'legacy_log.*',                  // a table we keep as it is
]],

--allow=<glob> adds one for a single run. An allowance that matches no difference any more is named after the report: the difference was fixed, or the key never matched, and either way it should go before it hides the next one.

--apply only adds. A missing column and a missing index are added exactly as the reference database declares them: its SHOW CREATE TABLE line on MySQL, its definition on PostgreSQL. On PostgreSQL a NOT NULL column without a default is added nullable, then set NOT NULL where the rows allow it. Nothing is changed or removed: a type, a default, a unique flag and a foreign key stay in the report, for a migration of the application's own. A foreign key can meet rows that break it, and a type change can lose data.

Migration Files Naming

Timestamp Convention

Migration files use the timestamp prefix: YYYY_MM_DD_HHMMSS_description.php

2026_05_29_090000_create_users_table.php
2026_05_29_091500_add_email_to_users.php
2026_05_30_143000_create_posts_table.php

The timestamp determines execution order. The separator after it is an underscore: the prefix is recognised by pattern, and a file named 2026_05_14_000050-AddSomething.php is not a timestamped migration — it is an untimestamped one whose whole basename becomes its name. It still runs, because the loader reads classes rather than filenames, but it sorts with the timestampless and it is not what anybody reading the directory will assume.

An untimestamped file — Migration0151.php, the older convention — is supported and counted. Its slug comes from the class short name, converted from camelCase to snake_case, because a class name cannot begin with a digit and so cannot carry the timestamp: Migration0151 becomes migration0151, CreateUsersTable becomes create_users_table. Name the file after the class, which the convention does anyway; see What the fingerprint covers for what happens when it does not.

Framework migrations are always timestamped, and a unit test asserts it over the real directories, so a file named with a hyphen where the convention has an underscore fails the suite.

Framework vs Application Migrations

  • Framework migrations: database/migrations/framework/
    Created by the framework for core features.

  • Application migrations: app/Migrations/ (the default --path), plus any directory listed in app.php under migrations.paths.
    Created by you for your application schema.

Important: the framework's baseline migrations carry the 2020_01_01_* timestamp; later framework migrations carry their real date. An installation whose database predates the migration system skips the baseline with a cutoff just after it: 'migration_cutoff' => '2020_01_02_000000' in app.php — or the same moment as a date-time, '2020-01-02 00:00:00'. --cutoff and MigrationRunner::run(['cutoff' => …]) take the underscore form.

Batching & Rollback

Migrations are grouped into "batches." Each migrate run increments the batch number. You can rollback by batch:

# Rollback the latest batch
php vendor/bin/pramnos migrate:rollback

# Rollback one specific batch, by its number
php vendor/bin/pramnos migrate:rollback --batch=3

# Rollback all batches
php vendor/bin/pramnos migrate:reset

Status & Information

# Show migration status — every migration, applied or pending
php vendor/bin/pramnos migrate:status

MigrationRunner

MigrationRunner handles execution order, history recording, rollback, and cutoff filtering.

Namespace: Pramnos\Database\MigrationRunner

new MigrationRunner(
    ?Database $db = null,
    string $historyTable = 'schemaversion',
    ?Application $app = null  // enables maintenance-mode integration
)

Running Migrations

$runner = new MigrationRunner($db);

// Run all pending migrations (sorted, filtered, recorded)
$result = $runner->run($migrations);
// $result = ['ran' => [...], 'failed' => [slug => error], 'warned' => [slug => rejected statements],
//            'declined' => [slug => reason]]

// With options
$result = $runner->run($migrations, [
    'force'  => true,                    // include autoExecute=false migrations
    'cutoff' => '2022_01_01_000000',     // skip migrations at or before this date
]);

History Table Schema

ensureHistoryTable() creates the ledger — schemaversion by default, see The ledger's name — if it does not exist:

`key`            VARCHAR(255)   PRIMARY KEY  -- slug, e.g. '2026_09_28_000001_add_thing'
`extra`          VARCHAR(255)   NULL         -- the migration's $description
`version`        VARCHAR(50)    NULL         -- the migration's $version, when it declares one
`scope`          VARCHAR(255)   DEFAULT 'app'
`feature`        VARCHAR(255)   NULL
`batch`          INT            NULL
`execution_time` DOUBLE         NULL         -- seconds
`result`         SMALLINT       DEFAULT 1    -- 1=success, 2=ran with errors, 3=declined, 0=failed
`error_message`  TEXT           NULL
`when`           TIMESTAMP      DEFAULT NOW()

The first three column names are what the legacy ledger used, and they are kept because this is the same table: an installation that migrated through Application::runMigration() for years has rows in it already.

The table upgrades itself. Every column except key is added on the next ensureHistoryTable() call if it is missing — through ADD COLUMN IF NOT EXISTS on PostgreSQL and a schema lookup on MySQL, which has no such clause. No migration file adds a column to the migration ledger, because it has to be readable before migrations can run.

Sorting

sort(array $migrations, array $alreadyRan = []): array

Returns migrations in execution order: 1. Topological sort — dependencies run before dependents 2. Priority ascending — lower $priority runs first 3. Timestamp ascending — older YYYY_MM_DD_HHmmss prefix runs first

$sorted = $runner->sort($migrations);
// Throws RuntimeException if a cycle is detected

A migration that adds to tables other migrations create runs late. Give it a high $priority (the framework's own use 200) or a dependency on what it touches. At the default it can run before a feature creates its tables, find none, do nothing, and still be recorded as run: the installation then lacks what it adds, while one migrated table by table has it. FrameworkMigrationOrderTest holds the framework's own to this.

Filtering

// Exclude autoExecute=false migrations (pass force=true to include them)
$runner->filterAutorun($migrations, force: false);

// Skip migrations at or before the cutoff timestamp
$runner->filterCutoff($migrations, cutoff: '2022_01_01_000000');

// Remove already-ran slugs
$runner->filterAlreadyRan($migrations, ranSlugs: ['create_roles_table']);

Rollback

// Rollback the last batch
$result = $runner->rollback($migrations);
// $result = ['rolledBack' => ['create_users_table', ...]]

// Rollback specific batch number
$result = $runner->rollback($migrations, ['batch' => 3]);

// Rollback all batches
$result = $runner->rollbackAll($migrations);

Pending Migrations

// Returns migrations whose slug does not appear as result=1 in history
$pending = $runner->getPending($migrations);

// History for migrate:status
$history = $runner->getHistory();

A batch that ran anything clears the column cache

getColumns() and the models cache a table's column list for an hour, and SchemaBuilder invalidates the tables its own DDL methods touch. A migration that writes raw SQL — allowed, since the builder cannot express every engine's grammar — would leave the old list in place: a model then saves without the new column, and a list built by getApiList() answers without it.

So MigrationRunner::run() calls Database::forgetAllColumns() when a batch ran anything: every table, since the runner cannot know which ones a raw statement touched without parsing SQL. It clears the stored lists and the one each model keeps for the rest of the process. A migrate that finds nothing pending flushes nothing.

One key per table. Database::columnCacheKey($table) is the key every reader and every flush uses, whatever the name it is given — #PREFIX#users, pramnos_users, or on PostgreSQL public.properties as a model writes it: the prefix is resolved and the connection's own schema dropped, while a table in another schema (authserver.roles) keeps it. The key is scoped to the database, so two databases behind one cache do not share an answer.

A schema changed by hand, outside migrate, is what cache:clear is for — it clears the query cache as well as the application's.

Every path that runs a migration flushes, not only migrate:

When
MigrationRunner::run() a batch that ran anything
MigrationRunner::rollback() a rollback that undid anything
Application::runMigration() always, in a finally

The rollback matters for the opposite reason to the rest: a down() that drops a column leaves every reader believing it is still there, so code selects a column the database no longer has and the failure is a query error a long way from the rollback.

The legacy loader flushes in a finally rather than after success, because a migration that raised half way is the worst of the three outcomes — some of its DDL ran, the ledger has no row, and the cache is now wrong about a table nobody knows changed.

The key carries the connection

schema_columns_<database>_<schema>_<table>, with the prefix resolved. Two databases behind one cache — an installation's test and dev, or two installations on one Redis — keep their own column lists.

Without the connection in the key the failure is the quiet kind: a command run against the dev database, with a migration not yet applied, would rewrite the column list the test database's models save with, and Model::_save() would silently drop the columns the migration had added.

Test and dev is both the common case and the worst one: the two are meant to differ while a migration is being written, which is exactly when somebody is reading the result and drawing conclusions from it.

What is still on you: a schema changed by hand, outside migrate, and — on more than one node — the other machines, whose own caches this cannot reach. That is what cache:clear is for, and it is why a deploy script that runs migrate and then cache:clear was never wrong to.

MigrationLoader

Discovers and instantiates Migration subclasses from PHP files in a directory.

Namespace: Pramnos\Database\MigrationLoader

// Load from one directory
$migrations = MigrationLoader::loadFromDirectory(
    ROOT . '/app/Migrations',
    $app
);

// Load from multiple directories
$migrations = MigrationLoader::loadFromDirectories(
    [
        ROOT . '/app/Migrations',
        ROOT . '/vendor/mrpc/pramnosframework/database/migrations/framework/queue',
    ],
    $app
);

// Everything in scope for this application — app directories and the enabled
// framework feature directories, the same set `migrate` and auto-run use
$dirs = MigrationLoader::scopeFor($app)['dirs'];

A directory is not searched recursively: framework migrations live one directory per feature (database/migrations/framework/<feature>/), so name the feature directory or use scopeFor().

Files are sorted alphabetically before loading, so YYYY_MM_DD_HHmmss_ prefixes naturally produce chronological order.