Skip to main content
Architecture & Backend · 11 min

Hexagonal Architecture and DDD in Laravel: Decoupling Domain from Eloquent with DTOs and Repositories

How to architect complex enterprise applications in Laravel without letting Eloquent pollute business rules: Ports and Adapters, immutable PHP 8.2 Value Objects, and strict layer boundaries.

Pablo Fesa
Pablo Fesa Software Architect & Tech Lead

A prominent dilemma when scaling Laravel applications is the framework’s default inclination toward the Fat Model (or bloated controller) anti-pattern. Because Eloquent is an Active Record implementation, models routinely absorb validation rules, presentation mutators, event dispatchers, database query scopes, and core business rules within single 1,500-line classes.

As projects grow in complexity or cross-functional teams expand, refactoring a database query or testing an invoicing rule in isolation becomes fraught with risk.

In this deep dive, we explore how to apply Hexagonal Architecture (Ports and Adapters) and Domain-Driven Design (DDD) principles within Laravel without fighting the framework, segregating core domain logic into pure POPOs and relegating Eloquent to its optimal role: an infrastructure persistence adapter.


1. The Anatomy of Coupling in Typical Laravel Apps

In standard Laravel approaches, traditional software layers collapse:

[HTTP Request] ──► [Controller] ──► [Eloquent Model ($user->save())] ──► [Database]

Why this structure breaks in complex domains:

  1. Business logic held hostage by the ORM: You cannot instantiate an Order in a fast unit test without attempting a database connection or managing mock tables.
  2. Primitive Obsession: Methods accepting untyped array $data without domain type enforcement (failing to distinguish a Money domain object from a raw int, or an email from an arbitrary string).
  3. Implicit Side Effects: Model observers and Eloquent events cascading unpredictably during batch migrations or data sync pipelines.

2. Layer Boundaries: Ports and Adapters

To decouple the architecture, we partition the application into concentric boundaries:

┌─────────────────────────────────────────────────────────────┐
│ 1. INFRASTRUCTURE (External Adapters)                       │
│    ├─ HTTP Controllers (Web / API)                          │
│    ├─ Eloquent Models (App\Infrastructure\Persistence)       │
│    └─ Payment Gateways (Stripe / PayPal)                    │
│                                                             │
│    ┌───────────────────────────────────────────────────┐    │
│    │ 2. APPLICATION (Use Cases / Orchestration)        │    │
│    │    ├─ Commands / Handlers                         │    │
│    │    ├─ Input/Output DTOs                           │    │
│    │    └─ Ports (Repository Interfaces)               │    │
│    │                                                   │    │
│    │    ┌─────────────────────────────────────────┐    │    │
│    │    │ 3. PURE DOMAIN (Zero Framework Code)    │    │    │
│    │    │    ├─ Pure Entities (POPOs)             │    │    │
│    │    │    ├─ Immutable Value Objects           │    │    │
│    │    │    └─ Domain Exceptions                 │    │    │
│    │    └─────────────────────────────────────────┘    │    │
│    └───────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────┘

The fundamental dependency rule: inner layers have zero awareness of outer layers. The Domain layer never imports symbols from the Illuminate\* namespace.


3. Practical Implementation: From Architecture to Code

Let us trace a production use case: capturing and settling a customer order (CaptureOrderUseCase).

Step 1: Immutable Value Objects with PHP 8.2 readonly

Rather than representing currency amounts using floating-point primitives, we define an immutable Value Object encapsulating invariants:

namespace Domain\Billing\ValueObjects;

use Domain\Billing\Exceptions\InvalidAmountException;

readonly class Money
{
    public function __construct(
        public int $amountInCents,
        public Currency $currency
    ) {
        if ($this->amountInCents < 0) {
            throw new InvalidAmountException("Monetary amounts cannot be negative.");
        }
    }

    public function add(Money $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidAmountException("Cannot add amounts across mismatched currencies.");
        }

        return new self($this->amountInCents + $other->amountInCents, $this->currency);
    }
}

Step 2: The Repository Port (Domain Interface)

The domain specifies its persistence contract without prescribing underlying technology:

namespace Domain\Orders\Repositories;

use Domain\Orders\Entities\Order;
use Domain\Orders\ValueObjects\OrderId;

interface OrderRepositoryInterface
{
    public function findById(OrderId $id): ?Order;
    public function save(Order $order): void;
}

Step 3: Application Use Case Orchestration

Business operations execute in an orchestrating use case:

namespace Application\Orders\UseCases;

use Domain\Orders\Repositories\OrderRepositoryInterface;
use Domain\Orders\ValueObjects\OrderId;
use Application\Orders\DTOs\CaptureOrderResponseDTO;

class CaptureOrderUseCase
{
    public function __construct(
        private OrderRepositoryInterface $orderRepository
    ) {}

    public function execute(string $rawOrderId): CaptureOrderResponseDTO
    {
        $orderId = new OrderId($rawOrderId);
        $order = $this->orderRepository->findById($orderId);

        if (! $order) {
            throw new OrderNotFoundException("Order {$rawOrderId} does not exist.");
        }

        // Executes pure business rules directly on the domain aggregate
        $order->capture();

        // Persists state via abstraction port
        $this->orderRepository->save($order);

        return CaptureOrderResponseDTO::fromDomain($order);
    }
}

Step 4: The Eloquent Infrastructure Adapter

Here is where Laravel integrates cleanly. We implement the domain port inside the infrastructure layer by mapping to the Eloquent model:

namespace Infrastructure\Orders\Repositories;

use Domain\Orders\Entities\Order as DomainOrder;
use Domain\Orders\Repositories\OrderRepositoryInterface;
use Domain\Orders\ValueObjects\OrderId;
use Infrastructure\Orders\Models\EloquentOrderModel;

class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function findById(OrderId $id): ?DomainOrder
    {
        $record = EloquentOrderModel::find($id->value);

        if (! $record) {
            return null;
        }

        // Transforms Eloquent database record into a pure domain entity
        return OrderDataMapper::toDomain($record);
    }

    public function save(DomainOrder $order): void
    {
        EloquentOrderModel::updateOrCreate(
            ['id' => $order->id->value],
            OrderDataMapper::toPersistence($order)
        );
    }
}

Step 5: Binding in Laravel’s IoC Container

In RepositoryServiceProvider, bind the domain contract to its infrastructure implementation:

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Domain\Orders\Repositories\OrderRepositoryInterface;
use Infrastructure\Orders\Repositories\EloquentOrderRepository;

class RepositoryServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            OrderRepositoryInterface::class,
            EloquentOrderRepository::class
        );
    }
}

4. Engineering Payoffs

  1. Sub-Millisecond Unit Tests: Testing CaptureOrderUseCase takes < 2 milliseconds, executing purely in RAM with an in-memory repository mock without invoking SQLite, MySQL, or database migration pipelines.
  2. Zero-Friction Storage Migrations: Shifting an audit table from PostgreSQL to Redis or ScyllaDB preserves 100% of domain validation rules untouched.
  3. Framework Upgrade Immunity: Major framework version upgrades (Laravel 10 to 11, or 11 to 12) affect only peripheral I/O adapters, drastically lowering technical debt.

Summary

Migrating to enterprise design does not require abandoning Laravel. By using Laravel’s Service Container as an inversion-of-control orchestrator for Hexagonal Architecture, engineering teams achieve rapid feature velocity alongside mathematical domain protection.

Topics: #PHP #Laravel #Hexagonal Architecture #DDD #Design Patterns #Backend
Continue Reading

Related Articles

Looking to architect resilient systems without budget overruns?

Book a 15-minute diagnostic session with Pablo to audit your bottlenecks and map out a clear technical path.