in Symfony: Ports and Adapters
The hexagonal architecture, also known as Ports and Adapters, frees the domain logic from coupling to frameworks, databases, and external services. In Symfony it can be implemented precisely with PHP interfaces as Ports and Symfony services as Adapters: fully testable domain code without a single framework import in the core logic.
Table of Contents
- 1. What is Hexagonal Architecture?
- 2. The Three Layers: Domain, Application, Infrastructure
- 3. Ports: PHP Interfaces as Contract Definitions
- 4. The Domain Core Without Framework Imports
- 5. Application Layer: Use Cases and Command Handlers
- 6. Adapters: Symfony Services Implementing Ports
- 7. Dependency Injection: Wiring Ports to Adapters
- 8. Testing Without a Framework: Isolated Domain Logic
- 9. Comparison: Traditional vs. Hexagonal in Symfony
- 10. Summary
- 11. FAQ
1. What is Hexagonal Architecture?
The hexagonal architecture, originally described by Alistair Cockburn as "Ports and Adapters," is an architectural pattern that isolates an application's business logic from its technical environment. The core idea: an application should be equally callable from an HTTP request, a CLI command, a test, or a message queue consumer, without the business logic knowing anything about that calling context. At the same time, the application should be able to use interchangeable infrastructure: a real database in production, an in-memory implementation in tests.
In the hexagonal architecture, the hexagon is the domain, the area where business rules apply. Everything outside the hexagon is either a primary adapter (which calls the domain: HTTP, CLI, tests) or a secondary adapter (which the domain calls: database, email service, external APIs). Ports are the defined interfaces of these connections: PHP interfaces that describe what is needed without describing how it is implemented. That is the core principle: the domain defines Ports (interfaces), and Adapters implement these Ports outside the domain.
Why does this matter in Symfony projects? In a typical Symfony project, the service code knows Doctrine entities, HTTP request objects, and Symfony-specific classes. That makes the domain logic hard to test, since every test needs a database, an HTTP request, and a running Symfony container. With the hexagonal architecture, the domain logic knows none of that: it only knows Ports (interfaces), which are replaced by in-memory implementations in tests. This enables fast, database-free unit tests for the entire business logic.
2. The Three Layers: Domain, Application, Infrastructure
The hexagonal architecture structures a Symfony project into three clearly separated layers. The Domain layer contains the business logic: entities (not Doctrine entities, but pure PHP objects with domain logic), value objects, domain services, and the Port interfaces (repositories, mailer, notification sender, etc.). This layer has no dependency on Symfony or Doctrine whatsoever, it imports only PHP standard libraries and, where applicable, contracts such as PSR interfaces.
The Application layer orchestrates the domain. It contains use cases (or command handlers when using CQRS/Messenger) that combine domain objects and Port interfaces to represent business use cases. The Application layer fully knows the Domain layer but does not know the Infrastructure layer. It calls Port interfaces, and it does not care which concrete adapter sits behind them. This is the Dependency Inversion principle from SOLID in its purest form.
The Infrastructure layer contains all Adapters: Doctrine repository implementations as secondary adapters (they implement the Port interfaces of the Domain layer), Symfony controllers as primary adapters (they receive HTTP requests and call Application layer use cases), mailer adapters, external API adapters, and all other framework-specific implementations. The Infrastructure layer knows all other layers and may import Symfony, Doctrine, and any other library. It is the only layer that contains framework coupling.
3. Ports: PHP Interfaces as Contract Definitions
A Port in the hexagonal architecture is a PHP interface that describes what the domain needs from its environment or what it offers outward. A repository Port describes methods such as findById(), save(), and findByStatus(), without mentioning whether Doctrine, Redis, or a REST API sits behind it. A notification Port describes sendOrderConfirmation() and sendShippingUpdate(), without knowing whether this happens by email, SMS, or push notification. These Ports are part of the Domain layer and are the only communication path between domain and infrastructure.
In PHP 8.2+, Ports benefit from the language's type system features: readonly classes for value objects, intersection types for precise type combinations, and enums for domain states. The Port interfaces themselves are plain PHP interfaces, no special annotations, no Doctrine attributes, no Symfony attributes. This is the proof that the Domain layer is framework-independent: an interface with only PHP standard types needs no external library. Adding PSR-3 for the logger Port is an accepted compromise, since PSR interfaces are standards, not framework dependencies.
<?php
declare(strict_types=1);
// Domain-Layer: Ports defined in the domain, no framework imports
namespace App\Domain\Order\Port;
use App\Domain\Order\Model\Order;
use App\Domain\Order\Model\OrderId;
use App\Domain\Order\Model\OrderStatus;
/**
* Repository port, describes what the domain needs for Order persistence.
* No Doctrine, no Symfony, pure PHP interface in the domain layer.
*/
interface OrderRepositoryInterface
{
public function findById(OrderId $id): ?Order;
/** @return Order[] */
public function findByStatus(OrderStatus $status): array;
public function save(Order $order): void;
public function remove(Order $order): void;
}
// ---
namespace App\Domain\Order\Port;
use App\Domain\Order\Model\Order;
/**
* Notification port, describes what the domain needs to notify customers.
* Implementation can be email, SMS, or push, domain does not care.
*/
interface OrderNotificationPort
{
public function sendOrderConfirmation(Order $order): void;
public function sendShippingUpdate(Order $order, string $trackingCode): void;
}
// ---
namespace App\Domain\Order\Model;
/**
* Value Object for Order ID, immutable, validated at construction.
* No Doctrine mapping here, this is pure domain model.
*/
final readonly class OrderId
{
public function __construct(
public readonly string $value,
) {
if (empty($this->value)) {
throw new \InvalidArgumentException('OrderId cannot be empty');
}
}
public static function generate(): self
{
return new self(\Ramsey\Uuid\Uuid::uuid4()->toString());
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}
4. The Domain Core Without Framework Imports
The domain core of the hexagonal architecture is the totality of all Domain layer classes: entities, value objects, domain services, aggregate roots, and Port interfaces. The decisive characteristic: no use Symfony\, no use Doctrine\ in these classes. A domain entity is not a Doctrine mapping object, it is a PHP object that expresses business rules through methods. The constructor throws a DomainException if an order is created with a negative amount. The ship() method checks whether the order is in a state from which shipping is allowed, and otherwise throws a domain-specific exception.
Domain services in the hexagonal architecture encapsulate logic that does not naturally belong to an entity but is still domain logic. An OrderPricingService calculates the total price taking discounts, taxes, and shipping costs into account, it knows the domain's pricing rules but no database. An InventoryCheckService checks product availability, but only against Port interfaces, not directly against Doctrine. The domain service calls the repository Port, and the concrete adapter behind it is implemented in the Infrastructure layer.
Value objects are particularly important in the hexagonal architecture: they replace primitive types with semantically rich, validated objects. Instead of string $email, there is an EmailAddress value object that is validated in the constructor and represents only valid email addresses. In PHP 8.2+, value objects are written with readonly class, immutable, explicit, and type-safe. The entire domain core becomes a system that makes invalid states impossible through its type system.
5. Application Layer: Use Cases and Command Handlers
The Application layer contains use cases, the business use cases of the application. In the hexagonal architecture, a use case is a class that orchestrates a single use case: PlaceOrderUseCase, CancelOrderUseCase, ShipOrderUseCase. Each use case receives a command (a simple DTO with the required input data), calls domain objects and Port interfaces, and coordinates the flow. It contains no business rules itself, those live in the Domain layer, only orchestration logic.
When using Symfony Messenger, the use case becomes a message handler: the command is a Messenger message, the use case implements MessageHandlerInterface. Dependency injection automatically wires all Port interfaces to their infrastructure adapters. The nice part: the use case class in the Application layer knows only the domain Port interfaces, not the concrete Doctrine repositories or mailer services. Symfony's DI container resolves these dependencies at compile time.
<?php
declare(strict_types=1);
// Application Layer: Use Case orchestrates domain without knowing infrastructure
namespace App\Application\Order\UseCase;
use App\Domain\Order\Model\Order;
use App\Domain\Order\Model\OrderId;
use App\Domain\Order\Port\OrderNotificationPort;
use App\Domain\Order\Port\OrderRepositoryInterface;
use App\Application\Order\Command\PlaceOrderCommand;
/**
* PlaceOrderUseCase, application layer use case.
* Knows Domain (Order, OrderId) and Ports (interfaces).
* Does NOT know Doctrine, Mailer classes, or Symfony internals.
*/
final class PlaceOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository, // Port, not Doctrine
private readonly OrderNotificationPort $notification, // Port, not MailerInterface
) {}
/**
* Execute the place order use case.
* All infrastructure calls go through ports, swappable in tests.
*/
public function execute(PlaceOrderCommand $command): OrderId
{
// Create domain entity, domain model enforces business rules
$order = Order::place(
id: OrderId::generate(),
customerId: $command->customerId,
items: $command->items,
shippingAddress: $command->shippingAddress,
);
// Persist through port, actual implementation injected by DI
$this->orderRepository->save($order);
// Notify through port, email, SMS, or push depending on adapter
$this->notification->sendOrderConfirmation($order);
return $order->getId();
}
}
// Application command, pure DTO, no framework imports
namespace App\Application\Order\Command;
use App\Domain\Order\Model\CustomerId;
use App\Domain\Order\Model\OrderItem;
use App\Domain\Order\Model\ShippingAddress;
final readonly class PlaceOrderCommand
{
/** @param OrderItem[] $items */
public function __construct(
public readonly CustomerId $customerId,
public readonly array $items,
public readonly ShippingAddress $shippingAddress,
) {}
}
6. Adapters: Symfony Services Implementing Ports
Adapters are the Infrastructure layer of the hexagonal architecture. Each secondary adapter implements a domain Port and contains the concrete implementation: the Doctrine repository implements OrderRepositoryInterface and translates between domain entities and Doctrine mapping objects. The mailer adapter implements OrderNotificationPort and uses Symfony's MailerInterface. An SMS adapter would implement the same OrderNotificationPort interface and call an SMS API instead.
The Doctrine adapter faces a particular challenge: it must translate between the domain entity (without Doctrine mapping) and the Doctrine mapping entity. There are two common approaches: the direct approach, where the domain entity is annotated as a Doctrine entity (a compromise between purity and pragmatism), and the clean approach with separate mapping classes. The latter uses a separate OrderRecord object with Doctrine mapping that the adapter populates when saving and translates back into the domain entity when loading. This is more code, but complete separation: Doctrine changes do not touch the domain.
Primary adapters in the hexagonal architecture are Symfony controllers, console commands, and Messenger handlers. A controller receives the HTTP request, extracts the required data, creates an application command, and calls the use case. The controller knows the use case, but thanks to the Application layer abstraction it needs no domain logic. It is a thin adapter that translates HTTP semantics into use case calls. This keeps the controller extremely lean and easy to test, no testing effort for business logic, because that lives in the use case.
7. Dependency Injection: Wiring Ports to Adapters
The Symfony DI container is the mechanism that connects Ports and Adapters in the hexagonal architecture. The use case declares the Port (the interface) in its constructor, and the container supplies the adapter (the concrete implementation) when building the use case service. This requires either explicit alias configuration in services.yaml (App\Domain\Order\Port\OrderRepositoryInterface: '@App\Infrastructure\Doctrine\DoctrineOrderRepository') or the use of PHP attributes.
With autoconfigure and the #[AsAlias] attribute on the adapter, modern Symfony projects can eliminate the alias configuration from YAML entirely. The Doctrine adapter carries the attribute and thereby signals which Port it implements. Symfony's container compiler resolves the interface-to-class binding automatically, without a single YAML entry. This ties the hexagonal architecture in Symfony together with the autoconfigure feature into a very compact setup: Ports as interfaces in the domain, Adapters with PHP attributes in the infrastructure, the DI container as the glue.
<?php
declare(strict_types=1);
// Infrastructure Layer: Doctrine Adapter implements Domain Port
namespace App\Infrastructure\Persistence\Doctrine;
use App\Domain\Order\Model\Order;
use App\Domain\Order\Model\OrderId;
use App\Domain\Order\Model\OrderStatus;
use App\Domain\Order\Port\OrderRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
/**
* DoctrineOrderRepository, infrastructure adapter for the order repository port.
* Implements domain port, uses Doctrine ORM. Domain knows only the interface.
* #[AsAlias] binds the interface to this concrete class automatically.
*/
#[\Symfony\Component\DependencyInjection\Attribute\AsAlias(id: OrderRepositoryInterface::class)]
final class DoctrineOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
public function findById(OrderId $id): ?Order
{
// In pragmatic approach: domain entity IS the Doctrine entity
// In pure approach: load Doctrine record, map to domain entity
return $this->entityManager->find(Order::class, $id->value);
}
/** @return Order[] */
public function findByStatus(OrderStatus $status): array
{
return $this->entityManager->createQueryBuilder()
->select('o')
->from(Order::class, 'o')
->where('o.status = :status')
->setParameter('status', $status->value)
->getQuery()
->getResult();
}
public function save(Order $order): void
{
$this->entityManager->persist($order);
$this->entityManager->flush();
}
public function remove(Order $order): void
{
$this->entityManager->remove($order);
$this->entityManager->flush();
}
}
// Symfony Controller as PRIMARY adapter, thin layer, no business logic
namespace App\Infrastructure\Http;
use App\Application\Order\Command\PlaceOrderCommand;
use App\Application\Order\UseCase\PlaceOrderUseCase;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/orders', methods: ['POST'])]
final class PlaceOrderController extends AbstractController
{
public function __construct(
private readonly PlaceOrderUseCase $placeOrder,
) {}
public function __invoke(Request $request): JsonResponse
{
// Controller translates HTTP request to application command
$command = new PlaceOrderCommand(/* ... extracted from $request ... */);
$orderId = $this->placeOrder->execute($command);
return $this->json(['orderId' => $orderId->value], 201);
}
}
8. Testing Without a Framework: Isolated Domain Logic
The biggest win of the hexagonal architecture is testability. Since the Domain layer and the Application layer access infrastructure exclusively through Port interfaces, tests can use in-memory implementations of these Ports. An InMemoryOrderRepository implements OrderRepositoryInterface and stores orders in a simple PHP array. No database access, no transactions, no Doctrine, the unit test for PlaceOrderUseCase runs in milliseconds.
Writing in-memory adapters is not much effort: they are typically 10 to 20 lines of PHP with no external dependencies. In return, you get a complete unit test suite for the entire business logic without database access. In a typical Symfony project with the hexagonal architecture, the unit test suite runs in under 3 seconds, regardless of project size. Integration tests with real Doctrine repositories and the Symfony test environment are added as a second layer, but they test less code and therefore need correspondingly less maintenance.
| Layer | Content | Framework Dependency | Test Strategy |
|---|---|---|---|
| Domain | Entities, value objects, domain services, Ports | None | Unit tests, in-memory adapters |
| Application | Use cases, commands, command handlers | PSR interfaces only | Unit tests with Port mocks |
| Infrastructure | Doctrine repos, controllers, mailer adapters | Symfony, Doctrine, all libs | Integration tests with DB |
| In-Memory Adapter | Test implementations of the Ports | None | For unit tests of the app layer |
9. Comparison: Traditional vs. Hexagonal in Symfony
In a traditional Symfony project, a service knows the Doctrine repository, the mailer, and external APIs directly, all as concrete classes. This leads to tests that either need a full Symfony environment with a database or elaborate mocking configurations for every class. With the hexagonal architecture, the same service knows only interfaces. Tests replace these interfaces with in-memory implementations, fast, without a mocking framework, and without database access.
The initial effort of the hexagonal architecture is real: more files, more interfaces, more layers. For small projects with little domain logic, this overhead is not justified. For medium and large projects where testability and the interchangeability of infrastructure components matter, the hexagonal architecture pays off increasingly as the project grows. One indicator: when a project starts writing integration tests that spend more time on database setup than on the actual test, the hexagonal architecture is a sensible next step.
Mironsoft
Symfony architecture, domain-driven design, and clean code
Want to introduce hexagonal architecture in your Symfony project?
We analyze existing Symfony projects for architectural weaknesses, design a step-by-step migration toward hexagonal architecture, and implement the domain core, Ports, and Adapters, with full unit test coverage and no database dependency.
Architecture Analysis
Analyze existing Symfony projects for framework coupling and testability weaknesses
Migration
Step-by-step migration to hexagonal architecture without interrupting ongoing operations
Test Strategy
Build a unit test suite with in-memory adapters, fast tests without database dependency
10. Summary
The hexagonal architecture in Symfony isolates the domain core through Ports (PHP interfaces) and Adapters (Symfony services). The Domain layer knows no framework classes, the Application layer orchestrates use cases against Port interfaces, the Infrastructure layer implements these Ports with Doctrine, Symfony Mailer, and external APIs. Symfony's DI container wires Ports and Adapters automatically, with #[AsAlias] on the adapter even without YAML.
The main benefit of the hexagonal architecture is the unit testability of the entire domain and application code without database access. In-memory implementations of the Port interfaces enable fast tests in milliseconds. Integration tests with real infrastructure remain as a second test layer, but they need to cover less domain logic. For medium-sized and large Symfony projects, the hexagonal architecture is the architectural foundation that secures long-term maintainability and testability.
Hexagonal Architecture in Symfony, the Essentials at a Glance
Domain Layer
Entities, value objects, domain services, and Port interfaces, no use Symfony\ or use Doctrine\. Pure PHP classes with domain logic.
Application Layer
Use cases orchestrate the domain against Port interfaces. Commands are DTOs. Knows the domain, knows no infrastructure classes.
Infrastructure Layer
Doctrine repos, controllers, mailer adapters implement Ports. The only layer with framework coupling. #[AsAlias] wires Port and Adapter.
Testing
In-memory adapters for all Ports, unit tests for the entire domain/application code without database access. Unit suite runs in under 3 seconds.