background-shape
Eloquent vs Domain Models in Clean Architecture
June 24, 2022 · 5 min read · by Muhammad Amal programming

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:

  1. Couples persistence and domain. Can’t use the type without a DB connection. Can’t test in isolation.
  2. 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.