Architecture

Hexagonal Architecture in PHP: Practical Implementation with Symfony

Learn how to implement hexagonal architecture in real PHP projects using Symfony, Domain-Driven Design and SOLID principles to create scalable and maintainable applications.

FC

Fernando Caravaca

FullStack Developer

December 15, 2024
12 min read
Hexagonal architecture diagram

Hexagonal Architecture in PHP: Practical Implementation with Symfony

Hexagonal Architecture Diagram 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

Architecture layers 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 Architecture 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

  1. Keep the domain pure: No framework annotations, no external dependencies
  2. Use value objects: Encapsulate primitive types with business meaning
  3. Test the domain extensively: Unit tests should be fast and isolated
  4. Keep use cases thin: They orchestrate, they don't contain business logic
  5. Name things properly: Use ubiquitous language from DDD
  6. 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

  1. Use DTOs for queries: Don't expose domain entities in read operations
  2. Implement caching: Use Redis for frequently accessed data
  3. Optimize database queries: Use query objects for complex reads
  4. 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:

  1. The domain doesn't depend on anything external
  2. Communication with the domain happens through interfaces (ports)
  3. Technical details (adapters) implement those interfaces

Additional Resources


Have questions about hexagonal architecture? Want to share your experience? Leave me a comment or contact me on LinkedIn.

#PHP#Symfony#Hexagonal Architecture#DDD#SOLID

Did you like this article?

Share your thoughts on LinkedIn or contact me if you want to discuss these topics.

Fernando Caravaca - FullStack Developer