Skip to content

5.next: Capability interfaces for reversible vs directional migrations#1086

Draft
dereuromark wants to merge 5 commits into
5.nextfrom
feature/capability-interfaces
Draft

5.next: Capability interfaces for reversible vs directional migrations#1086
dereuromark wants to merge 5 commits into
5.nextfrom
feature/capability-interfaces

Conversation

@dereuromark
Copy link
Copy Markdown
Member

@dereuromark dereuromark commented May 13, 2026

Refs #1084.

Soft-introduces the capability interfaces proposed in #1084 in the 5.x cycle so the 6.x BC-breaking step lands on top of an already-adopted shape.

What this changes

Two new marker interfaces under the Migrations namespace:

  • ReversibleMigrationInterface — for migrations defining a single change() method.
  • DirectionalMigrationInterface — for migrations defining separate up() and down() methods.

Method contracts are declared as PHPDoc method tags only (not real abstract methods):

namespace Migrations;

/**
 * @method void change()
 */
interface ReversibleMigrationInterface extends MigrationInterface
{
}

/**
 * @method void up()
 * @method void down()
 */
interface DirectionalMigrationInterface extends MigrationInterface
{
}

Migration\Environment::executeMigration now dispatches via instanceof first and keeps the existing method_exists() fallback for migrations that have not adopted the interfaces yet:

if ($migration instanceof ReversibleMigrationInterface) {
    // change() dispatch (with recording adapter for down)
} elseif ($migration instanceof DirectionalMigrationInterface) {
    $direction === MigrationInterface::UP ? $migration->up() : $migration->down();
} elseif (method_exists($migration, MigrationInterface::CHANGE)) {
    // legacy reversible fallback (5.x only)
} elseif (method_exists($migration, $direction)) {
    // legacy directional fallback (5.x only)
}

Bake templates (skeleton, skeleton-anonymous, diff, snapshot) now emit the matching implements clause for newly generated migrations.

A rector rule (Migrations\Rector\AddMigrationCapabilityInterfaceRector) is shipped for the upgrade — it walks every BaseMigration subclass in a user's config/Migrations folder and adds the right implements clause. Abstract bases and anonymous migration classes are both supported.

A dedicated upgrade guide at docs/en/upgrades/upgrading-to-capability-interfaces.md documents the motivation, per-app before/after examples, the rector-driven upgrade, manual residuals, and the 6.x direction.

Framing note

  • Typo detection. A user defining function chnage() still silently no-ops today; once 6.x promotes the PHPDoc method tags to real abstract methods this becomes a static error.
  • IDE and static analysis support. Once narrowed via instanceof, IDEs and PHPStan resolve change() / up() / down() directly.
  • Semantic clarity. A migration is either reversible or directional. Today both styles are dispatched through the same opaque method_exists lookup.
  • Custom runners wrapping MigrationInterface no longer need reflection to figure out the migration style.

Why marker interfaces with PHPDoc method tags only

This is the soft-window shape. Marker interfaces mean:

  • No signature is forced on the implementer — implements ReversibleMigrationInterface works even on a migration whose change() has a non-matching signature. Zero BC pressure on existing code.
  • Static analyzers and IDEs still resolve the methods via the PHPDoc tags once instanceof narrows the type.
  • The 6.x step is mechanical: promote the method tags to real abstract method declarations and drop the method_exists fallback. The BC break in 6.x then only bites users who adopted the interface in 5.x but mistyped the method signature — a much smaller cohort than today's proposal.

Per-app upgrade

For migrations defining change():

use Migrations\BaseMigration;
use Migrations\ReversibleMigrationInterface;

class CreateProducts extends BaseMigration implements ReversibleMigrationInterface
{
    public function change(): void { /* ... */ }
}

For migrations defining up() / down():

use Migrations\BaseMigration;
use Migrations\DirectionalMigrationInterface;

class BackfillOrderTotals extends BaseMigration implements DirectionalMigrationInterface
{
    public function up(): void { /* ... */ }
    public function down(): void { /* ... */ }
}

Or run rector once:

vendor/bin/rector process --config=rector.php

with a rector.php like:

use Migrations\Rector\AddMigrationCapabilityInterfaceRector;
use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/config/Migrations'])
    ->withRules([AddMigrationCapabilityInterfaceRector::class]);

Full walkthrough including manual residuals (Phinx-style bases, dynamically generated classes, third-party plugins) is in the new upgrade guide.

Forward direction (6.x)

A follow-up 6.x PR will:

  • Promote the PHPDoc method tags on both interfaces to real abstract method declarations.
  • Remove the method_exists() fallback in Environment.

Apps that ran rector during 5.x see the 6.x bump as a no-op for their migration files.

Notes for reviewers

  • The Environment dispatch order is Reversible → Directional → legacy change → legacy up/down. The directional interface check comes before the legacy change fallback so that a class implementing DirectionalMigrationInterface that also defines change() is dispatched via up()/down() — the interface declaration wins over method existence. Test coverage for this in EnvironmentTest::testDirectionalInterfaceWinsOverChangeMethod.
  • All 43 comparison fixtures under tests/comparisons/ were regenerated to match the new bake output. No content change beyond the new implements clause and matching use import.
  • The PHPDoc method tags on the new interfaces are void returns. The 6.x abstract method declaration should match.
  • The rector rule does not run as part of CI — it is upgrade tooling for downstream apps. It does respect abstract bases (so the interface propagates to leaves) and anonymous migration classes.

Introduce two marker interfaces so migrations can declare their style
explicitly:

- ReversibleMigrationInterface for migrations defining a single change()
  method (handled with the recording adapter for the down direction).
- DirectionalMigrationInterface for migrations defining separate up() and
  down() methods.

Environment now dispatches via instanceof first and keeps the existing
method_exists fallback for migrations that have not adopted either
interface yet, so the change is backwards compatible. The interfaces
declare their method contracts via PHPDoc method tags only (not as real
abstract methods), so adopting them is a single-line implements addition
with no signature validation pressure.

The 6.x release is expected to promote the PHPDoc method tags to real
abstract method declarations and drop the method_exists fallback.
Bake-generated migrations now declare implements ReversibleMigrationInterface
(change-style) or implements DirectionalMigrationInterface (up/down-style)
out of the box.

Updated templates:

- skeleton.twig (reversible)
- skeleton-anonymous.twig (reversible)
- diff.twig (directional)
- snapshot.twig (reversible or directional based on useChange)

All bake comparison fixtures under tests/comparisons/ are updated to match
the new output so the bake tests stay green.
Ship a rector rule that retrofits the capability interfaces onto existing
migrations during the 5.x upgrade. For every class that extends BaseMigration
(directly or transitively):

- If the class defines change(), add implements ReversibleMigrationInterface.
- If the class defines up() or down(), add implements DirectionalMigrationInterface.
- Classes already implementing either interface are skipped.
- Classes defining both styles are left alone, since the choice is a deliberate
  one.

Abstract migration bases and anonymous migration classes are both supported
so the interface propagates through inheritance and through bake's anonymous
migration shape.
Add a dedicated upgrade guide at docs/en/upgrades/upgrading-to-capability-interfaces.md
covering motivation, per-app before/after examples for both styles, the
rector-driven automatic upgrade, the manual residuals (Phinx-style bases,
dynamically generated classes, third-party plugins), and the 6.x forward
direction.

Wire the new page into the VitePress sidebar (toc_en.json) and update the
Migration Methods guide so the inline examples already include the
implements clause and link to the upgrade guide.
The rector rule extends rector/rector base classes which are installed
on-demand through the rector-setup composer script rather than as a
permanent dev dependency. PHPStan runs before that script in CI and
therefore cannot resolve AbstractRector or the Symplify value objects.
The rule is type-checked by rector itself when invoked, and downstream
apps that wire it into their own rector.php have rector installed
locally, so excluding the directory here is the minimal fix that keeps
the existing on-demand dependency pattern intact.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant