Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Hexagonal Architecture with Symfony

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.

Hexagonal Architecture LayersDOMAINEntities, Value ObjectsDomain Services, PortsAPPLICATION LAYERUse Cases / Command HandlersDRIVING ADAPTERSymfony ControllerDRIVEN ADAPTERDoctrine RepositoryCalls Use CaseImplements Port
Core domain remains isolated from Symfony controllers and Doctrine implementations through strict port boundaries.

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.

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.

Request Flow SequenceControllerUse CaseDomain ServiceRepo AdapterExecute(cmd)Business LogicSave(entity)Return EntityResultResponse DTOKey Benefits Observed
  • • Unit tests run in <10ms (no DB bootstrap)
  • • Swap MySQL for MongoDB without changing Domain
  • • Controllers contain zero business rules
  • • Clear ownership of code changes
  • • Easier onboarding for new developers
Request lifecycle demonstrating unidirectional dependency flow and isolation benefits in practice.

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.

CriteriaTraditional MVC BundleHexagonal Architecture
Initial Setup TimeLow (standard skeleton)High (custom structure, DI config)
Simple CRUD SpeedFast (MakerBundle ready)Slower (boilerplate per feature)
Complex Business RulesBecomes messy quicklyExcels, keeps logic isolated
TestabilityRequires kernel/database oftenPure unit tests possible
Framework UpgradesCoupled to Symfony changesDomain immune to upgrades
Team OnboardingEasier for juniorsSteeper learning curve
Ideal ForCMS, simple apps, MVPsSaaS, 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.

Architecture Decision MatrixStart New ProjectComplex Business Logic?NOYESStandard MVCFast delivery, low overheadHexagonalLong-term maintainabilityCMS, Brochure Sites, MVPsSaaS, Legal-Tech, Banking
Practical decision framework for selecting architecture based on domain complexity and project longevity.

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.

Frequently Asked Questions

It separates business logic from frameworks by organizing code into Domain, Application, and Infrastructure layers. Dependencies point inward toward the domain core, ensuring Symfony remains a replaceable delivery mechanism rather than the application foundation.

Standard Symfony couples controllers directly to entities and services. Hexagonal enforces strict boundaries where controllers only call Application Services via interfaces. The Domain layer contains pure PHP business rules with zero framework imports, making logic testable without booting the kernel or database.

Use it for complex domains requiring long-term maintainability, multiple delivery mechanisms, or extensive unit testing. For simple CRUD apps or prototypes, standard Symfony structure is faster. I reserve hexagonal patterns for projects like legal-tech portals where business rules are intricate and regulatory compliance demands isolated, verifiable logic.

Organize src into Domain, Application, and Infrastructure directories. Domain holds Entities, Value Objects, and Repository Interfaces. Application contains Use Cases and DTOs. Infrastructure implements Symfony-specific adapters like Doctrine repositories and controllers. This physical separation enforces dependency rules better than namespace conventions alone.

Yes. Start by extracting one bounded context or complex service into the new structure while keeping legacy code functional. Create proper Domain models and ports first, then adapt existing infrastructure gradually. In my experience, attempting a full rewrite at once causes more disruption than value; incremental refactoring preserves delivery velocity.

Configure interfaces in Domain/Application layers and bind implementations in Infrastructure via Symfony's services.yaml. Controllers depend only on Application Service interfaces. Use tagged services and compiler passes to wire infrastructure adapters. This keeps the container configuration as the sole place aware of concrete implementations, maintaining clean architectural boundaries.

Leaking framework types into Domain entities, creating anemic domain models that are just data holders, and over-engineering simple operations. Developers often create unnecessary abstractions for trivial CRUD. True hexagonal requires rich domain behavior, not just structural reorganization. Validate that each abstraction solves a real complexity problem before introducing it.

Domain logic becomes testable with plain PHPUnit without database or container overhead. Application services can be tested using fake infrastructure implementations. Integration tests focus only on adapter correctness. On production systems I have maintained, this reduced test suite runtime significantly and made refactoring safer because business rules were decoupled from Symfony lifecycle concerns.

Yes, but Doctrine entities should live in Infrastructure as persistence models, not in Domain. Map Domain entities to Doctrine entities via repositories implementing Domain interfaces. Alternatively, use Doctrine as a direct Domain implementation if mapping overhead is unjustified. The key is ensuring Domain never imports Doctrine annotations or attributes directly.

Expect 20-30% initial overhead for setup and learning curve, offset by faster feature development later. For Nepal-based teams billing Rs 1,500-3,000/hour (~USD 11-22), budget an extra 40-60 hours for medium-complexity projects. The investment pays off after six months of maintenance; for short-lived projects under three months, stick with standard Symfony structure.

Use static analysis tools like PHPStan with custom rules or deptrac to enforce dependency directions automatically in CI pipelines. Configure deptrac to fail builds when Infrastructure leaks into Domain or when Domain imports Symfony components. Automated enforcement prevents gradual erosion that code reviews alone cannot catch consistently across team members and time.

Absolutely. Message handlers belong in the Application layer as use case orchestrators. Messages themselves are Application-layer DTOs. Infrastructure handles transport configuration and serialization. This keeps async processing aligned with business operations rather than technical concerns. Ensure handlers depend only on Domain ports, not concrete infrastructure services.

Minimal runtime impact if implemented correctly. Extra indirection adds negligible overhead compared to database queries. Avoid creating deep object graphs or excessive service calls per request. Cache computed Domain results at the Application layer. In practice, well-structured hexagonal Symfony apps perform identically to traditional ones because the bottleneck remains I/O, not object instantiation.

Clean Architecture shares similar goals with slightly different layer naming. DDD Tactical Patterns focus specifically on domain modeling without prescribing outer layers. Vertical Slice Architecture organizes by feature instead of layer, reducing abstraction for some teams. Choose based on team familiarity and problem shape; hexagonal is one valid approach, not universally superior.

Document the three-layer contract explicitly with examples showing correct and incorrect dependency flows. Provide template use cases and repository implementations as starting points. Pair program on the first feature to reinforce mental models. Expect two to three weeks for developers familiar with Symfony but new to hexagonal patterns; the constraint discipline requires unlearning framework-centric habits.

Share this article

Quick Contact Options
Choose how you want to connect me: