Repository navigation
Description
Background
MigrationInterface does not declare up(), down(), change(), or init(). They are runtime hooks dispatched via method_exists():
// src/Migration/Environment.php
if (method_exists($migration, MigrationInterface::CHANGE)) {
$migration->change();
} elseif (method_exists($migration, $direction)) {
$migration->{$direction}();
}This costs us two permanent PHPStan baseline entries (method.notFound for the up/down dispatch) and prevents IDEs/static analysis from understanding migrations. Adding the methods directly to MigrationInterface is a BC break and is wrong semantically — a migration is either reversible (defines change()) or directional (defines up()/down()), never both.
Proposal
Split the optional hooks into capability interfaces, dispatch via instanceof.
New interfaces
namespace Migrations;
interface ReversibleMigrationInterface extends MigrationInterface
{
public function change(): void;
}
interface DirectionalMigrationInterface extends MigrationInterface
{
public function up(): void;
public function down(): void;
}(init() could get its own InitializableMigrationInterface if useful, or stay an optional hook — open question.)
Environment dispatch
if ($migration instanceof ReversibleMigrationInterface) {
if ($direction === MigrationInterface::DOWN) {
// ... record-adapter wrapping
$migration->change();
$recordAdapter->executeInvertedCommands();
} else {
$migration->change();
}
} elseif ($migration instanceof DirectionalMigrationInterface) {
$direction === MigrationInterface::UP ? $migration->up() : $migration->down();
}PHPStan narrows correctly; the two baseline entries are removed.
BaseMigration stance
Proposal: opt-in. BaseMigration does NOT implement either capability interface. Every user migration declares its style explicitly:
class CreateProducts extends BaseMigration implements ReversibleMigrationInterface
{
public function change(): void { /* ... */ }
}Rationale: cleanest types, the 6.x BC-break window is where this kind of mechanical update is acceptable, and a rector rule can auto-apply the implements clause based on which method the class defines.
Alternative considered: BaseMigration defaults to DirectionalMigrationInterface with empty up()/down(). Zero user friction but PHPStan would treat every migration as having up/down even when only change() is defined — undermines the whole reason for the split.
Migration path for users
- For migrations defining
change(): addimplements ReversibleMigrationInterface. - For migrations defining
up()/down(): addimplements DirectionalMigrationInterface. - Bake templates emit the correct
implementsclause for new migrations. - Provide a rector rule (
AddMigrationCapabilityInterfaceRector) that scans for the method shape and adds the interface — runnable once in the upgrade.
Tasks
- Add
ReversibleMigrationInterface/DirectionalMigrationInterface(and possiblyInitializableMigrationInterface) - Replace
method_existsdispatch inEnvironment::executeMigrationwithinstanceof - Update bake templates to emit
implements ... - Provide rector rule for upgrade
- Drop the two remaining
Environmententries fromphpstan-baseline.neon - Document the new contracts and the upgrade path in the 6.x migration guide
Open questions
- Does
init()warrant its own interface, or is the optional-hook style fine for it? - Should we ship the rector rule in this repo, or as a separate
cakephp-upgradetask?
Context
Came up while reducing the PHPStan baseline in #1083 — the two remaining baseline entries on Environment.php cannot be removed without an interface change.
Activity
markstory commentedon May 10, 2026
Isn't this a lot of work/changes to push onto application developers just to avoid PHPStan warnings?
dereuromark commentedon May 12, 2026
Status quo:
No typo detection (
function chnage()runs as a regular no-op method and the migration silently does nothing). Anyone wrapping MigrationInterface in a custom runner has to inspect via reflection.But fair point.
I guess rector could easily auto-fill the needed changes.
Alternative shape worth considering: sibling base classes instead of (or alongside) bare interfaces. BaseReversibleMigration and BaseDirectionalMigration both extend BaseMigration and declare the right method abstract. Users change one word in extends rather than adding an implements line. Slightly more idiomatic, bake picks the right one based on what it's generating, and PHPStan narrows via the concrete class type.
Trade-off: weaker narrowing for users who currently extend their own base class, but those users can implement the interface directly or have their base class extend ours. Orthogonal to the interface change; doesn't replace it, but reduces user-side keystrokes if shipped alongside.
jamisonbryant commentedon May 12, 2026
I think the existence of the rector kind of mops up all the busy work, doesn't it?
Not to get philosophical, but at my org we treat PHPStan fusses as important fixes. We call them "non-bugs" because of the dozens and dozens of times they've prevented something that WOULD have been a bug if it was allowed. (Migrations aren't "deployed code" per se, but still wanted to weigh in on this point).
I don't love the idea of a bifurcated base class, it feels like base = one. Therefore, I am a fan of the interfaces.
dereuromark commentedon May 12, 2026
Sketching out a soft 5.next path so the interface work doesn't have to wait for the 6.x window.
5.next: marker interfaces with PHPDoc-only contracts
Ship the new capability interfaces in 5.next as pure markers — the method contracts live in PHPDoc method tags, not as abstract declarations:
Environment::executeMigrationswitches toinstanceof-first with amethod_existsfallback for the duration of the 5.x cycle:What this buys us in 5.next:
Environment.phpPHPStan baseline entries become removable once we're comfortable trusting the typed path internally — fine to keep them through 5.x and only drop them in 6.x, that's a follow-up call.method_existsfallback still handles every legacy class.6.x: promote to real abstract methods
In 6.x the PHPDoc method tags become real abstract method declarations and the
method_existsfallback is removed. That's still a BC break, but a much smaller cohort than today's proposal — it only affects users who adopted the interface in 5.next and mistyped the signature.Honest tradeoff
PHPDoc-only contracts do not catch user-side typos.
function chnage()on a class declaringimplements ReversibleMigrationInterfacewill still silently no-op in 5.x. That only goes away in 6.x when the real abstract method enforces the signature. The 5.next release notes should be explicit: the value in 5.x is static analysis and a runway, not enforcement.Rector rules to minimise manual work
Two rules, shipped with a config the user can run via
vendor/bin/rector:AddMigrationCapabilityInterfaceRectorMigrations\BaseMigration(recursively, so app-local intermediate bases are covered).change()→ addimplements ReversibleMigrationInterfaceand the matchinguseimport.up()ordown()→ addimplements DirectionalMigrationInterfaceand the matchinguseimport.change()andup()/down(), leave it alone and emit a warning so the user picks deliberately.NormalizeMigrationMethodSignatureRectorchange()/up()/down()arepublicand have: voidreturn type — that's what 6.x will demand once the PHPDoc method tags become real abstracts. Doing it in 5.x means the 6.x upgrade is free for anyone who already ran rector.Bake template changes (not rector, but tied to the rollout)
implements ...clause out of the box.Manual work that remains after rector
BaseMigration(legacy PhinxAbstractMigrationforks, custom bases that don't extend ours). Rare on 5.x but possible — these need a hand-edit.implementsclause has to be added at the generation site.change()/up()/down()(e.g.AppMigration extends BaseMigrationdoing shared setup). Rector adds the interface to the base once and the subclasses inherit it. If the base lives in a plugin the app developer doesn't control, the choice is to either implement the interface on the leaf class or PR the plugin upstream.method_existsfallback throughout 5.x, but want a sweep before 6.x lands.init()decision — rector deliberately doesn't touch it. The InitializableMigrationInterface (or "stays an optional hook") call from the original proposal is still open and should land before we extend rector to it. Orthogonal to the rest.Net: for a typical 5.x app on
BaseMigration, the upgrade is one rector command plus a re-run of phpcs. The carrying cost during 5.x is the dual dispatch inEnvironmentand keeping the two baseline entries until 6.x — both acceptable for a soft window.markstory commentedon May 24, 2026
Fair point, typing errors are real errors. My objection was with requiring application developers to make changes to fix type errors in library internals felt like unnecessary toil that provides little if any benefit to them.
I like the marker interface approach. It gives us better backwards compatibility, a smoother upgrade and provides an hook point for deprecation warnings to be added.
dereuromark commentedon May 28, 2026
@markstory The PR is open for you to check and sign off on: #1086