Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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.next, 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