Hexagonal Architecture with Symfony

Khimananda Oli 8 min read Web Development
Hexagonal Architecture with Symfony

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.

Hexagonal Architecture LayersDOMAIN CORE(Pure PHP Entities & Value Objects)INBOUND PORTSOUTBOUND PORTSDRIVING ADAPTERS(Controllers, CLI, Tests)DRIVEN ADAPTERS(Doctrine, APIs, Files)
Conceptual overview of Hexagonal Architecture with Symfony: dependencies flow inward from adapters through ports to the pure domain core.

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.

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.

Request Flow Through Hexagonal LayersController(UserInterface)Use Case Handler(Application)Domain Service(Pure Logic)Outbound Port(Interface)Doctrine Adapter(Infrastructure)HTTP RequestJSON Response
Sequence diagram showing how a request flows through Hexagonal Architecture with Symfony: Controller → Use Case → Domain → Port → Adapter.

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.

CriteriaTraditional Symfony MVCHexagonal Architecture with Symfony
Test SpeedSlow (requires kernel boot, DB fixtures)Fast (pure unit tests, in-memory fakes)
Framework CouplingHigh (entities extend BaseEntity, controllers use traits)Low (domain has zero Symfony imports)
Onboarding ComplexityLow (standard docs apply)Moderate (team must learn port/adapter discipline)
Database SwappingPainful (rewrite queries, update mappings globally)Isolated (replace one adapter class)
Compliance AuditsHard to prove isolation of sensitive logicClear boundaries simplify evidence collection
BoilerplateMinimalHigher (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:

  1. Anemic Domain Models: Moving all logic to "services" while leaving entities as mere data holders defeats the purpose. Your Order entity should enforce its own invariants ($order->confirm(), not $orderService->confirmOrder($order)). Rich domain models reduce procedural sprawl.
  2. Leaky Abstractions in Ports: If your repository interface exposes QueryBuilder or 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.
  3. 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.

Traditional MVC DependenciesControllerService / EntityDatabase / Framework⚠ Dependencies Point OUTWARDHexagonal DependenciesAdapter (UI / Infra)Port (Interface)Domain Core✓ Dependencies Point INWARD
Side-by-side comparison of dependency direction: traditional MVC couples outward to infrastructure, while Hexagonal Architecture with Symfony inverts dependencies toward the domain.

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.

Frequently Asked Questions

It separates core domain logic from external frameworks using ports and adapters. Symfony handles HTTP and persistence as outer-layer adapters, keeping business rules testable and independent of infrastructure changes or vendor lock-in.

Create src/Domain, src/Application, and src/Infrastructure directories. Domain holds entities and value objects. Application contains use cases. Infrastructure implements Symfony controllers, Doctrine repositories, and message bus handlers as adapters connecting to domain ports.

Yes, but treat Doctrine as an infrastructure adapter. Define repository interfaces in the domain layer and implement them using EntityManager in infrastructure. Never expose Doctrine annotations or query builders inside domain entities or application services.

Often yes. Simple CRUD apps rarely justify the indirection. Reserve it for complex domains with evolving business rules, multiple integration points, or teams needing strict separation between core logic and framework-specific code.

Configure interface-to-implementation bindings in services.yaml. Domain ports are interfaces; infrastructure adapters are concrete classes. Use autowiring carefully to avoid leaking framework dependencies into inner layers through constructor type hints.

Controllers become thin adapters that deserialize requests, invoke application use cases, and serialize responses. All orchestration logic lives in use case classes within the application layer, making endpoints trivially testable without HTTP overhead.

Write pure PHPUnit tests against domain entities and use cases using fake or in-memory port implementations. No container bootstrapping needed. This keeps feedback loops under 100ms and validates business rules in isolation.

Absolutely. Message handlers are infrastructure adapters implementing command or query ports. Dispatch messages via domain events or application services. The transport mechanism remains swappable without affecting core workflow definitions or validation logic.

Use dedicated mappers in the application layer. Never pass raw request arrays into domain constructors. Validate input first, then construct value objects or entities through factory methods that enforce invariants and reject invalid states explicitly.

Leaking Doctrine types into domain models, putting business logic in controllers, creating anemic domains with only getters/setters, and over-engineering simple features. Start pragmatic and refactor toward purity only when complexity demands it.

Commands and queries become separate port interfaces. Write models mutate state through domain aggregates; read models project data via dedicated query adapters. This aligns naturally with hexagonal boundaries and enables independent scaling of read and write paths.

Yes. Extract one bounded context at a time. Create domain and application layers for new features first. Refactor legacy controllers into adapters incrementally while maintaining backward compatibility through shared infrastructure during transition periods.

Place invariant checks inside domain entities and value objects. Use application-layer validators for cross-cutting input rules. Keep Symfony Validator component confined to infrastructure adapters for API contract enforcement, never inside core domain logic.

Minimal if implemented correctly. Extra indirection adds negligible overhead compared to I/O operations. Avoid unnecessary abstraction layers for simple operations. Profile before optimizing; most bottlenecks remain database queries or external API calls, not architectural patterns.

For prototypes, internal tools with stable requirements, or teams unfamiliar with DDD concepts. The learning curve and initial setup cost outweigh benefits when business logic is straightforward and unlikely to change significantly over time.