Skip to main content
A migration is a DDL operation. up() and down() must not depend on runtime state.

Not allowed

  • app(), config(), or env() for runtime decisions
  • TenantContext::get() and tenant-aware services
  • Branching on feature or module activation
  • Seed data that depends on runtime state
  • DB::table() against another module’s tables from this module’s migration

Why

A migration runs in contexts its author did not picture: on a fresh install with no configuration yet, during a rollback, on a tenant created next year, from a CI job with a different environment, in whatever order the migrator chose. A migration that reads configuration produces a different schema depending on when it ran. Two installations then diverge structurally while reporting the same migration state — and the next migration, which assumes one shape, meets the other. Branching on module activation has the same effect with an extra trap: enabling the module later does not re-run the migration, so the table never appears and the failure surfaces as a missing column at runtime. Touching another module’s tables makes the migration order a hidden dependency. Migrations from separate modules interleave; the table you read may not exist yet, and nothing in either module says so.

The consequence for extensions

This applies to Plugin and Solution migrations exactly as it applies to Core. Register a migrations path through CoreRegistrarInterface and let the platform mount it — see Building a Plugin.

How it is checked

These rules are PHPat rules, executed through phpstan.neon. The default PHPUnit run covers them through tests/Unit/Architecture/MigrationTest.php, which invokes PHPStan.
Do not run php artisan test tests/Architecture as the architecture check — those classes are not PHPUnit TestCases, so the command verifies nothing.