Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/.vitepress/toc_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"collapsed": true,
"items": [
{ "text": "Upgrading from 4.x to 5.x", "link": "/upgrades/upgrading-from-4-x" },
{ "text": "Upgrading to the Builtin Backend", "link": "/upgrades/upgrading-to-builtin-backend" }
{ "text": "Upgrading to the Builtin Backend", "link": "/upgrades/upgrading-to-builtin-backend" },
{ "text": "Upgrading to Capability Interfaces", "link": "/upgrades/upgrading-to-capability-interfaces" }
]
}
]
Expand Down
23 changes: 20 additions & 3 deletions docs/en/guides/writing-migrations/migration-methods.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Migration Methods

A migration declares its style by implementing one of two capability interfaces:

- `Migrations\ReversibleMigrationInterface` for migrations that define a
single `change()` method.
- `Migrations\DirectionalMigrationInterface` for migrations that define
separate `up()` and `down()` methods.

A migration implements **one** of the two, never both. Bake-generated
migrations already include the right `implements` clause. Migrations from
older versions of cakephp/migrations keep working without the interface
through a `method_exists()` fallback; see
[Upgrading to Capability Interfaces](/upgrades/upgrading-to-capability-interfaces)
for the adoption path.

## The Change Method

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

use Migrations\BaseMigration;
use Migrations\ReversibleMigrationInterface;

class CreateUserLoginsTable extends BaseMigration
class CreateUserLoginsTable extends BaseMigration implements ReversibleMigrationInterface
{
public function change(): void
{
Expand Down Expand Up @@ -57,8 +72,9 @@ direction. For example:
<?php

use Migrations\BaseMigration;
use Migrations\ReversibleMigrationInterface;

class CreateUserLoginsTable extends BaseMigration
class CreateUserLoginsTable extends BaseMigration implements ReversibleMigrationInterface
{
public function change(): void
{
Expand Down Expand Up @@ -111,8 +127,9 @@ from within your database migration:
<?php

use Migrations\BaseMigration;
use Migrations\DirectionalMigrationInterface;

class MyNewMigration extends BaseMigration
class MyNewMigration extends BaseMigration implements DirectionalMigrationInterface
{
public function up(): void
{
Expand Down
212 changes: 212 additions & 0 deletions docs/en/upgrades/upgrading-to-capability-interfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
# Upgrading to Capability Interfaces

Starting with 5.2.0, cakephp/migrations ships two capability interfaces that let migrations declare their style explicitly:

- `Migrations\ReversibleMigrationInterface` — for migrations that define a single reversible `change()` method.
- `Migrations\DirectionalMigrationInterface` — for migrations that define separate `up()` and `down()` methods.

A migration implements **one** of the two interfaces, never both.

## Why this exists

Until now, `Environment` dispatched migrations through `method_exists()` checks against `change()`, `up()`, and `down()`. This works at runtime but has a few drawbacks:

- 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.
- IDEs and static analyzers cannot resolve `change()` / `up()` / `down()` on a generic `MigrationInterface`, so refactoring tools and PHPStan narrowing do not work.
- Custom runners that wrap `MigrationInterface` have to use reflection to figure out the migration style.

The capability interfaces are a way out of this without breaking existing code:

- `Environment` now dispatches via `instanceof` first and falls back to `method_exists()` for migrations that have not adopted the interfaces yet.
- 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.

::: tip 5.x is a soft window
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.
:::

## What changed for app developers

If you do nothing, your existing migrations keep working. `Environment` retains a `method_exists()` fallback throughout the 5.x cycle.

Adopting the interfaces now is recommended because:

- New bakes already emit the right `implements` clause.
- Static analysis and IDE autocomplete start working on your migrations.
- Your app is upgrade-ready when 6.x lands.

## Per-app upgrade — manual edits

### Reversible migration (defines `change()`)

Before:

```php
<?php
declare(strict_types=1);

use Migrations\BaseMigration;

class CreateProducts extends BaseMigration
{
public function change(): void
{
$this->table('products')
->addColumn('name', 'string')
->create();
}
}
```

After:

```php
<?php
declare(strict_types=1);

use Migrations\BaseMigration;
use Migrations\ReversibleMigrationInterface;

class CreateProducts extends BaseMigration implements ReversibleMigrationInterface
{
public function change(): void
{
$this->table('products')
->addColumn('name', 'string')
->create();
}
}
```

### Directional migration (defines `up()` and `down()`)

Before:

```php
<?php
declare(strict_types=1);

use Migrations\BaseMigration;

class BackfillOrderTotals extends BaseMigration
{
public function up(): void
{
$this->execute('UPDATE orders SET total = ...');
}

public function down(): void
{
$this->execute('UPDATE orders SET total = NULL');
}
}
```

After:

```php
<?php
declare(strict_types=1);

use Migrations\BaseMigration;
use Migrations\DirectionalMigrationInterface;

class BackfillOrderTotals extends BaseMigration implements DirectionalMigrationInterface
{
public function up(): void
{
$this->execute('UPDATE orders SET total = ...');
}

public function down(): void
{
$this->execute('UPDATE orders SET total = NULL');
}
}
```

### Anonymous migrations

Anonymous migrations get the same treatment:

```php
return new class extends BaseMigration implements ReversibleMigrationInterface
{
public function change(): void
{
}
};
```

## Automated upgrade with rector

cakephp/migrations ships a rector rule that adds the right `implements` clause to every migration in your `config/Migrations/` folder.

Add the following to your `rector.php`:

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

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

Then run rector:

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

What the rule does:

- For every class extending `Migrations\BaseMigration` (directly or transitively):
- If the class defines `change()`, add `implements ReversibleMigrationInterface`.
- If the class defines `up()` or `down()`, add `implements DirectionalMigrationInterface`.
- Classes that already implement either capability interface are skipped.
- Classes that define both `change()` and `up()`/`down()` are skipped — these are user errors that need a deliberate decision.

### Optional: combine with built-in rector rules

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:

```php
use Migrations\Rector\AddMigrationCapabilityInterfaceRector;
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\SetList;

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

::: warning Only point rector at your migrations folder
Migration paths use scoped rector configs by default; pointing rector at `src/` or `tests/` will apply unrelated transformations. Keep the path list narrow.
:::

## Manual work that remains after rector

- **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.
- **Dynamically generated migration classes** (eval'd test fixtures, factories). Rector cannot see them. Add the `implements` clause at the generation site.
- **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.
- **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.

## Forward direction

The 6.x release is expected to:

- Promote the PHPDoc `@method` declarations on the capability interfaces to real abstract method declarations.
- Remove the `method_exists()` fallback in `Environment`.

Running rector now means the 6.x bump is a no-op for your migration files.
6 changes: 6 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ parameters:
level: 8
paths:
- src/
excludePaths:
# rector/rector is installed on-demand via the rector-setup composer
# script, so PHPStan cannot resolve AbstractRector or the Symplify
# value objects during the main analysis run. The rule is autoloaded
# for downstream apps and type-checked by rector itself when invoked.
- src/Rector/
bootstrapFiles:
- tests/bootstrap.php
ignoreErrors:
Expand Down
32 changes: 32 additions & 0 deletions src/DirectionalMigrationInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);

/**
* MIT License
* For full license information, please view the LICENSE file that was distributed with this source code.
*/

namespace Migrations;

/**
* Marker interface for migrations that define separate `up()` and `down()` methods.
*
* When implemented, `Migrations\Migration\Environment` dispatches the migration via
* `up()` or `down()` depending on the direction.
*
* In 5.x the method contracts are declared via PHPDoc only and are not enforced at
* the type system level — implementations are still discovered through `method_exists`
* for compatibility. The PHPDoc method tags exist so static analyzers and IDEs
* resolve `up()` / `down()` once the interface is asserted via `instanceof`. The
* 6.x release is expected to promote `up()` and `down()` to real abstract methods
* on this interface.
*
* A migration implements either this interface or {@see ReversibleMigrationInterface},
* never both.
*
* @method void up() Apply the schema change.
* @method void down() Revert the schema change.
*/
interface DirectionalMigrationInterface extends MigrationInterface
{
}
15 changes: 13 additions & 2 deletions src/Migration/Environment.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
use Cake\Datasource\ConnectionManager;
use Migrations\Db\Adapter\AdapterFactory;
use Migrations\Db\Adapter\AdapterInterface;
use Migrations\DirectionalMigrationInterface;
use Migrations\MigrationInterface;
use Migrations\ReversibleMigrationInterface;
use Migrations\SeedInterface;
use RuntimeException;

Expand Down Expand Up @@ -74,8 +76,15 @@ public function executeMigration(MigrationInterface $migration, string $directio
}

if (!$fake) {
// Run the migration
if (method_exists($migration, MigrationInterface::CHANGE)) {
// Run the migration. Dispatch order: capability interfaces first
// (statically narrowable for IDEs and static analysis), then a
// method_exists fallback for migrations that haven't yet adopted
// either ReversibleMigrationInterface or DirectionalMigrationInterface.
$isReversible = $migration instanceof ReversibleMigrationInterface
|| (!$migration instanceof DirectionalMigrationInterface
&& method_exists($migration, MigrationInterface::CHANGE));

if ($isReversible) {
if ($direction === MigrationInterface::DOWN) {
// Create an instance of the RecordingAdapter so we can record all
// of the migration commands for reverse playback
Expand All @@ -94,6 +103,8 @@ public function executeMigration(MigrationInterface $migration, string $directio
} else {
$migration->{MigrationInterface::CHANGE}();
}
} elseif ($migration instanceof DirectionalMigrationInterface) {
$direction === MigrationInterface::UP ? $migration->up() : $migration->down();
} elseif (method_exists($migration, $direction)) {
$migration->{$direction}();
}
Expand Down
Loading
Loading