Skip to content

Commit 67044f1

Browse files
5.next: Capability interfaces for reversible vs directional migrations (#1086)
* Add ReversibleMigrationInterface and DirectionalMigrationInterface 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. * Emit capability interface in bake templates 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. * Add AddMigrationCapabilityInterfaceRector 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. * Document capability interfaces and the upgrade path 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. * Exclude src/Rector/ from PHPStan analysis 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. * Update docs/en/upgrades/upgrading-to-capability-interfaces.md Co-authored-by: Mark Story <mark@mark-story.com> * Match parent visibility of buildOptionParser() The buildOptionParser() overrides in BakeMigrationCommand and the CustomBakeMigrationDiffCommand test command were public, while the parent Command::buildOptionParser() and every other command in the package declare it protected. Rector's MakeInheritedMethodVisibilitySameAsParentRule flagged the mismatch, failing the cs-stan CI job. Align both to protected. * Fix capability-interface Rector rule edge cases Two correctness gaps in AddMigrationCapabilityInterfaceRector surfaced in review: - The rule only checked the target interface, so a class already implementing DirectionalMigrationInterface that also defined change() had ReversibleMigrationInterface added on top, producing a class implementing both mutually exclusive interfaces. Now skip any class already implementing either capability interface, keeping the rule idempotent and honoring the documented "skip if already annotated" behavior. - Directional adoption used "up() OR down()", so a one-way migration (only up() or only down()) had DirectionalMigrationInterface added. Environment calls the matching direction unconditionally for interface-based migrations, so rolling back such a migration turned a previous method_exists() no-op into a fatal undefined-method error. Only adopt the directional interface when both up() and down() exist; one-way migrations stay on the runtime fallback. Mixed-style detection (change() plus any directional method) is preserved. Docblock and the upgrade guide updated to match. --------- Co-authored-by: Mark Story <mark@mark-story.com>
1 parent 369d849 commit 67044f1

58 files changed

Lines changed: 729 additions & 55 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/.vitepress/toc_en.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
"collapsed": true,
3838
"items": [
3939
{ "text": "Upgrading from 4.x to 5.x", "link": "/upgrades/upgrading-from-4-x" },
40-
{ "text": "Upgrading to the Builtin Backend", "link": "/upgrades/upgrading-to-builtin-backend" }
40+
{ "text": "Upgrading to the Builtin Backend", "link": "/upgrades/upgrading-to-builtin-backend" },
41+
{ "text": "Upgrading to Capability Interfaces", "link": "/upgrades/upgrading-to-capability-interfaces" }
4142
]
4243
}
4344
]

‎docs/en/guides/writing-migrations/migration-methods.md‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Migration Methods
22

3+
A migration declares its style by implementing one of two capability interfaces:
4+
5+
- `Migrations\ReversibleMigrationInterface` for migrations that define a
6+
single `change()` method.
7+
- `Migrations\DirectionalMigrationInterface` for migrations that define
8+
separate `up()` and `down()` methods.
9+
10+
A migration implements **one** of the two, never both. Bake-generated
11+
migrations already include the right `implements` clause. Migrations from
12+
older versions of cakephp/migrations keep working without the interface
13+
through a `method_exists()` fallback; see
14+
[Upgrading to Capability Interfaces](/upgrades/upgrading-to-capability-interfaces)
15+
for the adoption path.
16+
317
## The Change Method
418

519
Migrations supports 'reversible migrations'. In many scenarios, you only need
@@ -10,8 +24,9 @@ rollback operations for you. For example:
1024
<?php
1125

1226
use Migrations\BaseMigration;
27+
use Migrations\ReversibleMigrationInterface;
1328

14-
class CreateUserLoginsTable extends BaseMigration
29+
class CreateUserLoginsTable extends BaseMigration implements ReversibleMigrationInterface
1530
{
1631
public function change(): void
1732
{
@@ -57,8 +72,9 @@ direction. For example:
5772
<?php
5873

5974
use Migrations\BaseMigration;
75+
use Migrations\ReversibleMigrationInterface;
6076

61-
class CreateUserLoginsTable extends BaseMigration
77+
class CreateUserLoginsTable extends BaseMigration implements ReversibleMigrationInterface
6278
{
6379
public function change(): void
6480
{
@@ -111,8 +127,9 @@ from within your database migration:
111127
<?php
112128

113129
use Migrations\BaseMigration;
130+
use Migrations\DirectionalMigrationInterface;
114131

115-
class MyNewMigration extends BaseMigration
132+
class MyNewMigration extends BaseMigration implements DirectionalMigrationInterface
116133
{
117134
public function up(): void
118135
{
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# Upgrading to Capability Interfaces
2+
3+
Starting with 5.2.0, cakephp/migrations ships two capability interfaces that let migrations declare their style explicitly:
4+
5+
- `Migrations\ReversibleMigrationInterface` — for migrations that define a single reversible `change()` method.
6+
- `Migrations\DirectionalMigrationInterface` — for migrations that define separate `up()` and `down()` methods.
7+
8+
A migration implements **one** of the two interfaces, never both.
9+
10+
## Why this exists
11+
12+
Until now, `Environment` dispatched migrations through `method_exists()` checks against `change()`, `up()`, and `down()`. This works at runtime but has a few drawbacks:
13+
14+
- A typo such as `function chnage()` silently no-ops at run time. The runtime cannot tell whether the migration is reversible or directional, so it just does nothing.
15+
- IDEs and static analyzers cannot resolve `change()` / `up()` / `down()` on a generic `MigrationInterface`, so refactoring tools and PHPStan narrowing do not work.
16+
- Custom runners that wrap `MigrationInterface` have to use reflection to figure out the migration style.
17+
18+
The capability interfaces are a way out of this without breaking existing code:
19+
20+
- `Environment` now dispatches via `instanceof` first and falls back to `method_exists()` for migrations that have not adopted the interfaces yet.
21+
- The interfaces declare their method contracts as PHPDoc `@method` tags (not as real abstract methods), which means adding `implements` to an existing migration is a **zero-friction** change — your method signature is not validated against an abstract.
22+
23+
::: tip 5.x is a soft window
24+
The PHPDoc-only contract is intentional. The 6.x release is expected to promote the `@method` tags to real abstract method declarations, at which point a missing or mistyped `change()` / `up()` / `down()` becomes a static error. The 5.next release gives you a runway to adopt the interfaces without breakage; the 6.x release tightens the contract.
25+
:::
26+
27+
## What changed for app developers
28+
29+
If you do nothing, your existing migrations keep working. `Environment` retains a `method_exists()` fallback throughout the 5.x cycle.
30+
31+
Adopting the interfaces now is recommended because:
32+
33+
- New bakes already emit the right `implements` clause.
34+
- Static analysis and IDE autocomplete start working on your migrations.
35+
- Your app is upgrade-ready when 6.x lands.
36+
37+
## Per-app upgrade — manual edits
38+
39+
### Reversible migration (defines `change()`)
40+
41+
Before:
42+
43+
```php
44+
<?php
45+
declare(strict_types=1);
46+
47+
use Migrations\BaseMigration;
48+
49+
class CreateProducts extends BaseMigration
50+
{
51+
public function change(): void
52+
{
53+
$this->table('products')
54+
->addColumn('name', 'string')
55+
->create();
56+
}
57+
}
58+
```
59+
60+
After:
61+
62+
```php
63+
<?php
64+
declare(strict_types=1);
65+
66+
use Migrations\BaseMigration;
67+
use Migrations\ReversibleMigrationInterface;
68+
69+
class CreateProducts extends BaseMigration implements ReversibleMigrationInterface
70+
{
71+
public function change(): void
72+
{
73+
$this->table('products')
74+
->addColumn('name', 'string')
75+
->create();
76+
}
77+
}
78+
```
79+
80+
### Directional migration (defines `up()` and `down()`)
81+
82+
Before:
83+
84+
```php
85+
<?php
86+
declare(strict_types=1);
87+
88+
use Migrations\BaseMigration;
89+
90+
class BackfillOrderTotals extends BaseMigration
91+
{
92+
public function up(): void
93+
{
94+
$this->execute('UPDATE orders SET total = ...');
95+
}
96+
97+
public function down(): void
98+
{
99+
$this->execute('UPDATE orders SET total = NULL');
100+
}
101+
}
102+
```
103+
104+
After:
105+
106+
```php
107+
<?php
108+
declare(strict_types=1);
109+
110+
use Migrations\BaseMigration;
111+
use Migrations\DirectionalMigrationInterface;
112+
113+
class BackfillOrderTotals extends BaseMigration implements DirectionalMigrationInterface
114+
{
115+
public function up(): void
116+
{
117+
$this->execute('UPDATE orders SET total = ...');
118+
}
119+
120+
public function down(): void
121+
{
122+
$this->execute('UPDATE orders SET total = NULL');
123+
}
124+
}
125+
```
126+
127+
### Anonymous migrations
128+
129+
Anonymous migrations get the same treatment:
130+
131+
```php
132+
return new class extends BaseMigration implements ReversibleMigrationInterface
133+
{
134+
public function change(): void
135+
{
136+
}
137+
};
138+
```
139+
140+
## Automated upgrade with rector
141+
142+
cakephp/migrations ships a rector rule that adds the right `implements` clause to every migration in your `config/Migrations/` folder.
143+
144+
Add the following to your `rector.php`:
145+
146+
```php
147+
use Migrations\Rector\AddMigrationCapabilityInterfaceRector;
148+
use Rector\Config\RectorConfig;
149+
150+
return RectorConfig::configure()
151+
->withPaths([
152+
__DIR__ . '/config/Migrations',
153+
])
154+
->withRules([
155+
AddMigrationCapabilityInterfaceRector::class,
156+
]);
157+
```
158+
159+
Then run rector:
160+
161+
```bash
162+
vendor/bin/rector process --config=rector.php
163+
```
164+
165+
What the rule does:
166+
167+
- For every class extending `Migrations\BaseMigration` (directly or transitively):
168+
- If the class defines `change()`, add `implements ReversibleMigrationInterface`.
169+
- If the class defines both `up()` and `down()`, add `implements DirectionalMigrationInterface`.
170+
- One-way migrations that define only `up()` or only `down()` are left untouched. They keep working through the `method_exists()` fallback; adding `DirectionalMigrationInterface` would make `Environment` call the missing direction unconditionally and turn a rollback no-op into a fatal error.
171+
- Classes that already implement either capability interface are skipped.
172+
- Classes that define both `change()` and `up()`/`down()` are skipped — these are user errors that need a deliberate decision.
173+
174+
### Optional: combine with built-in rector rules
175+
176+
If you also want to normalize visibility and return types on your migration methods (the shape 6.x will expect), compose with rector's built-in sets:
177+
178+
```php
179+
use Migrations\Rector\AddMigrationCapabilityInterfaceRector;
180+
use Rector\Config\RectorConfig;
181+
use Rector\Set\ValueObject\SetList;
182+
183+
return RectorConfig::configure()
184+
->withPaths([
185+
__DIR__ . '/config/Migrations',
186+
])
187+
->withRules([
188+
AddMigrationCapabilityInterfaceRector::class,
189+
])
190+
->withSets([
191+
SetList::TYPE_DECLARATION,
192+
]);
193+
```
194+
195+
::: warning Only point rector at your migrations folder
196+
Migration paths use scoped rector configs by default; pointing rector at `src/` or `tests/` will apply unrelated transformations. Keep the path list narrow.
197+
:::
198+
199+
## Manual work that remains after rector
200+
201+
- **Migrations not on `BaseMigration`.** Anything still on a legacy Phinx `AbstractMigration` fork or a custom base that does not extend `BaseMigration` is skipped. Add the `implements` clause by hand.
202+
- **Dynamically generated migration classes** (eval'd test fixtures, factories). Rector cannot see them. Add the `implements` clause at the generation site.
203+
- **Custom base classes that themselves declare `change()` / `up()` / `down()`.** Rector adds the interface to the base class once. If the base lives in a third-party plugin you do not control, either implement the capability interface on the leaf class or PR the plugin upstream.
204+
- **Bake-generated migrations from older versions.** Bake templates emit the `implements` clause out of the box from 5.next; older bakes do not. Rector cleans those up in one pass.
205+
206+
## Forward direction
207+
208+
The 6.x release is expected to:
209+
210+
- Promote the PHPDoc `@method` declarations on the capability interfaces to real abstract method declarations.
211+
- Remove the `method_exists()` fallback in `Environment`.
212+
213+
Running rector now means the 6.x bump is a no-op for your migration files.

‎phpstan.neon‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ parameters:
55
level: 8
66
paths:
77
- src/
8+
excludePaths:
9+
# rector/rector is installed on-demand via the rector-setup composer
10+
# script, so PHPStan cannot resolve AbstractRector or the Symplify
11+
# value objects during the main analysis run. The rule is autoloaded
12+
# for downstream apps and type-checked by rector itself when invoked.
13+
- src/Rector/
814
bootstrapFiles:
915
- tests/bootstrap.php
1016
ignoreErrors:

‎src/Command/BakeMigrationCommand.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ public function getOptionParser(): ConsoleOptionParser
245245
/**
246246
* @inheritDoc
247247
*/
248-
public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
248+
protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
249249
{
250250
$parser = parent::buildOptionParser($parser);
251251

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* MIT License
6+
* For full license information, please view the LICENSE file that was distributed with this source code.
7+
*/
8+
9+
namespace Migrations;
10+
11+
/**
12+
* Marker interface for migrations that define separate `up()` and `down()` methods.
13+
*
14+
* When implemented, `Migrations\Migration\Environment` dispatches the migration via
15+
* `up()` or `down()` depending on the direction.
16+
*
17+
* In 5.x the method contracts are declared via PHPDoc only and are not enforced at
18+
* the type system level — implementations are still discovered through `method_exists`
19+
* for compatibility. The PHPDoc method tags exist so static analyzers and IDEs
20+
* resolve `up()` / `down()` once the interface is asserted via `instanceof`. The
21+
* 6.x release is expected to promote `up()` and `down()` to real abstract methods
22+
* on this interface.
23+
*
24+
* A migration implements either this interface or {@see ReversibleMigrationInterface},
25+
* never both.
26+
*
27+
* @method void up() Apply the schema change.
28+
* @method void down() Revert the schema change.
29+
*/
30+
interface DirectionalMigrationInterface extends MigrationInterface
31+
{
32+
}

‎src/Migration/Environment.php‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
use Cake\Datasource\ConnectionManager;
1313
use Migrations\Db\Adapter\AdapterFactory;
1414
use Migrations\Db\Adapter\AdapterInterface;
15+
use Migrations\DirectionalMigrationInterface;
1516
use Migrations\MigrationInterface;
17+
use Migrations\ReversibleMigrationInterface;
1618
use Migrations\SeedInterface;
1719
use RuntimeException;
1820

@@ -74,8 +76,15 @@ public function executeMigration(MigrationInterface $migration, string $directio
7476
}
7577

7678
if (!$fake) {
77-
// Run the migration
78-
if (method_exists($migration, MigrationInterface::CHANGE)) {
79+
// Run the migration. Dispatch order: capability interfaces first
80+
// (statically narrowable for IDEs and static analysis), then a
81+
// method_exists fallback for migrations that haven't yet adopted
82+
// either ReversibleMigrationInterface or DirectionalMigrationInterface.
83+
$isReversible = $migration instanceof ReversibleMigrationInterface
84+
|| (!$migration instanceof DirectionalMigrationInterface
85+
&& method_exists($migration, MigrationInterface::CHANGE));
86+
87+
if ($isReversible) {
7988
if ($direction === MigrationInterface::DOWN) {
8089
// Create an instance of the RecordingAdapter so we can record all
8190
// of the migration commands for reverse playback
@@ -94,6 +103,8 @@ public function executeMigration(MigrationInterface $migration, string $directio
94103
} else {
95104
$migration->{MigrationInterface::CHANGE}();
96105
}
106+
} elseif ($migration instanceof DirectionalMigrationInterface) {
107+
$direction === MigrationInterface::UP ? $migration->up() : $migration->down();
97108
} elseif (method_exists($migration, $direction)) {
98109
$migration->{$direction}();
99110
}

0 commit comments

Comments
 (0)