DEV Community

Cover image for Migrate Eloquent Models to Domain Entities Without a Big-Bang Rewrite
Gabriel Anhaia
Gabriel Anhaia

Posted on

Migrate Eloquent Models to Domain Entities Without a Big-Bang Rewrite


Every team that wants out of Eloquent has the same first meeting. Someone draws a four-ring diagram. Someone else estimates four weeks. Two months later the migration branch has a hundred conflicts a day, the feature team is shipping around it on main, and the architecture lead is the only person who still believes the rewrite will land.

You don't need that meeting. You can move off extends Model in your domain code without freezing the codebase, without a long-lived branch, and without telling product to wait. The trick is treating Eloquent the way it already wants to be treated: as a persistence library that lives at the edge. You keep it, wrap it, and hide it behind a port. Then, feature by feature, you retire it from the parts of the code that should never have known about it.

This post is the playbook. One worked example, PHP 8.3, Laravel 11, no rewrite.

Eloquent model molting into a plain domain entity while the database wiring stays attached

The starting point: an Eloquent model doing five jobs

Pick the model in your app that hurts the most. The one used in eight controllers, three jobs, a console command, and a Blade view. For this walkthrough, that model is Order.

// app/Models/Order.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    protected $fillable = [
        'customer_id', 'status', 'total_cents', 'currency',
    ];

    protected $casts = [
        'total_cents' => 'integer',
        'placed_at'   => 'datetime',
    ];

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }

    public function confirm(): void
    {
        if ($this->status !== 'pending') {
            throw new \DomainException('not pending');
        }
        $this->status    = 'confirmed';
        $this->placed_at = now();
        $this->save();
    }

    public function totalForDisplay(): string
    {
        return number_format($this->total_cents / 100, 2)
            . ' ' . strtoupper($this->currency);
    }
}
Enter fullscreen mode Exit fullscreen mode

That single class is doing five jobs: table mapping, hydration, query API, business rule (confirm), and view formatting. Controllers reach in for $order->confirm(). Jobs reach in for Order::find($id). A Blade partial calls $order->totalForDisplay(). There is no seam.

You're not deleting this file. You're moving the business rule (confirm) somewhere that doesn't extend Model, and pointing controllers and jobs at that somewhere. Eloquent stays. It just stops being the thing the application code calls.

Step 1: define the domain entity next to the model, not instead of it

Create a parallel directory. Do not touch app/Models. Do not delete anything. Pure addition.

// src/Sales/Domain/Order.php
declare(strict_types=1);

namespace App\Sales\Domain;

use DateTimeImmutable;

final class Order
{
    public function __construct(
        public readonly OrderId $id,
        public readonly CustomerId $customerId,
        private OrderStatus $status,
        private readonly Money $total,
        private ?DateTimeImmutable $placedAt,
    ) {}

    public function confirm(DateTimeImmutable $at): void
    {
        if ($this->status !== OrderStatus::Pending) {
            throw new OrderNotPending($this->id);
        }
        $this->status   = OrderStatus::Confirmed;
        $this->placedAt = $at;
    }

    public function status(): OrderStatus       { return $this->status; }
    public function total(): Money              { return $this->total; }
    public function placedAt(): ?DateTimeImmutable { return $this->placedAt; }
}
Enter fullscreen mode Exit fullscreen mode

Three things in that snippet that look small and aren't:

  • final, no parent class. Cannot be hydrated by Eloquent. Cannot be queried via ::find. That is the point.
  • confirm does not call save(). The domain decides state changes. Persistence is somebody else's job.
  • OrderStatus is a native PHP 8.1+ enum. Money and OrderId are value objects. None of them know SQL exists.

The supporting types are short:

// src/Sales/Domain/OrderStatus.php
namespace App\Sales\Domain;

enum OrderStatus: string
{
    case Pending   = 'pending';
    case Confirmed = 'confirmed';
    case Cancelled = 'cancelled';
}

// src/Sales/Domain/Money.php
namespace App\Sales\Domain;

final readonly class Money
{
    public function __construct(
        public int $cents,
        public string $currency,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

You wrote about a hundred lines of new code. No existing file changed. Tests are still green. Commit and push.

Step 2: a repository port the domain owns

The domain needs a way to ask for an Order by id, and a way to hand a changed Order back to be persisted. Define that contract in domain language, not Eloquent's.

// src/Sales/Domain/OrderRepository.php
namespace App\Sales\Domain;

interface OrderRepository
{
    public function find(OrderId $id): ?Order;
    public function save(Order $order): void;
}
Enter fullscreen mode Exit fullscreen mode

Two methods. Returns domain types. No Builder, no Collection, no with(['items']). The use cases call this interface; they never see Eloquent.

Step 3: the adapter, Eloquent stays hidden behind the port

The existing App\Models\Order becomes the persistence detail an adapter wraps. It earns its keep as the thing that talks to the table; nothing else needs to know.

// src/Sales/Infrastructure/EloquentOrderRepository.php
declare(strict_types=1);

namespace App\Sales\Infrastructure;

use App\Models\Order as OrderModel;
use App\Sales\Domain\Order;
use App\Sales\Domain\OrderId;
use App\Sales\Domain\OrderRepository;
use App\Sales\Domain\OrderStatus;
use App\Sales\Domain\Money;
use App\Sales\Domain\CustomerId;
use DateTimeImmutable;

final class EloquentOrderRepository implements OrderRepository
{
    public function find(OrderId $id): ?Order
    {
        $row = OrderModel::find($id->value);
        if ($row === null) {
            return null;
        }
        return $this->toDomain($row);
    }

    public function save(Order $order): void
    {
        $row = OrderModel::find($order->id->value)
            ?? new OrderModel();

        $row->id          = $order->id->value;
        $row->customer_id = $order->customerId->value;
        $row->status      = $order->status()->value;
        $row->total_cents = $order->total()->cents;
        $row->currency    = $order->total()->currency;
        $row->placed_at   = $order->placedAt();
        $row->save();
    }

    private function toDomain(OrderModel $row): Order
    {
        return new Order(
            id:         new OrderId($row->id),
            customerId: new CustomerId($row->customer_id),
            status:     OrderStatus::from($row->status),
            total:      new Money($row->total_cents, $row->currency),
            placedAt:   $row->placed_at
                ? DateTimeImmutable::createFromMutable($row->placed_at)
                : null,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Two translation methods, both boring. The Eloquent model is now an implementation detail of one file. Nothing else in the new code knows it exists.

Wire it in the service provider:

// app/Providers/AppServiceProvider.php (inside register())
$this->app->bind(
    \App\Sales\Domain\OrderRepository::class,
    \App\Sales\Infrastructure\EloquentOrderRepository::class,
);
Enter fullscreen mode Exit fullscreen mode

Step 4: a use case that depends on the port

This is the seam the controllers and jobs will move to, one at a time.

// src/Sales/Application/ConfirmOrder.php
declare(strict_types=1);

namespace App\Sales\Application;

use App\Sales\Domain\OrderId;
use App\Sales\Domain\OrderNotFound;
use App\Sales\Domain\OrderRepository;
use DateTimeImmutable;
use Psr\Clock\ClockInterface;

final readonly class ConfirmOrder
{
    public function __construct(
        private OrderRepository $orders,
        private ClockInterface $clock,
    ) {}

    public function __invoke(OrderId $id): void
    {
        $order = $this->orders->find($id)
            ?? throw new OrderNotFound($id);

        $order->confirm(DateTimeImmutable::createFromInterface(
            $this->clock->now(),
        ));

        $this->orders->save($order);
    }
}
Enter fullscreen mode Exit fullscreen mode

Sixteen lines. No facade. No Order::find($id)->confirm(). No now(). The controller still calls $order->confirm() in the legacy path, but a new controller can call ($this->confirm)($orderId) and the rest of the application will follow over time.

Step 5: strangler move, one caller at a time

Teams skip this step and pay for it. Don't flip every caller in one PR. Pick one and only one entry point and move it.

Start with the controller that breaks the most often.

// app/Http/Controllers/Api/OrderController.php  (before)
public function confirm(string $id): JsonResponse
{
    $order = Order::findOrFail($id);
    $order->confirm();
    return new JsonResponse($order);
}

// after
public function confirm(
    string $id,
    ConfirmOrder $confirm,
): JsonResponse {
    ($confirm)(new OrderId($id));
    return new JsonResponse(['ok' => true]);
}
Enter fullscreen mode Exit fullscreen mode

That PR is small. Reviewable in ten minutes. The Eloquent model still exists, still works for every other caller, still serves the Blade view. You changed one route.

Run it. Watch staging. Ship to prod behind a feature flag if your stack supports it. Repeat for the next caller next week.

Three callers reaching for the Eloquent model on one side; one caller has switched over to the use case on the other, repository in the middle

Step 6: the dual-write window, and how to keep it short

For the weeks where some callers go through the use case and some still call $order->confirm() directly, you have two writers to the same row. That is fine because both paths land on the same underlying Eloquent save — the new path goes through EloquentOrderRepository::save() (which calls $row->save() internally), and the legacy path calls $this->save() from inside the model's own confirm(). Both end up issuing the same UPDATE against the same row.

What is not fine is the model duplicating business rules in two places. So the moment you have a working domain confirm(), gut the model's version:

// app/Models/Order.php  (after gutting)
public function confirm(): void
{
    throw new \LogicException(
        'Use App\\Sales\\Application\\ConfirmOrder. '
        . 'See ADR-014.'
    );
}
Enter fullscreen mode Exit fullscreen mode

In a single deploy, the legacy method becomes a tripwire. Any caller you missed during the strangler pass throws on the next CI run. Fix them as they come up. Within a sprint or two, the tripwire never fires and you delete the method.

Same playbook for totalForDisplay(): move it to a presenter or a view-model class in the inbound HTTP adapter, then make the model version throw.

Step 7: tests get faster, by accident

A useful side effect: the use case test does not need a database.

// tests/Unit/Sales/ConfirmOrderTest.php
use App\Sales\Application\ConfirmOrder;
use App\Sales\Domain\Order;
use App\Sales\Domain\OrderId;
use App\Sales\Domain\OrderRepository;
use App\Sales\Domain\OrderStatus;
use App\Sales\Domain\Money;
use App\Sales\Domain\CustomerId;

it('flips a pending order to confirmed', function () {
    $order = new Order(
        id:         new OrderId('o-1'),
        customerId: new CustomerId('c-1'),
        status:     OrderStatus::Pending,
        total:      new Money(2500, 'EUR'),
        placedAt:   null,
    );

    $repo = new class($order) implements OrderRepository {
        public function __construct(private Order $order) {}
        public function find(OrderId $id): ?Order { return $this->order; }
        public function save(Order $order): void { $this->order = $order; }
    };

    $clock = new class implements Psr\Clock\ClockInterface {
        public function now(): DateTimeImmutable {
            return new DateTimeImmutable('2026-01-01T00:00:00Z');
        }
    };

    (new ConfirmOrder($repo, $clock))(new OrderId('o-1'));

    expect($repo->find(new OrderId('o-1'))->status())
        ->toBe(OrderStatus::Confirmed);
});
Enter fullscreen mode Exit fullscreen mode

No RefreshDatabase. No php artisan migrate. No factories. The test boots in milliseconds because nothing it touches knows what SQLite is. You get one of these per business rule, and the slow integration tests against EloquentOrderRepository shrink to a handful of contract tests on the adapter itself.

What to stop doing on day one

Three habits keep teams stuck halfway through this migration. Drop them as part of the first PR:

  1. Type-hinting App\Models\Order in application code. From now on, application services type-hint App\Sales\Domain\Order. The Eloquent model is allowed inside the adapter and nowhere else. A simple Deptrac or Psalm rule enforces this.
  2. Returning Eloquent models from queries that flow into Blade/Inertia/API resources. Build a thin read-model class for views. The domain entity and the view model are different shapes and should be.
  3. Adding new business methods to the Eloquent model. New rules go on the domain entity. Old rules migrate when the corresponding caller migrates. If you keep adding to the model, the tripwire phase never ends.

When you are done

You will know the migration is finished when app/Models/Order has no methods beyond $casts and relationship declarations, and grep -r 'App\\Models\\Order' app/Http app/Console app/Jobs returns nothing. At that point, App\Models\Order is a DTO that exists only so EloquentOrderRepository has something to hydrate from. You can leave it alone for years. Or, if you want, replace it with a Doctrine entity, a raw query, or a different ORM, and only the adapter changes.

Deleting Eloquent was never the point. Making your domain code stop knowing about it was. You get there one caller at a time, on main, with green tests every Friday.


If this was useful

This is the chapter-27 playbook from Decoupled PHP, compressed to a single post. The book walks the same shape from a fresh project (domain, ports, use cases, adapters) and then spends Part VII on real legacy migrations: Laravel and Symfony, the strangler pattern, transactions across the seam, and how to keep the dual-write window short enough that nobody notices.

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)