Hexagonal Architecture (Ports & Adapters)
Concept
Hexagonal Architecture (also called Ports and Adapters) organizes an application so that the core business logic is completely isolated from infrastructure concerns (databases, HTTP, queues, etc.). The idea: your application can be driven from a web request, a CLI command, or a test — and can switch from MySQL to PostgreSQL — without changing the core.
The metaphor: A hexagon (the application core) has ports (interfaces) on its edges. Adapters plug into ports to connect the core to the outside world.
Three concentric layers:
- Domain (innermost): Pure business logic. No Laravel, no database, no HTTP. Entities, value objects, domain services. Framework-free PHP.
- Application (middle): Orchestrates use cases. Uses domain objects. Depends on port interfaces, not adapters.
- Infrastructure (outermost): Adapters that implement ports. Database repositories, HTTP controllers, queue adapters, email senders.
Ports:
- Driving ports (primary / left side): How the outside world drives the application. E.g.,
CreateOrderUseCaseinterface. The controller calls it. - Driven ports (secondary / right side): How the application drives the outside world. E.g.,
OrderRepositoryInterface. The application core uses it; a MySQL adapter implements it.
Dependency rule: Dependencies point inward. Domain depends on nothing. Application depends only on Domain. Infrastructure depends on Application and Domain.
Why it matters for testing: Because the application core depends on interfaces (ports), you can inject in-memory adapters in tests. No database, no HTTP, no slow I/O.
Code Example
<?php
// ============================================================
// DOMAIN LAYER — pure PHP, no framework
// ============================================================
final class OrderItem
{
public function __construct(
public readonly string $productId,
public readonly int $quantity,
public readonly float $unitPrice,
) {}
public function total(): float { return $this->quantity * $this->unitPrice; }
}
final class Order
{
private array $items = [];
public function __construct(public readonly string $id, public readonly string $customerId) {}
public function addItem(OrderItem $item): void { $this->items[] = $item; }
public function total(): float { return array_sum(array_map(fn($i) => $i->total(), $this->items)); }
public function getItems(): array { return $this->items; }
}
// ============================================================
// APPLICATION LAYER — ports (interfaces)
// ============================================================
// Driven port — the app core depends on this interface, not the DB
interface OrderRepositoryInterface
{
public function save(Order $order): void;
public function findById(string $id): ?Order;
}
// Driving port — use case
interface PlaceOrderUseCaseInterface
{
public function execute(PlaceOrderRequest $request): string; // returns order ID
}
final readonly class PlaceOrderRequest
{
public function __construct(
public readonly string $customerId,
public readonly array $items, // [{productId, quantity, unitPrice}]
) {}
}
// Application service — depends on domain + driven ports
final class PlaceOrderUseCase implements PlaceOrderUseCaseInterface
{
public function __construct(private readonly OrderRepositoryInterface $orders) {}
public function execute(PlaceOrderRequest $request): string
{
$order = new Order(uniqid('order_'), $request->customerId);
foreach ($request->items as $item) {
$order->addItem(new OrderItem($item['productId'], $item['quantity'], $item['unitPrice']));
}
$this->orders->save($order);
return $order->id;
}
}
// ============================================================
// INFRASTRUCTURE LAYER — adapters (implement the ports)
// ============================================================
// Driven adapter: Eloquent implementation of OrderRepository
class EloquentOrderRepository implements OrderRepositoryInterface
{
public function save(Order $order): void
{
\App\Models\Order::updateOrCreate(['id' => $order->id], [
'customer_id' => $order->customerId,
'total' => $order->total(),
]);
}
public function findById(string $id): ?Order { /* hydrate from Eloquent */ return null; }
}
// In-memory adapter for testing
class InMemoryOrderRepository implements OrderRepositoryInterface
{
private array $orders = [];
public function save(Order $order): void { $this->orders[$order->id] = $order; }
public function findById(string $id): ?Order { return $this->orders[$id] ?? null; }
public function count(): int { return count($this->orders); }
}
// Driving adapter: HTTP controller (primary/left adapter)
class OrderController
{
public function __construct(private readonly PlaceOrderUseCaseInterface $useCase) {}
public function store(\Illuminate\Http\Request $request): \Illuminate\Http\JsonResponse
{
$orderId = $this->useCase->execute(new PlaceOrderRequest(
$request->input('customer_id'),
$request->input('items', []),
));
return response()->json(['order_id' => $orderId], 201);
}
}
// ============================================================
// TESTING — swap adapter without changing anything else
// ============================================================
$repo = new InMemoryOrderRepository();
$useCase = new PlaceOrderUseCase($repo);
$orderId = $useCase->execute(new PlaceOrderRequest('cust-1', [
['productId' => 'p-1', 'quantity' => 2, 'unitPrice' => 25.0],
]));
assert($repo->count() === 1); // No database needed!