Skip to content

feat: add versioned database migrations - #6647

Open
gharlan wants to merge 3 commits into
6.xfrom
deployment
Open

feat: add versioned database migrations#6647
gharlan wants to merge 3 commits into
6.xfrom
deployment

Conversation

@gharlan

@gharlan gharlan commented Aug 31, 2026

Copy link
Copy Markdown
Member

The install() hooks describe the target state of the tables a package owns and are re-applied on every migrate. That covers structure well, but it has no memory: a step that should happen exactly once has to be written so that it survives running on every single deploy. setup/install.php showed what that costs:

$sql = Sql::factory();
$sql->setQuery('UPDATE ' . Core::getTablePrefix() . 'article_slice set revision=0 where revision<1 or revision IS NULL');

An unconditional UPDATE over the whole slice table, on every run, to normalise a column that was fixed years ago. It is removed here without replacement — but it is the kind of thing that needs somewhere to go.

Migrations

A migration is a one-time step, recorded in the new rex_migration ledger:

// migrations/2026-08-21-143000-backfill_product_sku.php
return new class extends Migration {
    public function up(): void
    {
        Sql::factory()->setQuery('UPDATE ' . Core::getTable('product') . ' SET sku = … WHERE sku IS NULL');
    }
};

The files live in a migrations/ directory of the core, of an addon or of the project. migrate runs the pending ones after the install() step, ordered by file name.

Which of the two mechanisms fits is a case-by-case call, and install() is often still the better place. It is idempotent, works from any starting state, and the order within the hook is up to you, so rescue-before-destroy can be written as a single guarded block:

if ($table->hasColumn('title')) {
    $sql->setQuery('UPDATE … SET name = title WHERE name = ""');
    $table->removeColumn('title');
}

A migration buys you the ledger. Use it when repeating the check on every deploy is what you want to avoid, or for tables no package owns.

Why glob and not class discovery

Migrations are what repairs a system whose schema has fallen behind — quite possibly a system that is currently broken because the migration is missing. So finding them must not depend on autoload state, on whether an addon is activated, or on a cache. They are plain files, found by glob, and they still return a typed instance so static analysis and tests see them.

For the same reason they run without booted addons: use Sql, Table and other core primitives, not addon runtime APIs.

Baselining

A fresh installation records all migrations without running them — install() already produced the state they lead to. This happens in the setup for the core and the project, and in AddonManager::install() for a newly installed addon. Uninstalling an addon drops its ledger entries again, so a later reinstall baselines cleanly.

The flip side, and it is worth knowing: whatever a fresh instance needs has to be in install(). A table that only ever gets created by a migration would be missing after a fresh setup.

Deactivated addons

migrate iterated AddonManager::getAddonOrder(), which only lists activated addons — so an installed but deactivated addon silently kept a stale schema, and re-activating it later held surprises. It now covers all installed addons, for the install() step as well as for migrations.

A deactivated addon that fails is reported instead of aborting the run. It is not booted anyway, so a stale schema does no harm right now, and it must not block a deployment:

Schema
------

  core ... OK
  someaddon ... SKIPPED AddOn "someaddon" could not be installed due to …
  project ... OK

An activated addon that fails still aborts.

The project

AbstractProject gets an install() hook with the same contract as an addon's, so project-owned tables are covered by the same mechanism, and migrate applies it after the core and all addons. The skeleton ships a commented stub.

Commands

migrate                      # install() of core, addons and project, then pending migrations
migrate --fake               # record pending migrations without running them
migrate:status               # list pending migrations, exits non-zero if there are any
migrate:make <description>   # create a migration file (--package=core|project|<addon>)

migrate:status exiting non-zero is deliberate, so a deployment or CI job can gate on "the database is behind the code". migrate clears the cache when it is done.

Notes

  • Ledger table rex_migration, keyed by package and migration id, so two packages can never collide and per-package baselining is exact.
  • File names are <Y-m-d-His>-<description>.php, the timestamp in UTC so migrations written in different time zones still sort in the intended order.
  • The core does not ship a migrations/ directory yet, so it is not in the analysis paths — that has to be added back with the first core migration.

`Application::loadPackages()` booted a single addon by name for a command called `ydeploy:migrate`. That is a
leftover from 5.x: the command does not exist in 6.x, and a hardcoded addon name in the core is not how an addon
gets its boot anyway.
The `install()` hooks describe the *target state* of the tables a package owns and are re-applied on every
`migrate`. That covers structure well, but it has no memory: a step that should happen once has to be written so
it can run on every deploy. `setup/install.php` showed what that costs — an unconditional `UPDATE` over the whole
slice table on every single run, just to normalise a column that was fixed years ago.

This adds the second mechanism next to it. A migration is a one-time step, recorded in the new `rex_migration`
ledger:

```php
// migrations/2026-08-21-143000-backfill_product_sku.php
return new class extends Migration {
    public function up(): void
    {
        Sql::factory()->setQuery('UPDATE ' . Core::getTable('product') . ' SET sku = … WHERE sku IS NULL');
    }
};
```

Which of the two fits is a case-by-case call, and `install()` is often still the better place — it is idempotent,
works from any starting state, and the order within the hook is up to you, so rescue-before-destroy can be one
guarded block. A migration buys the ledger: use it when repeating the check on every deploy is what you want to
avoid, or for tables no package owns.

The files live in a `migrations/` directory of the core, of an addon or of the project, and are found by `glob`
rather than by class discovery. That is deliberate: migrations are what repairs a system whose schema has fallen
behind, so finding them must not depend on autoload state, addon activation or a cache. They still return a typed
instance, so static analysis and tests see them.

A fresh install records all migrations without running them — `install()` already produced the state they lead to.
That happens in the setup for the core and the project, and in `AddonManager::install()` for a newly installed
addon; uninstalling an addon drops its ledger entries again.

Along the way:

* `migrate` now applies the schema of **installed but deactivated** addons too, which it silently skipped before
  (`AddonManager::getAddonOrder()` only lists activated ones), so a deactivated addon no longer falls behind. A
  deactivated addon that fails is reported instead of aborting the run — it is not booted anyway and must not
  block a deployment.
* The project gets an `install()` hook of its own, so project-owned tables are covered by the same mechanism.
* `migrate` clears the cache when it is done, and `migrate:status` exits non-zero while migrations are pending,
  so a deployment or CI job can gate on it.

New commands: `migrate:status` and `migrate:make`.
@gharlan gharlan added this to the REDAXO 6.0 milestone Aug 31, 2026
@rex-bot rex-bot added the feature Additional functionality label Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Additional functionality

Development

Successfully merging this pull request may close these issues.

2 participants