Skip to content

6.x: Capability interfaces for change() vs up()/down() migrations #1084

Open

Description

@dereuromark

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

  1. For migrations defining change(): add implements ReversibleMigrationInterface.
  2. For migrations defining up()/down(): add implements DirectionalMigrationInterface.
  3. Bake templates emit the correct implements clause for new migrations.
  4. 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 possibly InitializableMigrationInterface)
  • Replace method_exists dispatch in Environment::executeMigration with instanceof
  • Update bake templates to emit implements ...
  • Provide rector rule for upgrade
  • Drop the two remaining Environment entries from phpstan-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-upgrade task?

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

  1. added this to the 6.x (CakePHP 6) milestone on May 9, 2026
  2. markstory commented on May 10, 2026

    @markstory
    Member

    Every user migration declares its style explicitly:

    Isn't this a lot of work/changes to push onto application developers just to avoid PHPStan warnings?

  3. dereuromark commented on May 12, 2026

    @dereuromark
    MemberAuthor

    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.

  4. jamisonbryant commented on May 12, 2026

    @jamisonbryant
    Contributor

    Isn't this a lot of work/changes to push onto application developers...

    I think the existence of the rector kind of mops up all the busy work, doesn't it?

    ...just to avoid PHPStan warnings?

    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.

  5. dereuromark commented on May 12, 2026

    @dereuromark
    MemberAuthor

    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:

    namespace Migrations;
    
    /**
     * @method void change()
     */
    interface ReversibleMigrationInterface extends MigrationInterface
    {
    }
    
    /**
     * @method void up()
     * @method void down()
     */
    interface DirectionalMigrationInterface extends MigrationInterface
    {
    }

    Environment::executeMigration switches to instanceof-first with a method_exists fallback for the duration of the 5.x cycle:

    if ($migration instanceof ReversibleMigrationInterface) {
        // reversible dispatch (record-adapter wrapping unchanged)
    } elseif ($migration instanceof DirectionalMigrationInterface) {
        $direction === MigrationInterface::UP ? $migration->up() : $migration->down();
    } else {
        // legacy path: method_exists dispatch, unchanged from today
    }

    What this buys us in 5.next:

    • Static narrowing works for adopters; IDE autocomplete works on both interfaces.
    • The two Environment.php PHPStan 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.
    • No BC break. Markers don't force any signature on implementers, and the method_exists fallback 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_exists fallback 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 declaring implements ReversibleMigrationInterface will 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:

    AddMigrationCapabilityInterfaceRector

    • Scan every class extending Migrations\BaseMigration (recursively, so app-local intermediate bases are covered).
    • If the class defines change() → add implements ReversibleMigrationInterface and the matching use import.
    • If the class defines up() or down() → add implements DirectionalMigrationInterface and the matching use import.
    • Skip if a capability interface (or a subinterface of one) is already in the implements list.
    • Defensive: if a class somehow defines both change() and up()/down(), leave it alone and emit a warning so the user picks deliberately.

    NormalizeMigrationMethodSignatureRector

    • Ensure change() / up() / down() are public and have : void return 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.
    • Method body untouched.

    Bake template changes (not rector, but tied to the rollout)

    • New migrations emit the matching implements ... clause out of the box.
    • Document the change in the bake section of the cookbook alongside the upgrade guide.

    Manual work that remains after rector

    • Migrations not on BaseMigration (legacy Phinx AbstractMigration forks, custom bases that don't extend ours). Rare on 5.x but possible — these need a hand-edit.
    • Dynamically generated migration classes (factories, eval'd test fixtures). Rector can't see them; the implements clause has to be added at the generation site.
    • Custom base classes that themselves declare change() / up() / down() (e.g. AppMigration extends BaseMigration doing 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.
    • Cookbook and third-party documentation snippets. Tutorials and copy-pasted examples in the wild keep working via the method_exists fallback 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 in Environment and keeping the two baseline entries until 6.x — both acceptable for a soft window.

  6. markstory commented on May 24, 2026

    @markstory
    Member

    Not to get philosophical, but at my org we treat PHPStan fusses as important fixes.

    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.

  7. dereuromark commented on May 28, 2026

    @dereuromark
    MemberAuthor

    @markstory The PR is open for you to check and sign off on: #1086

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions