From symfony-skills
Structures Symfony apps with ports & adapters / clean architecture, keeping the domain framework-free. Use for complex, long-lived domains where isolating business logic from the framework pays off.
How this skill is triggered — by the user, by Claude, or both
Slash command
/symfony-skills:hexagonal-architectureThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
> **When to reach for this.** Hexagonal is for complex, long-lived domains where isolating business logic from the framework pays off — rich invariants, multiple delivery mechanisms, logic you expect to outlive the current stack. For typical CRUD and feature work (the ~80% of most apps), use [`layered-architecture`](../layered-architecture) instead; strict ports/adapters there is just overhead....
When to reach for this. Hexagonal is for complex, long-lived domains where isolating business logic from the framework pays off — rich invariants, multiple delivery mechanisms, logic you expect to outlive the current stack. For typical CRUD and feature work (the ~80% of most apps), use
layered-architectureinstead; strict ports/adapters there is just overhead. Apply this to the gnarly 20% that earns it, not as a blanket rule.
┌─────────────────────────────────────────┐
│ Infrastructure │ ← Symfony, Doctrine, HTTP, Messenger
│ (driving + driven adapters) │
│ ┌───────────────────────────────────┐ │
│ │ Application │ │ ← use cases / handlers
│ │ ┌───────────────────────────┐ │ │
│ │ │ Domain │ │ │ ← entities, value objects, ports
│ │ │ (no framework imports) │ │ │
│ │ └───────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
Source code dependencies point inward. The domain knows nothing about Symfony, Doctrine, or HTTP. Outer layers depend on inner-layer interfaces, never the reverse.
src/
├── Domain/ # pure PHP — no Symfony/Doctrine "use" statements
│ ├── Order/
│ │ ├── Order.php # aggregate root (plain object, no #[ORM\...])
│ │ ├── OrderId.php # value object
│ │ ├── OrderStatus.php # enum
│ │ └── OrderRepository.php # PORT (interface)
├── Application/ # use cases — orchestrates domain via ports
│ └── Order/
│ ├── CreateOrderHandler.php
│ └── CreateOrderCommand.php
└── Infrastructure/ # adapters — implement ports using Symfony/Doctrine
├── Persistence/Doctrine/
│ ├── DoctrineOrderRepository.php # ADAPTER implements Domain\Order\OrderRepository
│ └── Mapping/Order.orm.xml # ORM mapping kept OUT of the domain class
└── Http/
└── CreateOrderController.php # driving adapter
A port is an interface the domain owns and the infrastructure implements.
// ✅ src/Domain/Order/OrderRepository.php — no framework imports
namespace App\Domain\Order;
interface OrderRepository
{
public function save(Order $order): void;
public function ofId(OrderId $id): ?Order;
}
// ✅ src/Infrastructure/Persistence/Doctrine/DoctrineOrderRepository.php
namespace App\Infrastructure\Persistence\Doctrine;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepository;
use Doctrine\ORM\EntityManagerInterface;
final class DoctrineOrderRepository implements OrderRepository
{
public function __construct(private readonly EntityManagerInterface $em) {}
public function save(Order $order): void
{
$this->em->persist($order);
$this->em->flush();
}
public function ofId(OrderId $id): ?Order
{
return $this->em->find(Order::class, $id->toString());
}
}
Wire the interface to the implementation in config/services.yaml:
services:
App\Domain\Order\OrderRepository: '@App\Infrastructure\Persistence\Doctrine\DoctrineOrderRepository'
The hard part in Symfony is that Doctrine attribute mapping (#[ORM\Entity]) couples the entity to the ORM. Two acceptable strategies:
Infrastructure/Persistence/Doctrine/Mapping/. The domain never imports Doctrine.// ✅ STRICT — pure aggregate, mapped externally via XML
namespace App\Domain\Order;
final class Order
{
/** @var Collection<int, OrderLine>|OrderLine[] */
private array $lines = [];
private function __construct(
private readonly OrderId $id,
private OrderStatus $status,
) {}
public static function place(OrderId $id): self
{
return new self($id, OrderStatus::Pending); // invariant: new orders start Pending
}
}
State the chosen strategy in the project README so the agent stays consistent.
One handler per use case. It depends only on ports, never on adapters.
// ✅ src/Application/Order/CreateOrderHandler.php
namespace App\Application\Order;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepository;
final readonly class CreateOrderHandler
{
public function __construct(private OrderRepository $orders) {}
public function __invoke(CreateOrderCommand $command): OrderId
{
$order = Order::place(OrderId::generate());
$this->orders->save($order);
return $order->id();
}
}
Controllers, console commands, and Messenger handlers are driving adapters. They translate a delivery mechanism into an application call and translate the result back out. They contain no business logic.
EntityManagerInterface into the application/domain layer — depend on a repository port instead.Symfony\… or Doctrine\… in src/Domain/ — the domain must stay framework-free (or use the agreed pragmatic exception for ORM attributes only).DoctrineOrderRepository — depend on the interface; wire it in services.yaml.Response from a use-case handler — return a domain value (ID, DTO); the controller builds the HTTP response.#[ORM\Entity] on a "pure" domain class when the project chose the strict XML-mapping strategy — check the README's stated strategy first.npx claudepluginhub fatonh/symfony-skills --plugin symfony-skillsImplements Hexagonal Architecture (Ports and Adapters) in Symfony, separating domain logic from infrastructure with clear boundaries.
Implements Clean Architecture, Hexagonal (Ports & Adapters), and Domain-Driven Design patterns in PHP 8.3+ with Symfony 7.x. For enterprise app architecture, legacy refactoring, DDD, and testable backends.
Enforces layered architecture in Symfony: controllers handle HTTP, services contain business logic, repositories handle data access, DTOs map between layers. Use when generating controllers, services, repositories, or DTOs.