La primera vez que oyes hablar de arquitectura hexagonal suena a sobreingeniería para un CRUD con Symfony. La segunda vez, cuando llevas dos años añadiendo if a un controlador porque "solo es un caso más", empieza a sonar razonable. Llevo aplicándola en proyectos Symfony desde hace varios años y esto es lo que realmente importa, sin la teoría de más.

La idea en una frase

El dominio (las reglas de negocio) no debe depender de nada externo: ni de Doctrine, ni de Symfony, ni de una API de terceros. Todo lo externo se conecta al dominio a través de interfaces — puertos— y cada tecnología concreta implementa esos puertos mediante adaptadores. Doctrine es un adaptador. Un controlador HTTP es un adaptador. Un comando de consola es un adaptador. El dominio no sabe que existen.

Estructura de carpetas que uso

En vez de organizar por capa técnica (Controller/, Entity/, Repository/ a nivel de toda la app), organizo por contexto de negocio, y dentro de cada contexto separo dominio, aplicación e infraestructura:

src/
  Pedidos/
    Domain/
      Pedido.php
      PedidoRepository.php        (interfaz, el puerto)
      Money.php
    Application/
      CrearPedido/
        CrearPedidoCommand.php
        CrearPedidoHandler.php
    Infrastructure/
      Persistence/
        DoctrinePedidoRepository.php  (adaptador)
      Http/
        CrearPedidoController.php     (adaptador)
  Shared/
    Domain/
    Infrastructure/

La regla que hago cumplir sin excepciones: nada dentro de Domain/ tiene un use apuntando a Doctrine, Symfony o cualquier librería de infraestructura. Si un objeto de dominio necesita persistirse, define una interfaz en Domain/ y la implementa en Infrastructure/Persistence/.

Puertos: interfaces, no clases

Un puerto es simplemente una interfaz PHP definida en el dominio:

namespace App\Pedidos\Domain;

interface PedidoRepository
{
    public function save(Pedido $pedido): void;
    public function ofId(PedidoId $id): ?Pedido;
}

El adaptador de Doctrine implementa esa interfaz y vive fuera del dominio:

namespace App\Pedidos\Infrastructure\Persistence;

use App\Pedidos\Domain\Pedido;
use App\Pedidos\Domain\PedidoRepository;
use Doctrine\ORM\EntityManagerInterface;

final class DoctrinePedidoRepository implements PedidoRepository
{
    public function __construct(private EntityManagerInterface $em) {}

    public function save(Pedido $pedido): void
    {
        $this->em->persist($pedido);
        $this->em->flush();
    }

    public function ofId(PedidoId $id): ?Pedido
    {
        return $this->em->find(Pedido::class, $id->value());
    }
}

Symfony conecta ambos mediante services.yaml haciendo bind de la interfaz a la implementación concreta. El handler de aplicación solo conoce PedidoRepository, nunca DoctrinePedidoRepository.

Casos de uso como Command + Handler

Cada acción de negocio (crear un pedido, cancelar una suscripción, aplicar un descuento) es un command sencillo y un handler que orquesta el dominio:

final class CrearPedidoHandler
{
    public function __construct(
        private PedidoRepository $repository,
        private ClienteRepository $clientes,
    ) {}

    public function __invoke(CrearPedidoCommand $command): PedidoId
    {
        $cliente = $this->clientes->ofId($command->clienteId);
        $pedido = Pedido::crear($cliente, $command->lineas);

        $this->repository->save($pedido);

        return $pedido->id();
    }
}

El controlador HTTP se limita a deserializar la petición, construir el command y devolver una respuesta. Cero lógica de negocio en el controlador — eso es justo lo que evita que crezca sin control con el tiempo.

¿Cuándo NO merece la pena?

Sé honesto contigo mismo aquí. Si estás montando un prototipo, un microservicio de 3 endpoints o un proyecto que sabes que va a durar seis meses, esta estructura añade fricción sin devolver nada a cambio. La arquitectura hexagonal paga cuando el proyecto va a vivir años, cambiar de proveedor de persistencia es una posibilidad real, o el dominio tiene reglas de negocio complejas que merece la pena testear sin arrancar el framework completo.

El beneficio que más noto en el día a día

No es la teoría de "podríamos cambiar Doctrine por Mongo". Es que los tests del dominio corren en milisegundos porque no arrancan el kernel de Symfony, y que cuando entra alguien nuevo al equipo, Domain/Pedido.php se puede leer de arriba a abajo sin necesidad de saber qué ORM usamos. Esa separación es la que realmente sostiene un proyecto Symfony grande a largo plazo.