feat: add versioned database migrations - #6647
Open
gharlan wants to merge 3 commits into
Open
Conversation
`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`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The
install()hooks describe the target state of the tables a package owns and are re-applied on everymigrate. 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.phpshowed what that costs:An unconditional
UPDATEover 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_migrationledger:The files live in a
migrations/directory of the core, of an addon or of the project.migrateruns the pending ones after theinstall()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: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
globand not class discoveryMigrations 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,Tableand 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 inAddonManager::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
migrateiteratedAddonManager::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 theinstall()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:
An activated addon that fails still aborts.
The project
AbstractProjectgets aninstall()hook with the same contract as an addon's, so project-owned tables are covered by the same mechanism, andmigrateapplies it after the core and all addons. The skeleton ships a commented stub.Commands
migrate:statusexiting non-zero is deliberate, so a deployment or CI job can gate on "the database is behind the code".migrateclears the cache when it is done.Notes
rex_migration, keyed by package and migration id, so two packages can never collide and per-package baselining is exact.<Y-m-d-His>-<description>.php, the timestamp in UTC so migrations written in different time zones still sort in the intended order.migrations/directory yet, so it is not in the analysis paths — that has to be added back with the first core migration.