Migrations
The schema of your own tables is changed by Phinx migrations: a migration is a PHP class describing a change. No xPDO maps to rebuild.
You do not have to write them by hand — the Migrations tab in the constructor does the same thing and writes both the file and the model for you. That tab is PRO; writing the files yourself is not gated at all. This page is for when the change is easier to express in code — or when you are on the free edition.
Where they live
| Files | core/App/Database/migrations |
| Config | core/App/phinx.php |
| Log of what ran | pb_app_migrations |
This is the site layer — yours. A component update does not touch it.
Which cuts both ways
Because the resolver never overwrites anything under core/App/, a fix shipped for one of those files does not arrive on its own — core/App/phinx.php included. When the changelog says a file there was corrected, apply the edit by hand on an existing install.
The component's own migrations are a separate set with a separate log (pb_migrations), run by the package installer rather than by you. When a set has files the log does not account for, every manager page shows a banner — it covers PageBlocks, pbShop, the repository and your own core/App migrations. The install no longer reports success when its migrations fail, but the banner is the thing to trust.
Plan before you write
status shows what is pending, migrate applies it. Read the plan first and say the expected result out loud: a migration is undone by restoring a dump.
# the plan
php core/components/pageblocks/vendor/bin/phinx status -c core/App/phinx.php
# the write
php core/components/pageblocks/vendor/bin/phinx migrate -c core/App/phinx.phpNever run PHP as root on the server
The site's PHP runs as the site's user. A migration run by root leaves root-owned cache files the site cannot overwrite afterwards — and the manager starts serving a frozen lexicon. Use sudo -u <site user>.
Writing one
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreatePbExampleTable extends AbstractMigration
{
public function change(): void
{
$this->table('pb_example', ['id' => true, 'primary_key' => ['id']])
->addColumn('name', 'string', ['limit' => 100, 'null' => false, 'default' => ''])
->addColumn('data', 'json', ['null' => false, 'default' => '{}'])
->addColumn('menuindex', 'integer', ['signed' => false, 'null' => false, 'default' => 0])
->addColumn('published_at', 'datetime', ['null' => true, 'default' => null])
->addTimestamps()
->addColumn('deleted_at', 'datetime', ['null' => true, 'default' => null])
->addIndex(['name'], ['name' => 'idx_name'])
->create();
}
}Phinx adds the table prefix — write pb_example, not modx_pb_example.
Two rules about version numbers
The file name carries the version — 20261011120000_CreatePbExampleTable.php — and phinx identifies a migration by that version, not by its name or its contents. Hence two rules, and breaking either fails silently:
- Always take the next free number. Reuse one that is already in some install's log and phinx skips your file while still reporting itself up to date. A recycled version is the one thing
down()cannot repair. - Never edit a migration that has already run. The log remembers the version, so your change will not be replayed — that install keeps the old schema forever. Write a new migration instead.
A table meant to hold constructor data needs the same tail the built-in ones have: data, menuindex, published_at, timestamps and deleted_at. That tail is what makes publishing, ordering and the recycle bin work without extra code.