DEV Community

Cover image for Schema Migrations vs Aggregate Evolution: Two Different Problems
Gabriel Anhaia
Gabriel Anhaia

Posted on

Schema Migrations vs Aggregate Evolution: Two Different Problems


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.

Schema migrations renovate the room; aggregate evolutions rewrite the script the people in the room are following

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);
Enter fullscreen mode Exit fullscreen mode
<?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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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 COLUMN gets 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';
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

You patch canShip(). CI is green. You ship.

What you missed:

  1. 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.
  2. 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 says refunded or shipped because that's all the schema allowed. The new state is meaningless until you backfill them.
  3. Code that reads the status enum. Every match ($order->status) in the codebase compiles fine until it hits a PartiallyRefunded value at runtime and falls through to a default arm that throws or — worse — silently returns false.
  4. 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

A schema migration is a one-shot DDL. An aggregate evolution is domain change, backfill, and read-fallback together

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)