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:
featuresgates the framework's per-feature migration directories. A feature the application has not enabled contributes no migrations.migration_cutoffexcludes every migration whose filename timestamp is at or before it. Inapp.phpit 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:
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:
«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:
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:
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(), because0.100is above0.099and a string comparison — an SQLORDER BYincluded — 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:
migrateprintsDeclined:with the reason and a closing line saying these stay pending;migrate:statusshowsDeclinedin the Status column and prints the reason under the table;- and because
getRanSlugs()counts onlyRESULT_OK, the migration is attempted again by the nextmigrate— 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
WHEREthat makes it re-runnable. WithoutWHERE 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.excludeis 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-excludeleaves them out, asmigratedoes.- 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.
--freshempties it first: every table and view on MySQL, and every schema on PostgreSQL, withpublicmade 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 inapp.phpundermigrations.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¶
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
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.