Eloquent vs Domain Models in Clean Architecture
TL;DR — Eloquent models conflate persistence and domain. For small apps, just use Eloquent directly. For larger ones, separate: keep Eloquent in the repository layer only; map to plain-PHP domain entities for use cases. Yes, the mapping is verbose. Yes, the boundary is worth it past a scale threshold.
The Laravel-specific tension. Eloquent is fantastic for rapid CRUD. It’s also a framework class with strict-types, save-yourself behaviour, and Active Record patterns that don’t fit Clean Architecture cleanly. This post is how to make peace.
What Eloquent is, structurally
An Eloquent model:
- Extends
Illuminate\Database\Eloquent\Model - Knows about its database table
- Has
save(),delete(), relationship methods - Is constructed by the framework via
find(),create(), etc. - Is dynamic — adding a field to the table makes it available via
$model->newField
Powerful, idiomatic Laravel. Not a domain entity. Two problems for Clean Architecture:
- Couples persistence and domain. Can’t use the type without a DB connection. Can’t test in isolation.
- Anemic by default. Most Eloquent models I’ve seen are data bags with no behaviour. Business rules live in services or controllers around them.
Three approaches, ordered by purity
Approach 1: just use Eloquent
For small apps with little business logic, fight nothing. Use Eloquent models directly in controllers and services. No domain layer.
Works for:
- Internal tools
- Prototypes
- Small SaaS apps under a year old
- Apps where “the framework IS the architecture” is a fine answer
Stops working at: ~50 models, multiple complex business rules, multiple developers, “wait, where does this validation live?”
Approach 2: domain models alongside Eloquent
What I recommend for medium-to-large apps:
- Eloquent models live in
app/Repository/Eloquent/Models/. Used only inside repositories. Not used elsewhere. - Domain entities live in
app/Domain/. Plain PHP, no Eloquent inheritance. Have behaviour. - Repository is the boundary. Public methods accept/return domain entities; internally uses Eloquent.
// app/Repository/Eloquent/Models/SubscriptionModel.php
namespace App\Repository\Eloquent\Models;
use Illuminate\Database\Eloquent\Model;
class SubscriptionModel extends Model
{
protected $table = 'subscriptions';
protected $keyType = 'string';
public $incrementing = false;
protected $casts = [
'started_at' => 'datetime',
'renews_at' => 'datetime',
'canceled_at' => 'datetime',
];
}
// app/Domain/Subscription.php — plain PHP, no Eloquent
namespace App\Domain;
final class Subscription { ... }
// app/Repository/Eloquent/EloquentSubscriptionRepository.php
namespace App\Repository\Eloquent;
use App\Domain\Subscription;
use App\Repository\SubscriptionRepository;
final class EloquentSubscriptionRepository implements SubscriptionRepository
{
public function save(Subscription $sub): void
{
SubscriptionModel::updateOrCreate(
['id' => $sub->id()->toString()],
$this->toRow($sub),
);
}
public function getById(UuidInterface $id): ?Subscription
{
$row = SubscriptionModel::find($id->toString());
return $row ? $this->toDomain($row) : null;
}
private function toRow(Subscription $s): array { /* ... */ }
private function toDomain(SubscriptionModel $m): Subscription { /* ... */ }
}
The mapping (toDomain, toRow) is the cost. ~20 lines per entity. It’s mechanical, easy to write, easy to maintain.
Approach 3: ActiveRecord-style domain models (don’t)
Some teams extend Eloquent and put domain methods on it:
class Subscription extends Model
{
public function cancel(): void
{
if ($this->status === 'canceled') throw new AlreadyCanceledException();
$this->status = 'canceled';
$this->canceled_at = now();
$this->save(); // persists inside the domain method
}
}
Tempting. Less code. Don’t:
- Tests need a real DB.
- The domain method has side effects (save).
- “Pure business rule” and “persist” are now tangled.
- Replacing Eloquent → impossible without rewriting the domain.
Approach 2 keeps these separated. The cost is conversion boilerplate; the benefit is clarity.
When the boundary matters
Approach 2 starts paying off when:
- You write tests that don’t want a DB
- Business rules grow complex enough to deserve dedicated code (proration, refunds, multi-tenant rules)
- The team grows past 3-4 engineers
- You start considering swapping ORMs (rare but real)
If none of those apply, approach 1 is fine. Don’t add layers preemptively.
Conversion helpers — keep them simple
private function toDomain(SubscriptionModel $m): Subscription
{
return Subscription::restore(
id: Uuid::fromString($m->id),
customerId: Uuid::fromString($m->customer_id),
plan: new Plan($m->plan_id, $m->plan_price_cents, $m->plan_currency),
status: Status::from($m->status),
startedAt: DateTimeImmutable::createFromMutable($m->started_at),
renewsAt: DateTimeImmutable::createFromMutable($m->renews_at),
canceledAt: $m->canceled_at ? DateTimeImmutable::createFromMutable($m->canceled_at) : null,
);
}
private function toRow(Subscription $s): array
{
return [
'customer_id' => $s->customerId()->toString(),
'plan_id' => $s->plan()->id,
'plan_price_cents' => $s->plan()->priceCents,
'plan_currency' => $s->plan()->currency,
'status' => $s->status()->value,
'started_at' => $s->startedAt(),
'renews_at' => $s->renewsAt(),
'canceled_at' => $s->canceledAt(),
];
}
Most of the file is mapping. It’s repetitive, mechanical, and stable. You’ll write it once and forget it exists.
Relationships
Eloquent’s relationship loading ($sub->customer) is fantastic for casual use, complicated under Clean Architecture. Two approaches:
Don’t expose Eloquent relationships across the boundary. The repository returns a Subscription entity. If you need related data, the repository has another method (getSubscriptionWithCustomer($id)) returning a compound DTO.
Lazy-load via callbacks in domain. Domain entity holds a customerId; a separate use case looks up the customer when needed. Avoids the N+1 trap and keeps domain pure.
Both fine. Pick one per service; document.
Common Pitfalls
Using Eloquent models in controllers via type hints. Now controllers can save, delete, mutate. No boundary. Choose: either approach 1 (Eloquent everywhere) or approach 2 (Eloquent never in controllers).
Putting $model->save() inside domain methods. Side effects in domain. Make the domain method change state, persist via repository.
Bidirectional sync — domain mutates Eloquent which triggers events. Eloquent observers + domain events get confusing fast. Pick one event source.
Eloquent in tests of use cases. Defeats the speed win. Tests should use in-memory repository implementations.
Re-implementing Eloquent features in domain. “We need timestamps, soft deletes, etc.” Those are persistence concerns. Domain entities don’t need them.
Wrapping Up
Eloquent at the persistence edge; domain entities for everything else; repository as the boundary; mechanical mapping in between. Most pragmatic answer for medium-to-large Laravel apps. Monday: testing Clean Architecture — what changes about your test strategy.