Hexagonal architecture — also called ports and adapters — is a way of organizing code so that business logic does not depend on the database, the framework, or any other external system. The business logic sits in the middle. Around it are "ports" that define how the outside world can interact with it, and "adapters" that connect each port to a specific technology.
The pattern was named by Alistair Cockburn in the early 2000s, and the diagrams typically show a hexagon at the center for visual symmetry — but the shape is incidental. The point is the dependency direction.
The Dependency Rule
In a typical Laravel application, controllers call services, services call models, models hit the database. Dependencies flow from the outside in: the database lives at the bottom, and changing it ripples upward.
Hexagonal architecture reverses this. The core has no idea what database, framework, or transport it lives inside. It defines interfaces — ports — that describe what it needs and what it produces.
+-------------------+
| HTTP Adapter | ← Laravel controllers, JSON serializers
+--------+----------+
|
+--------v----------+
| Driving Port | ← interface: PlaceOrder, GetOrderStatus
+--------+----------+
|
+--------v----------+
| Domain Core | ← pure PHP, no Laravel, no Eloquent
+--------+----------+
|
+--------v----------+
| Driven Port | ← interface: OrderRepository, PaymentGateway
+--------+----------+
|
+--------v----------+
| Driven Adapter | ← Eloquent repository, Stripe API client
+-------------------+
The "driving" side is anything that calls into the core — HTTP controllers, CLI commands, queue listeners. The "driven" side is anything the core calls out to — databases, payment providers, email senders. Both are adapters behind ports the core defines.
A Concrete Example in Laravel
Let's model order placement. The domain core looks like this:
// src/Domain/Orders/PlaceOrder.php
namespace App\Domain\Orders;
final class PlaceOrder
{
public function __construct(
private OrderRepository $orders,
private PaymentGateway $payments,
private InventoryService $inventory,
) {}
public function execute(PlaceOrderCommand $command): Order
{
$this->inventory->reserve($command->items);
$order = Order::create(
customerId: $command->customerId,
items: $command->items,
);
$charge = $this->payments->charge(
customerId: $command->customerId,
amount: $order->total(),
);
$order->markPaid($charge->id);
$this->orders->save($order);
return $order;
}
}
Three things to notice. First, no Laravel imports. Second, the dependencies are interfaces — OrderRepository, PaymentGateway, InventoryService. Third, the data structures (Order, PlaceOrderCommand) are plain PHP, not Eloquent models.
The interfaces live next to the domain:
// src/Domain/Orders/OrderRepository.php
namespace App\Domain\Orders;
interface OrderRepository
{
public function save(Order $order): void;
public function findById(string $id): ?Order;
}
The Eloquent implementation lives in the adapter layer:
// src/Infrastructure/Persistence/EloquentOrderRepository.php
namespace App\Infrastructure\Persistence;
use App\Domain\Orders\Order;
use App\Domain\Orders\OrderRepository;
use App\Models\Order as OrderModel;
final class EloquentOrderRepository implements OrderRepository
{
public function save(Order $order): void
{
$model = OrderModel::firstOrNew(['id' => $order->id()]);
$model->customer_id = $order->customerId();
$model->total_cents = $order->total()->cents();
$model->status = $order->status()->value;
$model->save();
}
public function findById(string $id): ?Order
{
$model = OrderModel::find($id);
return $model ? Order::fromArray($model->toArray()) : null;
}
}
The driving adapter is a controller that translates HTTP into a command:
// app/Http/Controllers/PlaceOrderController.php
final class PlaceOrderController
{
public function __construct(private PlaceOrder $useCase) {}
public function __invoke(PlaceOrderRequest $request): JsonResponse
{
$order = $this->useCase->execute(
new PlaceOrderCommand(
customerId: $request->user()->id,
items: $request->validated('items'),
)
);
return response()->json($order->toArray(), 201);
}
}
The controller's only job is translation. All the domain logic — pricing, inventory checks, payment, persistence — lives in the use case and the entities it operates on.
Folder Layout
A typical hexagonal Laravel project looks like:
app/ ← framework-facing, thin
├── Http/Controllers/ ← driving adapters
├── Console/Commands/ ← driving adapters
└── Models/ ← Eloquent — used only by infrastructure
src/
├── Domain/ ← pure PHP, no Laravel
│ └── Orders/
│ ├── Order.php ← entity
│ ├── OrderRepository.php ← driven port
│ ├── PlaceOrder.php ← use case
│ └── PlaceOrderCommand.php
└── Infrastructure/ ← driven adapters
└── Persistence/
└── EloquentOrderRepository.php
The split between app/ and src/ is a strong signal: app/ knows about Laravel, src/ does not. Bind the interfaces in a service provider.
What You Get
- Testability. The domain core can be tested with simple in-memory fakes for the ports. No database, no HTTP, no Laravel boot.
- Replaceability. Swap Eloquent for Doctrine, swap Stripe for Adyen, swap REST for GraphQL — the core does not change.
- Clarity. Business logic lives in one place, written in plain PHP. Reading the use case tells you exactly what the system does.
What You Pay
- More code. Every entity has an Eloquent model and a domain class. Every external service has an interface and an adapter.
- Mapping overhead. Eloquent models map to domain entities and back. The mapping layer is a real maintenance cost.
- Onboarding cost. Engineers who expect "Laravel everywhere" need to learn the layering rules.
When Hexagonal Pays Off
Hexagonal architecture is overkill for CRUD applications and most internal tools. It earns its keep when:
- The business logic is genuinely complex and worth isolating from framework churn
- You expect to swap infrastructure pieces over the system's life
- You need fast, framework-free tests for domain logic
- Multiple teams need to work on the core without coordinating on Laravel upgrades
If your application is mostly forms over data, plain Laravel is a better fit. If your application is mostly business rules with forms on top, hexagonal is worth the investment.
Architecting an application where the business logic deserves to outlive the framework choices? We help teams build cores that survive the next ten years of stack churn. scopeforged.com