Hexagonal Architecture in PHP: Practical Implementation with Symfony
Conceptual diagram of hexagonal architecture showing the domain core surrounded by ports and adapters
Introduction
Hexagonal architecture, also known as "Ports and Adapters" or "Clean Architecture", is an architectural pattern that allows us to create decoupled, testable, and maintainable applications. In this article, we'll explore how to implement this pattern in PHP projects using Symfony.
Why does it matter? In real-world projects, requirements constantly change: we need to switch databases, integrate new APIs, or migrate frameworks. Hexagonal architecture protects us from these changes by keeping our business code independent of infrastructure details.
What is Hexagonal Architecture?
Hexagonal architecture, proposed by Alistair Cockburn in 2005, divides our application into three main layers:
1. Domain (Core/Nucleus)
The heart of our application. It contains:
- Entities: Business objects with identity
- Value Objects: Immutable objects without identity
- Domain Services: Complex business logic
- Domain Events: Events that occur in the domain
Golden rule: The domain must NOT depend on anything external (frameworks, databases, HTTP).
2. Ports (Interfaces)
These are the contracts that define how to interact with the domain:
- Input Ports (Driving Ports): Use cases that the application exposes (application service interfaces)
- Output Ports (Driven Ports): Interfaces that the domain needs (repositories, external services)
3. Adapters
These are the concrete implementations of the ports:
- Input Adapters: HTTP controllers, CLI commands, messaging events
- Output Adapters: Doctrine repositories, HTTP clients, email services
Data flow through hexagonal architecture layers
Practical Implementation: Order Management System
Let's build an order system step by step applying hexagonal architecture with Symfony.
Directory Structure
src/
├── Domain/
│ ├── Model/
│ │ ├── Order.php
│ │ ├── OrderId.php
│ │ ├── Money.php
│ │ └── OrderStatus.php
│ ├── Repository/
│ │ └── OrderRepositoryInterface.php
│ ├── Service/
│ │ └── OrderPriceCalculator.php
│ └── Event/
│ └── OrderWasPlaced.php
├── Application/
│ ├── UseCase/
│ │ ├── PlaceOrder/
│ │ │ ├── PlaceOrderCommand.php
│ │ │ └── PlaceOrderHandler.php
│ │ └── FindOrder/
│ │ ├── FindOrderQuery.php
│ │ └── FindOrderHandler.php
│ └── Service/
│ └── OrderService.php
└── Infrastructure/
├── Persistence/
│ └── Doctrine/
│ └── DoctrineOrderRepository.php
├── Http/
│ └── Controller/
│ └── OrderController.php
└── Messaging/
└── RabbitMQEventPublisher.php
Step 1: Define Domain Entities
<?php
// src/Domain/Model/Order.php
namespace App\\Domain\\Model;
use App\\Domain\\Event\\OrderWasPlaced;
use DateTimeImmutable;
final class Order
{
private OrderId $id;
private array $items = [];
private OrderStatus $status;
private Money $totalAmount;
private DateTimeImmutable $createdAt;
private array $domainEvents = [];
private function __construct(
OrderId $id,
DateTimeImmutable $createdAt
) {
$this->id = $id;
$this->status = OrderStatus::pending();
$this->createdAt = $createdAt;
$this->totalAmount = Money::zero();
}
public static function place(
OrderId $id,
array $items,
OrderPriceCalculator $calculator
): self {
$order = new self($id, new DateTimeImmutable());
foreach ($items as $item) {
$order->addItem($item);
}
$order->totalAmount = $calculator->calculate($order);
$order->recordThat(new OrderWasPlaced($id));
return $order;
}
public function confirm(): void
{
if (!$this->status->isPending()) {
throw new \\DomainException('Only pending orders can be confirmed');
}
$this->status = OrderStatus::confirmed();
}
private function addItem(OrderItem $item): void
{
$this->items[] = $item;
}
public function id(): OrderId
{
return $this->id;
}
public function totalAmount(): Money
{
return $this->totalAmount;
}
public function popEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
private function recordThat(object $event): void
{
$this->domainEvents[] = $event;
}
}
Step 2: Value Objects
<?php
// src/Domain/Model/OrderId.php
namespace App\\Domain\\Model;
use Ramsey\\Uuid\\Uuid;
final class OrderId
{
private string $value;
private function __construct(string $value)
{
if (!Uuid::isValid($value)) {
throw new \\InvalidArgumentException('Invalid OrderId format');
}
$this->value = $value;
}
public static function generate(): self
{
return new self(Uuid::uuid4()->toString());
}
public static function fromString(string $value): self
{
return new self($value);
}
public function value(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
<?php
// src/Domain/Model/Money.php
namespace App\\Domain\\Model;
final class Money
{
private int $amount; // Stored in cents
private string $currency;
private function __construct(int $amount, string $currency)
{
if ($amount < 0) {
throw new \\InvalidArgumentException('Amount cannot be negative');
}
$this->amount = $amount;
$this->currency = strtoupper($currency);
}
public static function fromFloat(float $amount, string $currency): self
{
return new self((int) ($amount * 100), $currency);
}
public static function zero(): self
{
return new self(0, 'USD');
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
public function toFloat(): float
{
return $this->amount / 100;
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new \\InvalidArgumentException('Cannot operate with different currencies');
}
}
}
Step 3: Output Port (Repository)
<?php
// src/Domain/Repository/OrderRepositoryInterface.php
namespace App\\Domain\\Repository;
use App\\Domain\\Model\\Order;
use App\\Domain\\Model\\OrderId;
interface OrderRepositoryInterface
{
public function save(Order $order): void;
public function findById(OrderId $id): ?Order;
public function nextIdentity(): OrderId;
}
Step 4: Use Case (Input Port)
<?php
// src/Application/UseCase/PlaceOrder/PlaceOrderCommand.php
namespace App\\Application\\UseCase\\PlaceOrder;
final class PlaceOrderCommand
{
public function __construct(
public readonly array $items,
public readonly ?string $customerId = null
) {}
}
<?php
// src/Application/UseCase/PlaceOrder/PlaceOrderHandler.php
namespace App\\Application\\UseCase\\PlaceOrder;
use App\\Domain\\Model\\Order;
use App\\Domain\\Repository\\OrderRepositoryInterface;
use App\\Domain\\Service\\OrderPriceCalculator;
final class PlaceOrderHandler
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private OrderPriceCalculator $priceCalculator
) {}
public function handle(PlaceOrderCommand $command): string
{
$orderId = $this->orderRepository->nextIdentity();
$order = Order::place(
$orderId,
$this->mapItems($command->items),
$this->priceCalculator
);
$this->orderRepository->save($order);
// Publish domain events
foreach ($order->popEvents() as $event) {
// Event dispatcher here
}
return $orderId->value();
}
private function mapItems(array $items): array
{
// Map raw data to OrderItem value objects
return array_map(fn($item) => OrderItem::fromArray($item), $items);
}
}
Step 5: Persistence Adapter (Doctrine)
<?php
// src/Infrastructure/Persistence/Doctrine/DoctrineOrderRepository.php
namespace App\\Infrastructure\\Persistence\\Doctrine;
use App\\Domain\\Model\\Order;
use App\\Domain\\Model\\OrderId;
use App\\Domain\\Repository\\OrderRepositoryInterface;
use Doctrine\\ORM\\EntityManagerInterface;
final class DoctrineOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager
) {}
public function save(Order $order): void
{
$this->entityManager->persist($order);
$this->entityManager->flush();
}
public function findById(OrderId $id): ?Order
{
return $this->entityManager
->getRepository(Order::class)
->find($id->value());
}
public function nextIdentity(): OrderId
{
return OrderId::generate();
}
}
Step 6: Input Adapter (HTTP Controller)
<?php
// src/Infrastructure/Http/Controller/OrderController.php
namespace App\\Infrastructure\\Http\\Controller;
use App\\Application\\UseCase\\PlaceOrder\\PlaceOrderCommand;
use App\\Application\\UseCase\\PlaceOrder\\PlaceOrderHandler;
use Symfony\\Component\\HttpFoundation\\JsonResponse;
use Symfony\\Component\\HttpFoundation\\Request;
use Symfony\\Component\\Routing\\Annotation\\Route;
final class OrderController
{
public function __construct(
private PlaceOrderHandler $placeOrderHandler
) {}
#[Route('/api/orders', methods: ['POST'])]
public function placeOrder(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$command = new PlaceOrderCommand(
items: $data['items'] ?? [],
customerId: $data['customer_id'] ?? null
);
$orderId = $this->placeOrderHandler->handle($command);
return new JsonResponse([
'order_id' => $orderId,
'status' => 'created'
], 201);
}
}
Symfony Service Configuration
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
# Domain Services
App\\Domain\\Service\\:
resource: '../src/Domain/Service/*'
# Application Use Cases
App\\Application\\UseCase\\:
resource: '../src/Application/UseCase/*'
# Infrastructure
App\\Infrastructure\\:
resource: '../src/Infrastructure/*'
exclude: '../src/Infrastructure/{DependencyInjection,Entity,Tests}'
# Repository binding
App\\Domain\\Repository\\OrderRepositoryInterface:
class: App\\Infrastructure\\Persistence\\Doctrine\\DoctrineOrderRepository
Testing pyramid in hexagonal architecture: domain unit tests, use case integration tests, and end-to-end tests
Advantages of Hexagonal Architecture
1. Testability
We can test the domain without needing a database or framework:
<?php
// tests/Unit/Domain/OrderTest.php
use App\\Domain\\Model\\Order;
use App\\Domain\\Model\\OrderId;
use PHPUnit\\Framework\\TestCase;
final class OrderTest extends TestCase
{
public function test_order_can_be_placed(): void
{
$orderId = OrderId::generate();
$items = [
OrderItem::create('product-1', 2, Money::fromFloat(10.00, 'USD'))
];
$order = Order::place($orderId, $items, new OrderPriceCalculator());
$this->assertEquals($orderId, $order->id());
$this->assertEquals(20.00, $order->totalAmount()->toFloat());
}
}
2. Framework Independence
The domain doesn't know about Symfony. We can migrate to Laravel tomorrow by only changing adapters.
3. Easy to Change
Need to switch from MySQL to MongoDB? Just change the persistence adapter:
<?php
final class MongoDBOrderRepository implements OrderRepositoryInterface
{
// Same interface, different implementation
}
4. Centralized Business Rules
All business logic is in the domain, easy to find and maintain.
Complementary Patterns
CQRS (Command Query Responsibility Segregation)
Separate read and write operations:
<?php
// Write Model (Command)
interface OrderCommandRepository {
public function save(Order $order): void;
}
// Read Model (Query)
interface OrderQueryRepository {
public function findById(string $id): ?OrderDTO;
public function findAll(): array;
}
Event Sourcing
Instead of saving state, save events:
<?php
final class Order extends AggregateRoot
{
protected function apply(OrderWasPlaced $event): void
{
$this->id = $event->orderId;
$this->status = OrderStatus::pending();
}
protected function apply(OrderWasConfirmed $event): void
{
$this->status = OrderStatus::confirmed();
}
}
Common Mistakes
❌ Not respecting dependencies
<?php
// ❌ WRONG: Domain must NOT depend on infrastructure
namespace App\\Domain;
use Doctrine\\ORM\\Mapping as ORM;
#[ORM\\Entity]
class Order { }
<?php
// ✅ CORRECT: Domain is independent
namespace App\\Domain;
class Order {
// No Doctrine annotations
}
❌ Business logic in controllers
<?php
// ❌ WRONG
class OrderController {
public function create() {
$total = 0;
foreach ($items as $item) {
$total += $item['price'] * $item['quantity'];
}
// Business logic in controller
}
}
<?php
// ✅ CORRECT
class OrderController {
public function create() {
$this->placeOrderHandler->handle($command);
// Controller only coordinates
}
}
Best Practices
- Keep the domain pure: No framework annotations, no external dependencies
- Use value objects: Encapsulate primitive types with business meaning
- Test the domain extensively: Unit tests should be fast and isolated
- Keep use cases thin: They orchestrate, they don't contain business logic
- Name things properly: Use ubiquitous language from DDD
- Document ports: Interfaces are contracts, document them well
Real-World Example: E-commerce Platform
Here's how we structured a real e-commerce platform with hexagonal architecture:
Domain Layer:
├── Catalog/
│ ├── Product.php
│ ├── Category.php
│ └── ProductRepository.php
├── Cart/
│ ├── Cart.php
│ ├── CartItem.php
│ └── CartRepository.php
├── Order/
│ ├── Order.php
│ ├── OrderItem.php
│ └── OrderRepository.php
└── Payment/
├── Payment.php
└── PaymentGateway.php
Application Layer:
├── AddProductToCart/
│ ├── AddProductToCartCommand.php
│ └── AddProductToCartHandler.php
└── CheckoutOrder/
├── CheckoutOrderCommand.php
└── CheckoutOrderHandler.php
Infrastructure Layer:
├── DoctrineProductRepository.php
├── RedisCartRepository.php
├── StripePaymentGateway.php
└── SendGridEmailService.php
Performance Considerations
- Use DTOs for queries: Don't expose domain entities in read operations
- Implement caching: Use Redis for frequently accessed data
- Optimize database queries: Use query objects for complex reads
- Consider CQRS: Separate read and write models for better performance
Conclusion
Hexagonal architecture allows us to build professional and maintainable applications in PHP. With Symfony as a framework, we get the best of both worlds: the power of the framework and the architectural clarity of the hexagonal pattern.
Remember the three golden rules:
- The domain doesn't depend on anything external
- Communication with the domain happens through interfaces (ports)
- Technical details (adapters) implement those interfaces
Additional Resources
- Book: "Hexagonal Architecture Explained" by Juan Manuel Garrido de Paz
- Official Symfony Documentation
- DDD in PHP - Carlos Buenosvinos
- Example Repository on GitHub
Have questions about hexagonal architecture? Want to share your experience? Leave me a comment or contact me on LinkedIn.