- 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
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.
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);
}
}
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; }
}
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. -
confirmdoes not callsave(). The domain decides state changes. Persistence is somebody else's job. -
OrderStatusis a native PHP 8.1+ enum.MoneyandOrderIdare 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,
) {}
}
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;
}
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,
);
}
}
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,
);
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);
}
}
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]);
}
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.
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.'
);
}
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);
});
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:
-
Type-hinting
App\Models\Orderin application code. From now on, application services type-hintApp\Sales\Domain\Order. The Eloquent model is allowed inside the adapter and nowhere else. A simple Deptrac or Psalm rule enforces this. - 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.
- 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.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)