- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Database Playbook: Choosing the Right Store for Every System You Build
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a pull request titled "Add partially_refunded state to Order". The diff has one Doctrine migration adding an enum value to a Postgres column, one change to a PHP enum, and three new branches inside Order::canShip(). CI is green. The reviewer hits approve. You merge.
Two days later support pings: orders that were already in refunded are now showing up as unknown in the dashboard, and a queue worker is throwing on an old serialized order pulled out of Redis. The migration ran. The deploy went out. The data is still wrong.
You ran a schema migration. The change you actually shipped was an aggregate evolution. They have different invariants, rollback shapes, and blast radii. Treating them as one thing is how you end up there at 2am.
What each one is
A schema migration moves the database from one shape to another. ALTER TABLE orders ADD COLUMN currency CHAR(3) NOT NULL DEFAULT 'EUR'. CREATE INDEX. DROP COLUMN. The unit of change is the relation. The tooling is well understood: Doctrine Migrations, Phinx, raw SQL files, php artisan migrate. The success criterion is the schema now matches what the code expects.
An aggregate evolution changes the meaning of a domain object. Order used to be pending | confirmed | shipped | refunded. Now there is a fifth state — partially_refunded — and the rules for what transitions into it, what it allows, and how the rest of the system reads it are all new. The unit of change is the aggregate's behavior contract. The tooling is your codebase, your tests, and your in-flight data. The success criterion is every existing Order can still be loaded, mutated, and reasoned about — including the ones that were serialized before the change existed.
A schema migration touches storage. An aggregate evolution touches semantics. Most non-trivial changes are both, which is the trap.
The pure schema migration
Here is a clean one. You want to track currency on every Order. The domain already has a Money value object; the column was missing.
-- migrations/Version20260519000001.php (up)
ALTER TABLE orders
ADD COLUMN currency CHAR(3) NOT NULL DEFAULT 'EUR';
CREATE INDEX idx_orders_currency ON orders (currency);
<?php
declare(strict_types=1);
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260519000001 extends AbstractMigration
{
public function up(Schema $schema): void
{
$this->addSql(
'ALTER TABLE orders ADD COLUMN currency '
. "CHAR(3) NOT NULL DEFAULT 'EUR'"
);
$this->addSql(
'CREATE INDEX idx_orders_currency '
. 'ON orders (currency)'
);
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX idx_orders_currency');
$this->addSql('ALTER TABLE orders DROP COLUMN currency');
}
}
The properties of this change:
- Idempotent at the schema level. Run it twice and the second run fails loudly. Doctrine's migrations table tracks that.
-
Reversible. The
down()is a real inverse.DROP COLUMNgets you back. -
Default-safe. Every existing row gets
'EUR'. Nothing in the application breaks the moment after the migration finishes, because the schema has a sensible value for everyone. - Blast radius: the database. A bad migration locks a table. The fix is to roll it back. The data is intact.
You can ship this migration ahead of the code that reads currency, and ship the code reading currency ahead of any code that writes it. The classic expand-contract play. Your aggregate didn't move.
The aggregate evolution that looks like a migration
Now the bad one. Product comes back and says: "We need a partially_refunded state. An Order can be partially refunded after it ships if any line item is returned. A partially refunded order is still shippable for the unreturned items if they haven't gone out yet."
You write the migration that looks the same as before:
ALTER TYPE order_status ADD VALUE 'partially_refunded';
You change the enum:
<?php
declare(strict_types=1);
namespace App\Domain\Order;
enum OrderStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Shipped = 'shipped';
case Refunded = 'refunded';
case PartiallyRefunded = 'partially_refunded';
}
You patch canShip(). CI is green. You ship.
What you missed:
- Old serialized aggregates. Queue payloads in Redis, snapshots in an event store, JSON columns in a reporting table — anything serialized before this change has only the four-state vocabulary. Loading them is fine. Comparing their status against the new five-state domain is where it breaks.
-
Existing rows that should be
partially_refunded. There are orders in production right now where line items were returned in a separate flow. Their status column saysrefundedorshippedbecause that's all the schema allowed. The new state is meaningless until you backfill them. -
Code that reads the status enum. Every
match ($order->status)in the codebase compiles fine until it hits aPartiallyRefundedvalue at runtime and falls through to adefaultarm that throws or — worse — silently returnsfalse. -
Read-side projections. Dashboards, exports, the admin UI's filter dropdown, the analytics warehouse — they all have their own copy of the status vocabulary. None of them got migrated by your
ALTER TYPE.
The schema change took one line. The evolution is everything else.
The evolution playbook in PHP
The pattern that makes this safe has three moves: a domain change that accepts the old shape, a backfill that creates the new shape, and a read-fallback that translates anything missed.
1. Domain accepts both shapes
The aggregate's constructor and reconstitution path must not reject historic data. The new state is added; the old states still load.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use App\Domain\Money\Money;
final class Order
{
public function __construct(
public readonly OrderId $id,
public readonly CustomerId $customerId,
private OrderStatus $status,
private Money $total,
private Money $refundedAmount,
) {
}
public static function fromRow(array $row): self
{
$status = OrderStatus::tryFrom($row['status'])
?? OrderStatus::Pending;
$refunded = isset($row['refunded_amount_cents'])
? Money::ofMinor(
(int) $row['refunded_amount_cents'],
$row['currency'] ?? 'EUR',
)
: Money::zero($row['currency'] ?? 'EUR');
return new self(
id: OrderId::fromString($row['id']),
customerId: CustomerId::fromString($row['customer_id']),
status: $status,
total: Money::ofMinor(
(int) $row['total_cents'],
$row['currency'] ?? 'EUR',
),
refundedAmount: $refunded,
);
}
public function canShip(): bool
{
return match ($this->status) {
OrderStatus::Confirmed,
OrderStatus::PartiallyRefunded => true,
OrderStatus::Pending,
OrderStatus::Shipped,
OrderStatus::Refunded => false,
};
}
public function applyRefund(Money $amount): void
{
$newRefunded = $this->refundedAmount->plus($amount);
if ($newRefunded->isGreaterThan($this->total)) {
throw new \DomainException(
'Refund exceeds order total',
);
}
$this->refundedAmount = $newRefunded;
$this->status = $newRefunded->equals($this->total)
? OrderStatus::Refunded
: OrderStatus::PartiallyRefunded;
}
}
fromRow does not assume the column exists. tryFrom returns null for unknown values and the caller falls back to a default. applyRefund enforces the new invariant (partial means less than total, full means equal) and writes the correct status. Every match on OrderStatus covers all cases, so PHPStan or Psalm flags any arm you forgot to add, and an uncovered arm throws UnhandledMatchError the first time your tests hit it.
2. Backfill that creates the new shape
You need a script that walks existing rows and assigns the new state where it applies. This runs after the schema migration and before the read-fallback gets retired.
<?php
declare(strict_types=1);
namespace App\Migrations\Backfill;
use Doctrine\DBAL\Connection;
final class PartiallyRefundedBackfill
{
public function __construct(private Connection $db)
{
}
public function run(int $batchSize = 500): int
{
$touched = 0;
$rows = $this->db->fetchAllAssociative(
'SELECT id, total_cents, refunded_amount_cents
FROM orders
WHERE refunded_amount_cents > 0
AND refunded_amount_cents < total_cents
AND status <> ?',
['partially_refunded'],
);
foreach (array_chunk($rows, $batchSize) as $chunk) {
$ids = array_column($chunk, 'id');
$this->db->executeStatement(
'UPDATE orders
SET status = ?
WHERE id IN (?)',
['partially_refunded', $ids],
[\PDO::PARAM_STR, Connection::PARAM_STR_ARRAY],
);
$touched += count($chunk);
}
return $touched;
}
}
This is not a schema migration. It does not belong in migrations/. It is a one-shot domain backfill, run as a command, logged, idempotent (re-running it touches the same rows and produces the same answer), and audited. The output goes to a ticket.
The difference matters because schema migrations are part of the deploy pipeline; backfills are part of the release plan. A schema migration runs in seconds and blocks the deploy. A backfill runs in minutes or hours, in batches, and the deploy goes out without waiting for it.
3. Read-fallback for anything you missed
Some rows escape the backfill. Maybe they were created during the backfill window. Maybe the upstream system that writes refunds has a separate code path you forgot. Maybe an offline replica caught up after the script ran.
The repository handles those cases by reconstructing the correct state on read:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Doctrine;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepository;
use App\Domain\Order\OrderStatus;
use Doctrine\DBAL\Connection;
final class DoctrineOrderRepository implements OrderRepository
{
public function __construct(private Connection $db)
{
}
public function get(OrderId $id): Order
{
$row = $this->db->fetchAssociative(
'SELECT * FROM orders WHERE id = :id',
['id' => $id->toString()],
);
if ($row === false) {
throw new OrderNotFound($id);
}
$row = $this->reconcileStatus($row);
return Order::fromRow($row);
}
private function reconcileStatus(array $row): array
{
$refunded = (int) ($row['refunded_amount_cents'] ?? 0);
$total = (int) $row['total_cents'];
if ($refunded > 0 && $refunded < $total) {
$row['status'] = OrderStatus::PartiallyRefunded->value;
}
return $row;
}
}
The fallback is tactical. It exists for the weeks between the deploy and a clean backfill report. Once the backfill log shows zero rows touched on a fresh run, the fallback gets ripped out in a small PR that also removes a TODO.
Failure modes, side by side
| Aspect | Schema migration | Aggregate evolution |
|---|---|---|
| Unit of change | A table or column | A domain object's contract |
| Tool | Doctrine Migrations, Phinx, raw SQL | Code + backfill + tests |
| Reversible? | Yes, with a real down()
|
Rarely — you cannot un-think a state |
| In-flight data | DEFAULT clause handles it | Needs explicit backfill or read-fallback |
| Blast radius | The DB | Every consumer of the aggregate |
| Pre-flight check | Dry-run on staging schema | Type checker + property tests on real shapes |
| Rollback if wrong |
down() migration |
Feature flag the new state; backfill the inverse |
The shape of the rollback is the giveaway. When a schema migration is wrong, you run a migration to undo it. When an aggregate evolution is wrong, there is no migration that fixes it. You ship a code change that translates the broken intermediate state back to something sane, then backfill again.
Where this lands in a hex codebase
Push schema migrations into the adapter layer. They are an infrastructure concern; the domain doesn't know they exist. Doctrine migrations live next to the repository, get versioned with the deploy, and never import a domain type.
Aggregate evolutions live in the domain. The enum, the invariants, the new methods, the totality of the match arms — all of that is domain code. The repository's job is to translate persistence rows into a valid aggregate; the backfill's job is to make sure persistence rows can actually become valid aggregates without the repository having to guess.
When you separate them this way, the PR diff tells you what you actually shipped. A diff with only migrations/ changes? Schema only. A diff with migrations/, src/Domain/, a bin/backfill-*.php, and a reconcileStatus block? You are shipping an evolution and you owe the playbook.
The mistake at the top of this post — merging an evolution as if it were a migration — happens because the diff looked small. Three branches in canShip, one new enum case, one DDL. The expensive parts are the parts that aren't in the diff: the in-flight queue payloads, the read-side projections, the existing rows that match the new state's definition but are stored under the old vocabulary. Knowing which kind of change you are shipping is what makes you remember to look for them.
If this was useful
Most production bugs in framework-coupled PHP apps are the same shape as the one above: a change that looks like infrastructure but is actually domain. Decoupled PHP walks the full pattern — keeping the aggregate's behavior contract in the domain, treating Doctrine migrations as adapter detail, and giving evolutions a real playbook instead of a hope. If you spend time on long-lived PHP services, the chapter on transactions and the one on migrating a legacy Laravel service are where the book pays for itself.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)