- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Open any Laravel codebase that's been in production for three years and look at app/Models/Order.php. Two thousand lines. confirm(), cancel(), refund(), applyDiscount(), recalculateTax(), notifyFulfillment(), markAsShipped(), plus a dozen query scopes, six accessors, four mutators, three boot() hooks, and a static::saving() closure that nobody on the team remembers writing.
The class extends Illuminate\Database\Eloquent\Model. It knows about your domain, your database schema, your tax rules, your email templates, and the HTTP request lifecycle. When you ask it what an Order is, it answers: whatever the framework lets me be.
That is the Active Record trap. Eloquent makes the first 30 days of a Laravel project feel like cheating. By month 18, you're afraid to touch the Order model because three different controllers, a queued job, a webhook handler, and an Artisan command all call different methods on it, and changing any of them might bend something invisible.
This post is the contrarian take: keep Eloquent. Use it. It is a good ORM. But stop making it your domain. Put the business rules in plain PHP classes that know nothing about Model, and let Eloquent do what it's actually good at: turning rows into objects and back.
How an Eloquent model becomes a maze
The pattern is the same every time. You start with the obvious thing:
class Order extends Model
{
protected $fillable = ['customer_id', 'total_cents', 'status'];
}
Then someone needs to "confirm" an order, and OrderController is already touching the model, so the method goes there:
class Order extends Model
{
public function confirm(): void
{
$this->status = 'confirmed';
$this->confirmed_at = now();
$this->save();
}
}
Six months later, the method starts pulling in invariants and orchestration:
class Order extends Model
{
public function confirm(): void
{
if ($this->status !== 'pending') {
throw new InvalidOrderStatus($this->status);
}
if ($this->total_cents <= 0) {
throw new InvalidOrderTotal();
}
if ($this->customer->is_blocked) {
throw new BlockedCustomer($this->customer_id);
}
Then a transaction wraps the state change, plus side effects — inventory, mail, events:
DB::transaction(function () {
$this->status = 'confirmed';
$this->confirmed_at = now();
$this->save();
$this->items->each(fn ($item) =>
Inventory::reserve($item->sku, $item->quantity)
);
Mail::to($this->customer->email)
->queue(new OrderConfirmedMail($this));
event(new OrderConfirmed($this->id));
});
}
}
Count the things this single method now knows about:
- The status state machine (
pending→confirmed). - The total invariant (must be positive).
- A customer rule that lives on a related table.
- A database transaction boundary.
- An inventory subsystem.
- The mail layer and a specific Mailable class.
- The framework's event bus.
- The fact that
now()is the right way to read the clock.
Nine concerns in one method on an ORM class that also knows how to translate itself into SQL. The signal-to-noise ratio collapses. Testing this method without booting the entire Laravel kernel is impossible. You can't construct an Order without the database, you can't call confirm() without DB, Mail, Inventory, and event() resolving to real instances, and you can't assert on side effects without faking four facades.
That's the maze. Not bad code, exactly. Each line was added by someone who picked the path of least resistance. But the model has become a router for the whole app. Every change risks every caller.
The fix: Eloquent is an adapter, not a domain
The cure is a boundary. Eloquent stays as the persistence adapter, mapping rows to objects and back. Business behavior lives somewhere else.
The business behavior moves into plain PHP classes that extend nothing, depend on no facade, and use no Model method. You hydrate them from Eloquent rows when you load, and you push them back to Eloquent rows when you save. Two translations. One domain. A model file you read in 30 seconds.
Here's the same Order, split the way you'd split it in a Hexagonal layout. Start with the domain entity's shape — constructor, state, and getters:
<?php
declare(strict_types=1);
namespace App\Domain\Ordering;
use DateTimeImmutable;
final class Order
{
/** @param list<OrderItem> $items */
public function __construct(
public readonly OrderId $id,
public readonly CustomerId $customerId,
public readonly array $items,
private OrderStatus $status,
private ?DateTimeImmutable $confirmedAt,
) {
}
public function status(): OrderStatus
{
return $this->status;
}
public function confirmedAt(): ?DateTimeImmutable
{
return $this->confirmedAt;
}
public function totalCents(): int
{
return array_sum(
array_map(
fn (OrderItem $i) => $i->priceCents * $i->quantity,
$this->items,
),
);
}
}
The behavior — the confirm() method — is the part that used to live on the Eloquent model. It now sits on the domain class with no framework dependencies:
public function confirm(
DateTimeImmutable $now,
CustomerPolicy $policy,
): void {
if ($this->status !== OrderStatus::Pending) {
throw OrderCannotBeConfirmed::wrongStatus($this->status);
}
if ($this->totalCents() <= 0) {
throw OrderCannotBeConfirmed::nonPositiveTotal();
}
if ($policy->isBlocked($this->customerId)) {
throw OrderCannotBeConfirmed::blockedCustomer(
$this->customerId,
);
}
$this->status = OrderStatus::Confirmed;
$this->confirmedAt = $now;
}
A few things to notice. The class extends nothing. Its constructor takes the state in full: there is no hidden hydration step. The confirm() method mutates state and returns nothing; it doesn't call save(), touch the inventory, or queue mail. It checks the rules, transitions the status, and stops.
The rules that came from outside (is_blocked, the current time) are passed in as parameters: a CustomerPolicy port and a DateTimeImmutable. That makes the test trivial. No facades, no time-travel helpers, just construct an order and call the method.
public function test_confirm_rejects_blocked_customer(): void
{
$order = new Order(
id: new OrderId('ord-1'),
customerId: new CustomerId('cust-9'),
items: [new OrderItem('SKU-A', 2, 1500)],
status: OrderStatus::Pending,
confirmedAt: null,
);
$policy = new class implements CustomerPolicy {
public function isBlocked(CustomerId $id): bool
{
return true;
}
};
$this->expectException(OrderCannotBeConfirmed::class);
$order->confirm(new DateTimeImmutable('2026-01-01'), $policy);
}
No RefreshDatabase. No Mail::fake(). No Event::fake(). The test runs in milliseconds because the domain doesn't know Laravel exists.
Eloquent's actual job: hydrate and persist
Eloquent stays as a thin adapter. The Eloquent model has fillables, casts, and relationships, and nothing else. No business methods.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Eloquent;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class OrderRow extends Model
{
protected $table = 'orders';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'id', 'customer_id', 'status', 'confirmed_at',
];
protected $casts = [
'confirmed_at' => 'immutable_datetime',
];
public function items(): HasMany
{
return $this->hasMany(OrderItemRow::class, 'order_id');
}
}
That's the entire file. No confirm(), no cancel(), no scopes that encode policy. It is a row. The repository is what bridges the row and the domain object:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Eloquent;
use App\Domain\Ordering\Order;
use App\Domain\Ordering\OrderId;
use App\Domain\Ordering\OrderItem;
use App\Domain\Ordering\OrderRepository;
use App\Domain\Ordering\OrderStatus;
use App\Domain\Ordering\CustomerId;
final class EloquentOrderRepository implements OrderRepository
{
public function find(OrderId $id): ?Order
{
$row = OrderRow::with('items')->find($id->value);
if ($row === null) {
return null;
}
return new Order(
id: new OrderId($row->id),
customerId: new CustomerId($row->customer_id),
items: $row->items->map(
fn ($i) => new OrderItem(
$i->sku, $i->quantity, $i->price_cents,
),
)->all(),
status: OrderStatus::from($row->status),
confirmedAt: $row->confirmed_at,
);
}
public function save(Order $order): void
{
OrderRow::updateOrCreate(
['id' => $order->id->value],
[
'customer_id' => $order->customerId->value,
'status' => $order->status()->value,
'confirmed_at' => $order->confirmedAt(),
],
);
}
}
The repository implements an interface (OrderRepository) that lives in the domain namespace. The domain knows the shape of the call (find, save); it does not know it's Eloquent on the other side. Swap Eloquent for Doctrine, or for a raw PDO mapper, and only this file changes.
Where the framework concerns go
The transaction, the mail, the inventory, and the event (all the things that were tangled inside Order::confirm()) move into a use case that orchestrates the domain and the ports. The use case is also plain PHP:
<?php
declare(strict_types=1);
namespace App\Application\Ordering;
use App\Domain\Ordering\Clock;
use App\Domain\Ordering\CustomerPolicy;
use App\Domain\Ordering\InventoryReserver;
use App\Domain\Ordering\Notifier;
use App\Domain\Ordering\OrderId;
use App\Domain\Ordering\OrderRepository;
use App\Domain\Ordering\Transactional;
final class ConfirmOrder
{
public function __construct(
private OrderRepository $orders,
private CustomerPolicy $policy,
private InventoryReserver $inventory,
private Notifier $notifier,
private Clock $clock,
private Transactional $tx,
) {
}
public function handle(OrderId $id): void
{
$this->tx->run(function () use ($id): void {
$order = $this->orders->find($id)
?? throw new OrderNotFound($id);
$order->confirm($this->clock->now(), $this->policy);
$this->orders->save($order);
foreach ($order->items as $item) {
$this->inventory->reserve(
$item->sku, $item->quantity,
);
}
$this->notifier->orderConfirmed($order->id);
});
}
}
The controller becomes three lines:
public function confirm(string $id, ConfirmOrder $useCase): Response
{
$useCase->handle(new OrderId($id));
return response()->noContent();
}
OrderRow owns the database, Order owns the rules, ConfirmOrder orchestrates, and the controller handles HTTP. Four layers, each one small, each one testable alone.
The objections, answered
"This is more code." Three new classes for one operation, yes. The trade is up-front lines against year-three change cost. If the project lives past 18 months, the Eloquent-as-domain layout becomes the bottleneck on every refactor. If it ships and dies in six months, you didn't need the boundary anyway. The decision is a bet on lifespan.
"Eloquent already gives me a domain object." It gives you a row wearing the costume of a domain object. The fields are dynamic, the relationships are lazily loaded behind property access, and save() is always available. That's a great database client. It is a poor place to encode invariants that must hold true regardless of how the object was loaded.
"I'll lose query scopes and accessors." Keep them on OrderRow. Persistence concerns belong on the persistence class. The domain Order doesn't need a scopeRecent() because the domain doesn't query; the repository does.
"What about events?" Eloquent's saving/saved events run in the persistence layer, which is the wrong place for domain events. Raise domain events from inside the domain method ($this->recordEvent(new OrderConfirmed(...))), collect them in the use case after the call, dispatch them after the transaction commits. The framework's event bus becomes an adapter, like the database.
"This is DDD with extra steps." No: it is the part of DDD that pays off without the parts that don't. You don't need ubiquitous language workshops or a domain/event-sourcing/snapshot/ package to get the benefit of moving business rules out of Model. The boundary is the only DDD idea you actually need on day one.
When to skip this
There are projects where putting business logic on the Eloquent model is fine. A CRUD admin tool with no real rules. A 200-line internal script. A throwaway prototype to show a stakeholder. Anything where the "domain" is the user filled in a form and we wrote it to a table. The fanciest thing your Order does is INSERT, and forcing a domain entity on top of that is theatre.
The rule of thumb: if the model has zero methods that aren't getters, setters, or relationship definitions, Active Record is fine. If you've already written one method that throws an exception based on a field, the boundary is overdue.
The shape to aim for
Three files per aggregate in the typical Laravel app:
-
app/Domain/Ordering/Order.php— pure PHP, noextends, all the rules. -
app/Infrastructure/Persistence/Eloquent/OrderRow.php— pure Eloquent, no methods. -
app/Infrastructure/Persistence/Eloquent/EloquentOrderRepository.php— translates between the two.
Use cases sit in app/Application/Ordering/, orchestrate the domain and ports, and stay framework-agnostic except for the Transactional port that wraps DB::transaction. Controllers go thin. Domain tests run without the framework, repository tests run against a real database (or SQLite in memory if you trust the parity), and the rest is wiring.
That's the shape. It looks heavier on day one. The payoff lands six months later, when changing a rule means reading one file instead of five.
If this was useful
The full layout (domain, ports, adapters, use cases, transactions across them, and a step-by-step refactor of a legacy Laravel service) is the spine of Decoupled PHP. It walks the same shape from a fresh Laravel 11 install through to a production application where the framework is one adapter among many, and it includes a public examples repo you can run locally.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)