
August 15, 2026
10 min read
Table of Contents
By Kokil Thapa | Last reviewed: August 2026
Implementing Hexagonal Architecture with Symfony solves the most persistent problem in enterprise PHP development: business logic that is hopelessly tangled with framework code, database schemas, and third-party APIs. Instead of letting Symfony’s infrastructure layer dictate your domain model, this architectural pattern enforces a strict boundary where core application rules remain pure, portable, and independently testable. For developers building complex legal-tech platforms or high-volume eCommerce systems, adopting this structure transforms maintenance from a nightmare of regression bugs into a predictable engineering workflow.
This approach contrasts sharply with the traditional MVC structure many Laravel and PHP developers start with, where controllers often become bloated god-classes. While I frequently build rapid prototypes using standard MVC, production systems requiring long-term maintainability benefit immensely from this decoupling. If you are evaluating whether this investment makes sense for your team, consider reading about modern architecture best practices to understand the trade-offs before committing to the stricter folder structure required here.
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 an application that behaves uniformly regardless of how it is driven or where data is stored. In the context of Symfony 7.x running on PHP 8.4, this means your domain entities and services have zero knowledge of HTTP requests, JSON responses, MySQL tables, or Redis caches.
The primary motivation is dependency inversion. In a standard Symfony bundle structure, it is tempting to inject the EntityManager directly into a controller or service. This creates a hard coupling to Doctrine ORM. If you later need to switch storage engines, or simply want to unit test a pricing algorithm without bootstrapping a database connection, you hit a wall. Hexagonal Architecture forces you to define a repository interface (a port) in the domain layer, which the infrastructure layer then implements.
In my experience working on production legal-tech portals, this separation proved invaluable when we needed to change how document generation worked without touching the case management logic. The domain layer defined what a "legal document" was; the infrastructure layer handled whether it was generated via TCPDF, stored in S3, or emailed via SMTP. Without this architecture, such changes would have rippled through dozens of controller methods.
How Do You Structure Folders for Hexagonal Architecture with Symfony?
Symfony does not enforce a specific directory structure beyond its own kernel requirements, which gives you freedom but also demands discipline. Abandon the default src/Entity, src/Controller, and src/Repository grouping. Instead, organize by bounded context or functional module first, then by architectural layer.
Recommended Directory Layout
src/
├── Domain/ # Pure PHP, no Symfony dependencies
│ ├── Model/ # Entities, Value Objects
│ ├── Service/ # Domain logic services
│ └── Port/ # Interfaces (Repositories, External Services)
│ └── Out/ # Driven ports (DB, Email, Payment)
├── Application/ # Orchestration only
│ ├── UseCase/ # One class per action (CreateOrderHandler)
│ ├── DTO/ # Input/Output data transfer objects
│ └── Port/ # Inbound ports (optional interface definition)
├── Infrastructure/ # Framework & External details
│ ├── Persistence/ # Doctrine repositories implementing Domain\Port
│ ├── Http/ # Symfony Controllers
│ ├── External/ # API clients (Stripe, eSewa)
│ └── Config/ # Service definitions, DI tags
└── Kernel.php This structure physically prevents accidental leakage. A developer looking at Domain/Model/Order.php immediately knows they cannot type-hint RequestStack or EntityManagerInterface. Conversely, Infrastructure/Http/OrderController.php is clearly the place for HTTP concerns like status codes and header manipulation.
When configuring Symfony’s service container for this layout, update config/services.yaml to bind interfaces to implementations automatically. This avoids manual wiring for every new adapter:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\Domain\:
resource: '../src/Domain/'
exclude: '../src/Domain/{Model,Port}'
App\Application\:
resource: '../src/Application/'
App\Infrastructure\:
resource: '../src/Infrastructure/'
# Auto-bind all Domain ports to Infrastructure implementations
App\Domain\Port\Out\:
resource: '../src/Infrastructure/Persistence/' How Do You Define Ports and Adapters in Symfony 7?
Ports are simply PHP interfaces residing in the Domain layer. They define contracts for capabilities the domain needs but does not implement. Adapters live in the Infrastructure layer and fulfill these contracts using specific technologies.
Defining a Driven Port (Outbound)
Consider a scenario where orders must be persisted. The domain shouldn't know about SQL. Define the contract based on domain concepts, not database operations:
<?php
// src/Domain/Port/Out/OrderRepositoryInterface.php
namespace App\Domain\Port\Out;
use App\Domain\Model\Order;
use App\Domain\Model\OrderId;
interface OrderRepositoryInterface
{
public function save(Order $order): void;
public function findById(OrderId $id): ?Order;
/** @return Order[] */
public function findPendingOrders(): array;
} Notice the return types and parameters are strictly domain objects. There is no mention of arrays, Doctrine collections, or query builders. This interface belongs entirely to the business.
Implementing the Adapter
The infrastructure adapter translates between the domain's pure objects and the persistence mechanism. With Doctrine ORM in Symfony 7, this typically involves mapping domain entities to Doctrine mappings (via XML or attributes) and handling hydration:
<?php
// src/Infrastructure/Persistence/DoctrineOrderRepository.php
namespace App\Infrastructure\Persistence;
use App\Domain\Model\Order;
use App\Domain\Model\OrderId;
use App\Domain\Port\Out\OrderRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
final readonly class DoctrineOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private EntityManagerInterface $em
) {}
public function save(Order $order): void
{
$this->em->persist($order);
$this->em->flush();
}
public function findById(OrderId $id): ?Order
{
return $this->em->find(Order::class, $id->value());
}
public function findPendingOrders(): array
{
// Uses DQL or QueryBuilder, returns hydrated Domain entities
return $this->em->createQuery(
'SELECT o FROM App\Domain\Model\Order o WHERE o.status = :status'
)
->setParameter('status', 'PENDING')
->getResult();
}
} A common mistake I see in junior implementations is leaking Doctrine proxies or lazy-loading exceptions into the domain. Always ensure your repository fully hydrates objects or uses explicit fetching strategies before returning them across the boundary.
How Does Testing Differ in Hexagonal Architecture with Symfony?
The most tangible ROI of this architecture appears in testing velocity. Because the domain and application layers have no framework dependencies, you can write fast, deterministic unit tests without PHPUnit extensions for Symfony or database fixtures.
Unit Testing the Domain
Testing an order creation use case becomes trivial. You mock the port interface, not the database:
<?php
// tests/Unit/Application/UseCase/CreateOrderHandlerTest.php
public function test_it_creates_and_persists_valid_order(): void
{
$repository = $this->createMock(OrderRepositoryInterface::class);
$repository->expects($this->once())->method('save');
$handler = new CreateOrderHandler($repository);
$command = new CreateOrderCommand('PROD-001', 2, 'customer@example.com');
$result = $handler->__invoke($command);
$this->assertInstanceOf(OrderCreatedResponse::class, $result);
$this->assertNotEmpty($result->orderId);
} This test executes in milliseconds. Compare this to a functional test in standard Symfony where you might need to boot the kernel, migrate a test database, and wait for I/O. On a recent project involving complex attestation workflows, moving business validation to the domain layer reduced our CI pipeline duration by 40% because we stopped running full-stack tests for pure logic verification.
Integration Testing Adapters
You still need integration tests, but they are scoped narrowly. Test the DoctrineOrderRepository against a real database to verify SQL correctness and mapping integrity. Test the StripePaymentAdapter against Stripe’s test mode. Never mix these concerns. This targeted approach aligns well with strategies discussed in API best practices where contract testing often supersedes monolithic integration suites.
Hexagonal Architecture vs Traditional Symfony MVC: When to Choose Which?
Not every project warrants this level of abstraction. Over-engineering is as dangerous as under-engineering. Use this comparison to make an informed decision based on project constraints.
| Criteria | Traditional MVC Bundle | Hexagonal Architecture |
|---|---|---|
| Initial Setup Time | Low (standard skeleton) | High (custom structure, DI config) |
| Simple CRUD Speed | Fast (MakerBundle ready) | Slower (boilerplate per feature) |
| Complex Business Rules | Becomes messy quickly | Excels, keeps logic isolated |
| Testability | Requires kernel/database often | Pure unit tests possible |
| Framework Upgrades | Coupled to Symfony changes | Domain immune to upgrades |
| Team Onboarding | Easier for juniors | Steeper learning curve |
| Ideal For | CMS, simple apps, MVPs | SaaS, FinTech, Legal-Tech, ERP |
If you are building a marketing site or a simple directory like those described in my SME website development guide, stick to standard Symfony bundles. The overhead of ports and adapters will slow you down without providing proportional value. However, if you are building a system where business rules change frequently independent of UI updates, or where multiple delivery mechanisms (web, CLI, queue workers) share logic, hexagonal architecture pays dividends within months.
Common Pitfalls When Adopting Hexagonal Architecture with Symfony
Even experienced teams stumble when transitioning to this pattern. Recognizing these anti-patterns early saves significant refactoring effort.
- Anemic Domain Models: Creating entities that are just data bags with getters/setters while putting all logic in "services." True hexagonal architecture encourages rich domain models where entities encapsulate their own state transitions and validation.
- Leaky Abstractions: Returning Doctrine Collections or exposing infrastructure exceptions from repository ports. Always map to domain collections or throw domain-specific exceptions.
- Over-Segmentation: Creating separate modules for tiny features that don’t warrant bounded contexts. Start monolithic within the hexagonal structure; split only when clear linguistic boundaries emerge.
- Ignoring Symfony’s Strengths: Reinventing event dispatching or security instead of leveraging Symfony’s battle-tested components. The infrastructure layer exists precisely to integrate these tools cleanly.
- Premature Optimization: Building generic abstractions before understanding the specific domain. Write concrete implementations first, extract interfaces only when a second use case emerges.
In my work on Nepal-based legal platforms, we initially over-engineered the user management module with excessive ports. We later simplified it back to a more direct implementation once we realized authentication wasn’t a volatile business rule. Pragmatism must always trump dogma.
Maintaining Discipline in Long-Term Projects
Adopting Hexagonal Architecture with Symfony is not a one-time setup task; it is an ongoing discipline. Code reviews should explicitly check for dependency direction violations. Static analysis tools like PHPStan or Rector can be configured to forbid certain namespaces from importing others, automating enforcement of architectural boundaries.
For teams in Nepal or distributed environments where senior oversight may be limited, invest time in documenting the "why" behind each layer. Create README files in each directory explaining its purpose and allowed dependencies. This reduces cognitive load for new contributors and prevents gradual erosion of the architecture under deadline pressure.
Ultimately, this architecture serves the business by making software adaptable. Whether you are integrating a new payment gateway like ConnectIPS or refactoring legacy case management logic, the cost of change remains predictable. If your current Symfony project feels brittle or testing is painful, consider a phased migration starting with the most complex domain module rather than a complete rewrite.
Ready to restructure your PHP application for long-term resilience? Contact me to discuss architectural audits or hands-on implementation support for your Symfony or Laravel systems.

