Migrations
Schema changes are Phinx migrations. There is no xPDO schema map to rebuild and no vehicle to regenerate — a migration is a PHP class that describes the change.
Two layers, two configs
This is the single most common source of confusion, so it comes first:
| What you are changing | Migrations live in | Config (-c) | Applied log |
|---|---|---|---|
| The component | core/components/pageblocks/src/Database/migrations | core/components/pageblocks/src/phinx.php | pb_migrations |
| Your site | core/App/Database/migrations | core/App/phinx.php | pb_app_migrations |
paths in each config points at the directory next to that config. The two do not see each other's migrations. Run with the wrong -c and your migration simply does not appear in status — no error, nothing. It looks exactly like a file that failed to deploy.
# component layer
php core/components/pageblocks/vendor/bin/phinx status \
-c core/components/pageblocks/src/phinx.php
# site layer
php core/components/pageblocks/vendor/bin/phinx status -c core/App/phinx.phpPlan before you write
status shows what is pending, migrate applies it. Always read the plan first and say out loud what you expect — a migration is rolled back by restoring a dump.
phinx status -c <config> # plan
phinx migrate -c <config> # applyNever run PHP as root on the server
The site's PHP runs as the site user. A migration started by root leaves root-owned cache files that the site then cannot overwrite — and the manager starts serving a frozen lexicon. Use sudo -u <siteuser>.
The component's migrations are run for you
When the package is installed or upgraded, its migrations resolver runs the component layer. You only run it by hand when files were delivered without reinstalling the package.
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();
}
}The table prefix is applied by Phinx — write pb_example, not modx_pb_example.
A table meant to hold constructor data wants the same tail as the built-in ones: data, menuindex, published_at, timestamps and deleted_at. That is what makes publishing, ordering and the basket work without extra code.
Two traps we have already hit
An index on a column the migration does not create. addIndex(['type']) where the column was renamed to placement passes review and passes on existing installs — the table is already there, so Phinx never re-runs the migration. On a fresh database it dies mid-run and leaves the component without half its tables. Create-migrations are only ever exercised by new installations, so they need a clean install to be tested at all.
Editing a migration that has already run. It changes nothing on installs that applied it — Phinx goes by the log, not by the file. If a fix has to reach existing databases, it needs a new migration, with hasColumn() / hasIndexByName() guards so it is a no-op where the change is already present.
Deleting migrations
A migration whose file is gone but whose row is still in the log shows up in status as ** MISSING MIGRATION FILE **. Phinx tolerates that and keeps working.
So folding old add_* migrations into the create_* ones is possible — but only while every installation has already applied them. After the component ships to someone else, their database may be at any point in the sequence, and a deleted migration becomes a silently broken install.