
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Most Symfony applications start simple but degrade into tangled spaghetti code as business rules multiply and framework dependencies spread through every layer. Implementing Hexagonal Architecture with Symfony solves this by placing your domain model at the center and isolating it from databases, APIs, and the framework itself via explicit ports and adapters. This guide provides the concrete directory structure, service configuration, and adapter patterns you need to build a decoupled system that remains testable and maintainable long-term.
What is Hexagonal Architecture with Symfony and why use it?
Hexagonal Architecture, also known as Ports and Adapters, was coined by Alistair Cockburn to create software where the application can be equally driven by users, programs, automated tests, or batch scripts without any change to the core logic. When applied to Symfony, this pattern directly combats the "fat controller" and "active record" anti-patterns that plague many PHP projects. Instead of your entities extending a base ORM class or your services calling static facade methods, your domain objects remain plain PHP classes with zero external dependencies.
The primary benefit is testability. Because your business logic depends only on interfaces (ports), you can write fast unit tests that verify complex rules without booting the Symfony kernel, connecting to a database, or mocking HTTP clients. For teams building systems that must evolve over years—whether in Kathmandu's growing tech sector or serving global clients—this separation reduces the cost of change. If you need to swap Doctrine for MongoDB, or replace an SMTP mailer with an API-based service, you modify only the adapter layer. The domain remains untouched. This aligns closely with principles discussed in our microservices vs monolith comparison, where internal modularity often delays or eliminates the need for distributed complexity.
How do you structure a Symfony project for Hexagonal Architecture?
A common mistake is trying to force hexagonal boundaries inside Symfony’s default src/ structure. While possible, it fights the framework’s conventions. In practice, I recommend organizing by bounded context or module first, then applying the hexagonal layers within each. This scales better than a single global Domain/ folder.
Recommended Directory Layout
src/
├── OrderModule/
│ ├── Domain/
│ │ ├── Model/
│ │ │ ├── Order.php # Pure entity, no Doctrine attributes
│ │ │ └── OrderId.php # Typed value object
│ │ ├── Port/
│ │ │ ├── In/
│ │ │ │ └── PlaceOrderUseCase.php
│ │ │ └── Out/
│ │ │ └── OrderRepositoryInterface.php
│ │ └── Service/
│ │ └── OrderPricingService.php # Pure domain logic
│ ├── Application/
│ │ └── PlaceOrderHandler.php # Orchestrates use case
│ ├── Infrastructure/
│ │ ├── Persistence/
│ │ │ ├── DoctrineOrderRepository.php
│ │ │ └── Mapping/Order.orm.xml
│ │ └── Api/
│ │ └── PaymentGatewayAdapter.php
│ └── UserInterface/
│ ├── Controller/
│ │ └── OrderController.php
│ └── Cli/
│ └── ExportOrdersCommand.php
This structure enforces the dependency rule physically. Code in Domain/ never imports from Infrastructure/ or UserInterface/. The Application/ layer coordinates use cases and depends only on domain ports. Infrastructure implements the outbound ports, and the user interface drives inbound ports. Note that mapping files (like Doctrine XML) live alongside their adapter implementation, keeping persistence concerns completely out of the domain model.
How do you define ports and implement adapters in Symfony?
Ports are simply PHP interfaces residing in the domain layer. They express what the domain needs, not how it’s achieved. Adapters live in the infrastructure layer and implement these interfaces using specific technologies.
Defining an Outbound Port
// src/OrderModule/Domain/Port/Out/OrderRepositoryInterface.php
namespace App\OrderModule\Domain\Port\Out;
use App\OrderModule\Domain\Model\Order;
use App\OrderModule\Domain\Model\OrderId;
interface OrderRepositoryInterface
{
public function findById(OrderId $id): ?Order;
public function save(Order $order): void;
public function nextIdentity(): OrderId;
}
Critically, this interface returns and accepts only domain objects. It does not leak query builders, pagination parameters, or ORM-specific types. If you later switch from PostgreSQL to MongoDB—as covered in our MongoDB administration basics guide—the domain signature remains identical.
Implementing the Driven Adapter
// src/OrderModule/Infrastructure/Persistence/DoctrineOrderRepository.php
namespace App\OrderModule\Infrastructure\Persistence;
use App\OrderModule\Domain\Model\Order;
use App\OrderModule\Domain\Model\OrderId;
use App\OrderModule\Domain\Port\Out\OrderRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
final class DoctrineOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private readonly EntityManagerInterface $em
) {}
public function findById(OrderId $id): ?Order
{
return $this->em->find(Order::class, $id);
}
public function save(Order $order): void
{
$this->em->persist($order);
$this->em->flush();
}
public function nextIdentity(): OrderId
{
// Generate UUID or use DB sequence
return OrderId::generate();
}
}
Wiring with Symfony’s Service Container
Symfony’s autowiring handles the binding automatically if you follow naming conventions, but explicit aliasing prevents ambiguity when multiple implementations exist:
# config/services.yaml
services:
App\OrderModule\Domain\Port\Out\OrderRepositoryInterface:
alias: App\OrderModule\Infrastructure\Persistence\DoctrineOrderRepository
# Tag controllers and commands for autoconfiguration
App\OrderModule\UserInterface\Controller\:
tags: ['controller.service_arguments']
resource: '../src/OrderModule/UserInterface/Controller/'
This configuration ensures that whenever a constructor requires OrderRepositoryInterface, Symfony injects the Doctrine implementation. During testing, you replace this alias with a fake or in-memory implementation without touching production code.
How does Hexagonal Architecture compare to traditional Symfony MVC?
Understanding the trade-offs helps you decide when to adopt this pattern. Traditional MVC works well for CRUD-heavy admin panels or simple websites. Hexagonal Architecture shines when business complexity is high, compliance requirements demand auditability, or you anticipate swapping infrastructure components.
| Criteria | Traditional Symfony MVC | Hexagonal Architecture with Symfony |
|---|---|---|
| Test Speed | Slow (requires kernel boot, DB fixtures) | Fast (pure unit tests, in-memory fakes) |
| Framework Coupling | High (entities extend BaseEntity, controllers use traits) | Low (domain has zero Symfony imports) |
| Onboarding Complexity | Low (standard docs apply) | Moderate (team must learn port/adapter discipline) |
| Database Swapping | Painful (rewrite queries, update mappings globally) | Isolated (replace one adapter class) |
| Compliance Audits | Hard to prove isolation of sensitive logic | Clear boundaries simplify evidence collection |
| Boilerplate | Minimal | Higher (interfaces, handlers, separate models) |
For teams already practicing rigorous observability—as detailed in our four golden signals of monitoring guide—hexagonal architecture complements operational excellence by making failure domains explicit. When an adapter fails, the blast radius is contained. When domain logic is buggy, it manifests consistently across all driving adapters, making reproduction trivial.
What are common pitfalls when adopting Hexagonal Architecture in Symfony?
I’ve seen teams fail not because the pattern is flawed, but because they violate its constraints subtly. Avoid these three mistakes:
- Anemic Domain Models: Moving all logic to "services" while leaving entities as mere data holders defeats the purpose. Your
Orderentity should enforce its own invariants ($order->confirm(), not$orderService->confirmOrder($order)). Rich domain models reduce procedural sprawl. - Leaky Abstractions in Ports: If your repository interface exposes
QueryBuilderor pagination arrays, you’ve coupled the domain to Doctrine. Return collections or domain-specific result objects instead. Pagination belongs in the read model or application layer. - Over-Hexagonalizing Simple Features: Not every endpoint needs six files. For static pages or trivial lookups, pragmatic MVC is fine. Reserve full hexagonal treatment for modules with genuine business complexity. Dogma kills adoption.
Another practical concern is serialization. Never expose domain entities directly to controllers. Create dedicated DTOs or API resources in the UserInterface layer. This prevents accidental coupling between your API contract and internal state representation. Tools like Symfony Serializer work perfectly here, but map explicitly rather than relying on reflection against domain objects.
Start Building Maintainable Symfony Applications Today
Adopting Hexagonal Architecture with Symfony is an investment in long-term velocity. It forces clarity about what your business actually does versus how it happens to be implemented today. Start small: extract one critical module, define its ports rigorously, and measure the improvement in test execution time and developer confidence. The initial boilerplate pays dividends during the inevitable refactors, audits, and platform migrations ahead. If your team needs guidance on structuring complex PHP systems or preparing infrastructure for compliance reviews, reach out to discuss your architecture. Clean foundations prevent costly rewrites tomorrow.