background-shape
Decoupling Laravel From Eloquent Without Fighting the Framework
October 23, 2023 · 9 min read · by Muhammad Amal programming

TL;DR — Eloquent is a fine ORM and a poor domain model / Decoupling means putting it behind an adapter, not replacing it with Doctrine / The boundary is the mapper, the contract is the repository

There is a recurring pattern in Laravel discussions: someone reads about clean architecture, decides Eloquent is “tightly coupled,” and proposes replacing it with Doctrine. Six months later their team is fighting Doctrine’s identity map, missing Laravel’s macros, and writing migrations in YAML. Then they roll it back.

Eloquent is not the problem. Eloquent-as-domain-model is the problem. The right move is to keep Eloquent for what it is good at — query building, relationships, migrations, mass assignment, soft deletes — and put it on the other side of an adapter. The application layer talks to a repository interface; the repository talks to Eloquent; Eloquent talks to the database.

This is the most pragmatic version of domain layer purity. You do not need to throw Eloquent away. You need to draw a line and not let it cross.

What Coupling Actually Looks Like

The kind of code that defeats decoupling:

// in a controller
public function show(Request $request, int $id)
{
    $invoice = Invoice::with('customer', 'lines')
        ->where('tenant_id', $request->user()->tenant_id)
        ->findOrFail($id);

    if ($invoice->status === 'open' && $invoice->due_at->isPast()) {
        $invoice->status = 'overdue';
        $invoice->save();
    }

    return new InvoiceResource($invoice);
}

What is wrong here:

  • The controller knows about columns (status, due_at, tenant_id).
  • The status transition logic — “open + due_at past => overdue” — lives in HTTP code.
  • The query is duplicated everywhere Invoice::with('customer', 'lines') appears.
  • InvoiceResource is operating on an Eloquent model directly, so changing the model breaks the API contract.

There are three classes of coupling here, and they want three different fixes:

  1. Query coupling. Solved by repository pattern.
  2. Business-logic coupling. Solved by domain entities.
  3. Presentation coupling. Solved by mapping API resources from domain objects, not from models.

The Repository as Adapter

The contract lives in the domain:

<?php
declare(strict_types=1);

namespace App\Domain\Billing;

interface InvoiceRepository
{
    public function ofId(InvoiceId $id, TenantId $tenant): Invoice;

    public function save(Invoice $invoice): void;

    /** @return list<Invoice> */
    public function overdueFor(TenantId $tenant, DateTimeImmutable $asOf): array;
}

The implementation lives in infrastructure:

<?php
declare(strict_types=1);

namespace App\Infrastructure\Billing;

use App\Domain\Billing\Exceptions\InvoiceNotFound;
use App\Domain\Billing\Invoice;
use App\Domain\Billing\InvoiceId;
use App\Domain\Billing\InvoiceRepository;
use App\Domain\Billing\TenantId;
use App\Infrastructure\Billing\Persistence\InvoiceEloquentModel;
use App\Infrastructure\Billing\Persistence\InvoiceMapper;
use Illuminate\Database\ConnectionInterface;
use DateTimeImmutable;

final class EloquentInvoiceRepository implements InvoiceRepository
{
    public function __construct(
        private readonly ConnectionInterface $db,
    ) {}

    public function ofId(InvoiceId $id, TenantId $tenant): Invoice
    {
        $row = InvoiceEloquentModel::query()
            ->with(['lines', 'customer'])
            ->where('tenant_id', $tenant->toString())
            ->find($id->toString());

        if ($row === null) {
            throw new InvoiceNotFound($id);
        }

        return InvoiceMapper::toDomain($row);
    }

    public function save(Invoice $invoice): void
    {
        $this->db->transaction(function () use ($invoice) {
            $row = InvoiceEloquentModel::query()->findOrNew($invoice->id()->toString());
            InvoiceMapper::hydrate($row, $invoice);
            $row->save();

            // re-sync lines
            $row->lines()->delete();
            foreach ($invoice->lines() as $line) {
                $row->lines()->create(InvoiceMapper::lineToRow($line));
            }
        });
    }

    public function overdueFor(TenantId $tenant, DateTimeImmutable $asOf): array
    {
        return InvoiceEloquentModel::query()
            ->with(['lines'])
            ->where('tenant_id', $tenant->toString())
            ->where('status', 'open')
            ->where('due_at', '<', $asOf)
            ->get()
            ->map(InvoiceMapper::toDomain(...))
            ->all();
    }
}

Note the use of ConnectionInterface instead of the DB facade. This is important — even the repository (which is allowed to know Eloquent exists) does not need to use facades. Inject the connection; you can swap it for testing, and you avoid the global state that facades imply.

The first-class callable syntax InvoiceMapper::toDomain(...) is the Laravel 10 / PHP 8.1+ way to turn a method into a callable. Cleaner than [InvoiceMapper::class, 'toDomain'].

The Eloquent Model: A Persistence Detail

Here is the Eloquent model itself, lurking in infrastructure:

<?php
declare(strict_types=1);

namespace App\Infrastructure\Billing\Persistence;

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

class InvoiceEloquentModel extends Model
{
    protected $table = 'invoices';
    protected $keyType = 'string';
    public $incrementing = false;

    protected $guarded = [];

    protected $casts = [
        'issued_at' => 'datetime',
        'due_at' => 'datetime',
        'paid_at' => 'datetime',
        'metadata' => AsArrayObject::class,
    ];

    public function lines(): HasMany
    {
        return $this->hasMany(InvoiceLineEloquentModel::class, 'invoice_id');
    }

    public function customer()
    {
        return $this->belongsTo(CustomerEloquentModel::class, 'customer_id');
    }
}

The naming convention I use: XxxEloquentModel. Verbose, but unambiguous. Nobody confuses it with the domain Invoice. When you grep the codebase for Invoice, you only get domain hits. When you grep for InvoiceEloquentModel, you only get persistence hits.

The model is guarded = [] because mass assignment protection is a defence against bad controllers, and our controllers do not construct Eloquent models directly. Defence-in-depth is for when other defences fail; if your controllers never see the model, you do not need that defence here.

Transactions: The Tricky Bit

The naive question: “where do transactions live? Domain? Application? Infrastructure?”

The pragmatic answer: in the application layer, at the use-case boundary. The domain knows about consistency rules; it does not know about database transactions. The infrastructure does not know what consistency boundaries the use case wants. The use case is the place that knows.

<?php
declare(strict_types=1);

namespace App\Application\Billing\MarkInvoicePaid;

use App\Domain\Billing\InvoiceRepository;
use App\Domain\Shared\Clock;
use App\Domain\Shared\EventBus;
use Illuminate\Database\ConnectionInterface;

final readonly class MarkInvoicePaidHandler
{
    public function __construct(
        private InvoiceRepository $invoices,
        private Clock $clock,
        private EventBus $events,
        private ConnectionInterface $db,
    ) {}

    public function __invoke(MarkInvoicePaidCommand $command): void
    {
        $this->db->transaction(function () use ($command) {
            $invoice = $this->invoices->ofId($command->invoiceId, $command->tenantId);
            $event = $invoice->markPaid($this->clock);
            $this->invoices->save($invoice);
            $this->events->publish($event);
        });
    }
}

There are two schools of thought here, and I want to be honest about the trade-off.

School A: the handler controls transactions. Pro: visible at the use-case level, easy to reason about. Con: the handler now has an infrastructure dependency (ConnectionInterface) leaking in.

School B: wrap transactions outside the handler via middleware or a decorator. Pro: pure handler. Con: harder to reason about partial commits, nested transactions, etc.

I run School A. The ConnectionInterface is a Laravel interface but not a domain concept; it leaks one level. I judge that acceptable. The clarity of having transactions visible in the use case is worth it. Reasonable engineers disagree.

If you go with School B, look at Laravel’s queue mechanics around transactions — the afterCommit semantics are exactly the problem you are trying to solve at the middleware layer, generalised.

What About Eloquent Events and Observers?

Eloquent has creating, created, updating, updated events on models. They are tempting and they are wrong for domain logic.

Why: they fire during persistence. They are coupled to the lifecycle of a row, not the lifecycle of a domain entity. They run inside save(), which means transactional behaviour is unclear and you cannot reason about ordering. They are silent on intent — updated does not know whether you just marked an invoice paid or just changed a typo in the description.

Domain events are different. They are produced by domain methods (Invoice::markPaid returns an InvoicePaid event). They are explicit. They are dispatched after the entity is saved, by the application layer. They survive any persistence change you make later.

// in the handler, after save:
$this->events->publish($event);

In Laravel, the EventBus adapter can wrap Laravel’s event dispatcher:

final readonly class LaravelEventBus implements EventBus
{
    public function __construct(private Dispatcher $dispatcher) {}

    public function publish(DomainEvent $event): void
    {
        $this->dispatcher->dispatch($event);
    }
}

Now Laravel’s listener/queue infrastructure works for you — you can ShouldQueue a listener, you can use afterCommit on it — but the domain has no idea.

The Read Side

So far this has been about writes. Reads are different and can be more pragmatic.

For listings, dashboards, and API endpoints that return data, you do not need to materialise a domain entity. You need a DTO with the right shape for the consumer. A read model.

final readonly class InvoiceListItem
{
    public function __construct(
        public string $id,
        public string $number,
        public string $customerName,
        public int $totalMinor,
        public string $currency,
        public string $status,
        public DateTimeImmutable $dueAt,
    ) {}
}

final class InvoiceQueries
{
    public function listForTenant(TenantId $tenant, ?string $status = null): array
    {
        return DB::table('invoices')
            ->join('customers', 'customers.id', '=', 'invoices.customer_id')
            ->select([
                'invoices.id',
                'invoices.number',
                'customers.name as customer_name',
                'invoices.subtotal_minor as total_minor',
                'invoices.currency',
                'invoices.status',
                'invoices.due_at',
            ])
            ->where('invoices.tenant_id', $tenant->toString())
            ->when($status, fn ($q) => $q->where('invoices.status', $status))
            ->get()
            ->map(fn ($row) => new InvoiceListItem(
                id: $row->id,
                number: $row->number,
                customerName: $row->customer_name,
                totalMinor: (int) $row->total_minor,
                currency: $row->currency,
                status: $row->status,
                dueAt: new DateTimeImmutable($row->due_at),
            ))
            ->all();
    }
}

Note: no Eloquent model in sight. We use DB::table() directly, because we want a flat SELECT, not an object graph. We are projecting straight from rows to DTOs. This is CQRS-lite: the write side goes through the repository and entities; the read side goes through query objects and DTOs.

The benefit is dramatic. A listing endpoint that previously did Invoice::with('customer', 'lines')->get() and hydrated 400 full models when 400 list items were needed becomes a single SELECT with seven columns. We measured a 12x speedup on one endpoint after this change.

Common Pitfalls

The mistakes I see repeatedly:

  • Returning Eloquent models from repositories. Defeats the entire point. The return type of every repository method must be a domain type.
  • N+1 in the mapper. The repository does eager loading; the mapper does not. If your mapper calls $row->customer->name, the customer better be eager-loaded. Verify with Laravel’s strict-loading mode.
  • Using Eloquent global scopes in domain queries. Global scopes hide query logic. Tenant scoping is the common case; it is fine in infrastructure, but the repository must declare tenant in its interface, not rely on a hidden scope.
  • The repository becomes a query bus. When InvoiceRepository has 47 methods, it stopped being a repository. Split into InvoiceRepository (writes) and InvoiceQueries (reads). Different shapes, different concerns.
  • Mutating the Eloquent model and then mapping back. Do mutation on the entity. Map to Eloquent only when saving. Going back and forth produces subtle drift.
  • forceCreate and friends inside the domain. Anything starting with force is bypassing model logic. Bypassing model logic to bypass your own decoupling is what we are trying to avoid.

The “repository as query bus” failure is by far the most common. Teams start clean and then the read patterns drive growth in the repo interface until it is unwieldy. Split early. The CQRS-lite approach above is the cleanest fix I know.

Wrapping Up

Eloquent is a great query builder, a great migration tool, and a competent active-record. Used as an active-record at the boundary, behind an adapter, it earns its keep. Used as a domain model, it slowly poisons the codebase. The decoupling is not architectural purity for its own sake; it pays back in testability, performance, and the freedom to change persistence details without rewriting business logic.

Next post will get into the repository pattern specifically — the variants I have used, the trade-offs, and why I think most code samples online get it wrong by making it too clever or too generic.