Tu código es una fortaleza, no un espagueti
\n\n¿Alguna vez has tenido que reescribir un proyecto de Laravel a Symfony o de MySQL a PostgreSQL? Si es así, conoces ese dolor: controladores llenos de consultas SQL, modelos que lo saben todo sobre peticiones HTTP y una lógica de negocio que muere al cambiar de framework. ¿Te suena familiar?
\n\nBienvenido al mundo de la Arquitectura Hexagonal en PHP (también conocida como Ports & Adapters). No es una palabra de moda, sino un salvavidas para proyectos que quieren vivir larga y felizmente. En este artículo, veremos cómo construir una aplicación PHP que no le tema a los cambios: migrar de base de datos, cambiar de caché o integrar una nueva API se convertirá en un simple reemplazo de adaptador, no en reescribir la mitad del código.
\n\n¿Qué es realmente la Arquitectura Hexagonal?
\n\nImagina que tu aplicación es un núcleo (core) que no sabe de dónde vienen los datos: si de la web, la consola, un bot de Telegram o los tests. Alrededor de este núcleo tienes puertos (interfaces) y en el exterior, adaptadores (implementaciones). La arquitectura hexagonal en PHP permite separar la lógica de negocio del código de infraestructura. Escribes las reglas una vez y las usas en todas partes.
\n\nPrincipios clave:
\n- \n
- Inversión de dependencias — el código de alto nivel no depende del de bajo nivel. Todo es al revés. \n
- Lógica de negocio aislada — ningún framework penetra en tus Casos de Uso. \n
- Capacidad de prueba — puedes probar el núcleo sin base de datos ni HTTP. \n
A diferencia de la Clean Architecture de Robert Martin, la Hexagonal es más flexible: no dicta la cantidad de capas, solo dice: «Dentro está el dominio, fuera la infraestructura, conéctalos mediante interfaces».
\n\nEstructura del proyecto: del caos al orden
\n\nVamos directo al grano. Esta es una estructura típica de carpetas para la Arquitectura Hexagonal en PHP:
\n\nsrc/\n├── Domain/\n│ ├── Entity/\n│ │ └── Order.php\n│ ├── Repository/\n│ │ └── OrderRepositoryInterface.php\n│ └── Service/\n│ └── OrderService.php\n├── Application/\n│ ├── UseCase/\n│ │ └── CreateOrderUseCase.php\n│ └── Port/\n│ ├── Input/\n│ │ └── CreateOrderInputPort.php\n│ └── Output/\n│ └── OrderOutputPort.php\n├── Infrastructure/\n│ ├── Persistence/\n│ │ └── DoctrineOrderRepository.php\n│ └── Http/\n│ └── OrderController.php\n└── Framework/\n └── Symfony/\n └── Kernel.php\n\n¿Te diste cuenta? Domain y Application no saben que existen Doctrine, Symfony o MySQL. Solo trabajan con interfaces. Los adaptadores viven en Infrastructure y se conectan mediante Inyección de Dependencias.
Paso 1: Definimos el núcleo — Domain
\n\nEmpecemos con la entidad de pedido. Sin anotaciones de ORM, sin setters que rompan invariantes:
\n\n<?php\n// src/Domain/Entity/Order.php\nnamespace App\\Domain\\Entity;\n\nclass Order\n{\n public function __construct(\n private readonly string $id,\n private readonly string $productName,\n private float $totalAmount,\n private string $status = 'pending'\n ) {}\n\n public function approve(): void\n {\n if ($this->status !== 'pending') {\n throw new \\DomainException('Solo los pedidos pendientes pueden ser aprobados.');\n }\n $this->status = 'approved';\n }\n\n public function getId(): string { return $this->id; }\n public function getProductName(): string { return $this->productName; }\n public function getTotalAmount(): float { return $this->totalAmount; }\n public function getStatus(): string { return $this->status; }\n}\n\nY aquí el puerto (interfaz) para el repositorio. Nota: no sabe nada de Doctrine ni PDO:
\n\n<?php\n// src/Domain/Repository/OrderRepositoryInterface.php\nnamespace App\\Domain\\Repository;\n\nuse App\\