|
| 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. |
0 commit comments