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.
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:
- Business logic held hostage by the ORM: You cannot instantiate an
Orderin a fast unit test without attempting a database connection or managing mock tables. - Primitive Obsession: Methods accepting untyped
array $datawithout domain type enforcement (failing to distinguish aMoneydomain object from a rawint, or an email from an arbitrary string). - 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
- Sub-Millisecond Unit Tests: Testing
CaptureOrderUseCasetakes < 2 milliseconds, executing purely in RAM with an in-memory repository mock without invoking SQLite, MySQL, or database migration pipelines. - Zero-Friction Storage Migrations: Shifting an audit table from PostgreSQL to Redis or ScyllaDB preserves 100% of domain validation rules untouched.
- 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.
Related Articles
The Anatomy of Laravel: Facades, IoC Containers, and the Eloquent vs Doctrine Debate (Part 2)
Demystifying Laravel's internal design: how Facades really work via metaprogramming, IoC Container auto-wiring, and the architectural trade-offs between Active Record and Data Mapper.
From PHP-FPM to FrankenPHP: Why Shared-Nothing Is No Longer the Only Way
How in-memory persistence and FrankenPHP's Worker Mode eliminate the framework boot tax in modern stacks like Laravel, slashing latencies from 40ms down to sub-millisecond ranges.
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.