Arquitectura Hexagonal y DDD en Laravel: Desacoplando el Dominio de Eloquent con DTOs y Repositorios
Cómo estructurar aplicaciones complejas en Laravel sin que Eloquent contamine la lógica de negocio: Puertos y Adaptadores, Value Objects inmutables con PHP 8.2 y separación estricta de capas.
Uno de los mayores dilemas al escalar aplicaciones en Laravel es la tendencia natural del framework a promover el antipatrón del Fat Model (o controladores hinchados). Al ser Eloquent una implementación pura de Active Record, los modelos terminan acumulando reglas de validación, mutadores de presentación, llamadas a eventos, scopes de base de datos y lógica crítica de negocio en un solo archivo de 1.500 líneas.
Cuando el proyecto crece en complejidad o intervienen múltiples equipos, cambiar una consulta SQL o probar una regla de facturación de forma aislada se convierte en un campo de minas.
En este artículo abordamos cómo aplicar Arquitectura Hexagonal (Puertos y Adaptadores) y principios de Diseño Guiado por el Dominio (DDD) en Laravel sin pelear contra el framework, extrayendo el dominio a un núcleo puro y relegando a Eloquent al rol que mejor desempeña: un adaptador de persistencia.
1. La Anatomía del Acoplamiento Típico en Laravel
En el enfoque convencional de Laravel, las tres capas fundamentales del software están colapsadas en dos:
[HTTP Request] ──► [Controller] ──► [Eloquent Model ($user->save())] ──► [Base de Datos]
Por qué este modelo no escala en dominios complejos:
- Lógica de negocio rehén del ORM: No puedes instanciar un
Orderen un test unitario rápido sin que intente conectarse a una base de datos o simular la conexión. - Filtrado de tipos primitivos: Métodos que reciben
array $datasin validar tipados semánticos de negocio (como diferenciar unMoneyde un simpleint, o unCpf/Dnide unstring). - Efectos secundarios invisibles: Model Observers y eventos de Eloquent que se disparan en cascada durante operaciones masivas o migraciones batch.
2. Estructura de Capas: Puertos y Adaptadores
Para desacoplar el sistema, dividimos la aplicación en tres anillos concéntricos:
┌─────────────────────────────────────────────────────────────┐
│ 1. INFRAESTRUCTURA (Adaptadores Externos) │
│ ├─ Controllers HTTP (Web / API) │
│ ├─ Eloquent Models (App\Infrastructure\Persistence) │
│ └─ Gateways de Pago (Stripe / PayPal) │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 2. APLICACIÓN (Casos de Uso / Orquestación) │ │
│ │ ├─ Commands / Handlers │ │
│ │ ├─ DTOs de Entrada/Salida │ │
│ │ └─ Puertos (Interfaces de Repositorio) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ 3. DOMINIO PURO (Sin Framework) │ │ │
│ │ │ ├─ Entidades Puras (POPOs) │ │ │
│ │ │ ├─ Value Objects Inmutables │ │ │
│ │ │ └─ Excepciones de Dominio │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
La regla dorada de dependencias: las capas internas nunca conocen a las capas externas. El Dominio no importa nada del namespace Illuminate\*.
3. Implementación Práctica: De la Teoría al Código
Veamos un caso de uso real: el cobro y cierre de un pedido comercial (CaptureOrderUseCase).
Paso 1: Value Objects Inmutables con PHP 8.2 readonly
En lugar de representar el dinero con números flotantes o enteros dispersos, modelamos un Value Object inmutable que encapsula sus propias invariantes:
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("El importe no puede ser negativo.");
}
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new InvalidAmountException("No se pueden sumar divisas distintas.");
}
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
}
Paso 2: El Puerto del Repositorio (Interfaz de Dominio)
El dominio define qué necesita para persistir, no cómo se persiste:
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;
}
Paso 3: El Caso de Uso en la Capa de Aplicación
La lógica de negocio reside en un caso de uso puramente orquestador:
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("El pedido {$rawOrderId} no existe.");
}
// Ejecuta la regla de negocio directamente sobre la entidad de dominio
$order->capture();
// Persiste mediante la abstracción
$this->orderRepository->save($order);
return CaptureOrderResponseDTO::fromDomain($order);
}
}
Paso 4: El Adaptador de Infraestructura con Eloquent
Ahora es donde entra Laravel. Creamos una clase en la capa de infraestructura que implementa la interfaz de dominio mapeando hacia el modelo Eloquent:
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;
}
// Transforma el registro Eloquent a una entidad pura de dominio
return OrderDataMapper::toDomain($record);
}
public function save(DomainOrder $order): void
{
EloquentOrderModel::updateOrCreate(
['id' => $order->id->value],
OrderDataMapper::toPersistence($order)
);
}
}
Paso 5: Registro en el Contenedor IoC de Laravel
En nuestro RepositoryServiceProvider, vinculamos el contrato con la implementación concreta:
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. Beneficios Tangibles de este Enfoque
- Tests Unitarios a la Velocidad de la Luz: Probar el caso de uso
CaptureOrderUseCasetoma < 2 milisegundos, porque se prueba en memoria con unInMemoryOrderRepositoryfalso sin tocar SQLite, MySQL ni arrancar migraciones de base de datos. - Reemplazo de Persistencia sin Traumas: Si necesitas mover un módulo de alta escritura a Redis, ScyllaDB o MongoDB, el 100% de la lógica de negocio y las validaciones de dominio permanecen intactas.
- Cero Acoplamiento a Versiones del Framework: Las actualizaciones mayores de Laravel (de v10 a v11, o v11 a v12) solo afectan al adaptador de entrada HTTP o persistencia, reduciendo los costes de mantenimiento a largo plazo.
Conclusión
No es necesario abandonar Laravel para escribir software de calidad empresarial. Utilizar el Service Container de Laravel como pegamento para una Arquitectura Hexagonal ofrece lo mejor de los dos mundos: la velocidad de desarrollo del ecosistema y la protección matemática de un dominio desacoplado.
Artículos Relacionados
La Anatomía de Laravel: Facades, IoC Containers y el Debate Eloquent vs Doctrine (Parte 2)
Desmitificando el diseño interno de Laravel: cómo funcionan realmente las Facades mediante metaprogramación, auto-wiring en el Contenedor IoC y el dilema arquitectónico entre Active Record y Data Mapper.
De PHP-FPM a FrankenPHP: Por qué el modelo Shared-Nothing ya no es la única opción
Cómo la persistencia en memoria y el Worker Mode de FrankenPHP eliminan el impuesto de arranque en frameworks modernos como Laravel y reducen latencias de 40ms a cifras sub-milisegundo.
¿Necesitas diseñar sistemas resistentes sin sobrecostes de infraestructura?
Hablemos 15 minutos en una sesión de diagnóstico técnico con Pablo para auditar tus cuellos de botella y definir la arquitectura adecuada.