Developers often overlook the hidden tax of tightly coupled code: slow tests, brittle CI pipelines, and migrations that feel more like archaeology than engineering. One team discovered this the hard way when their Laravel application’s continuous integration pipeline stretched beyond 20 minutes for a single commit. The culprit? A controller action that acted as a one-stop shop for database operations, email dispatch, analytics tracking, and CRM synchronization—all woven together into a single, untestable tangle. Their solution? Hexagonal architecture.
The hidden cost of tightly coupled controllers
Consider a typical Laravel controller method that handles order creation. It fetches validated input, persists an order to the database, sends a confirmation email, logs an event to an analytics platform, and conditionally dispatches a job to sync the order with a CRM. The code might look clean at first glance, but it embeds four distinct external dependencies—an ORM, a mailer, an analytics SDK, and a job queue—directly into the application’s core logic.
public function store(StoreOrderRequest $request) {
$order = Order::create($request->validated());
Mail::to($order->user->email)->send(new OrderCreated($order));
Statsig::logEvent($request->user(), 'order_created', [
'order_id' => $order->id,
]);
if (config('zoho.sync.enabled')) {
SyncOrderJob::dispatch($order->id);
}
return redirect()->route('orders.index');
}This approach forces every test to either spin up a full Laravel environment or mock all four dependencies. Even with framework-provided test doubles, maintaining these fakes becomes a chore as the codebase evolves. The real issue isn’t the implementation—it’s the coupling. When business logic and infrastructure details are intertwined, changing one often breaks the other.
Step one: define the application’s intent with ports
The first step toward decoupling is to shift focus from what the code uses to what the code wants to achieve. Hexagonal architecture, as described by Alistair Cockburn, advocates naming interfaces based on use cases rather than technologies. This creates a clear boundary between the application’s core logic and the external systems it interacts with.
The team introduced a naming convention they called Foring—interfaces prefixed with "For" to indicate the application’s intent. For example, instead of naming an interface OrderRepository, they created ForStoringOrders and ForFindingOrders to explicitly declare the actions the application needs to perform. This separation ensures consumers only depend on what they actually require, reducing unnecessary coupling.
namespace App\Application\Order\Ports;
interface ForStoringOrders {
public function create(array $attributes): Order;
public function update(int $id, array $attributes): Order;
}
interface ForFindingOrders {
public function find(int $id): ?Order;
public function findByUserId(int $userId): Collection;
}
interface ForSendingOrderNotifications {
public function notifyOrderCreated(User $user, Order $order): void;
}
interface ForLoggingAnalyticsEvents {
public function logEvent(User $user, string $name, array $properties = []): void;
}
interface ForSyncingOrdersWithCrm {
public function queueSync(int $orderId): void;
}Key observations here:
- The repository interfaces are split into read and write operations, allowing consumers to depend only on what they need.
- Port names describe intent, not technology.
ForSendingOrderNotificationsdoesn’t specify email or Slack; it simply states the application’s goal. - The CRM sync port,
ForSyncingOrdersWithCrm, avoids naming the specific tool (e.g., Zoho). This ensures the interface remains stable even if the underlying CRM changes.
Step two: refactor the controller to depend on abstractions
With the ports defined, the team rewrote the controller to delegate work to an orchestration class—CreateOrderAction—which depends solely on interfaces owned by the application. The controller’s role narrowed to translating an HTTP request into a structured input, calling the action, and rendering a response.
final class CreateOrderAction {
public function __construct(
private ForStoringOrders $orders,
private ForSendingOrderNotifications $notifications,
private ForLoggingAnalyticsEvents $analytics,
private ForSyncingOrdersWithCrm $crm,
) {}
public function execute(CreateOrderInput $input): Order {
$order = $this->orders->create($input->toAttributes());
$this->notifications->notifyOrderCreated($input->user, $order);
$this->analytics->logEvent($input->user, 'order_created', [
'order_id' => $order->id,
]);
$this->crm->queueSync($order->id);
return $order;
}
}
public function store(StoreOrderRequest $request, CreateOrderAction $action) {
$order = $action->execute(CreateOrderInput::fromRequest($request));
return redirect()->route('orders.index');
}The ForSyncingOrdersWithCrm port eliminated the need for a conditional check within the action. Instead, the production adapter—responsible for the actual CRM integration—handles whether the sync should occur based on its own configuration. The application’s core logic remains unchanged, even if the CRM’s behavior does.
Step three: implement adapters that bridge the gap to infrastructure
Adapters live outside the application namespace and are free to use framework-specific tools like Eloquent, Laravel’s mailer, or external SDKs. Their job is to translate the application’s abstract requests into concrete actions.
For example, the Eloquent adapter implements both ForStoringOrders and ForFindingOrders, providing database access without exposing the underlying ORM to the application:
namespace App\Infrastructure\Persistence\Eloquent\Order;
final class EloquentOrders implements ForFindingOrders, ForStoringOrders {
public function find(int $id): ?Order {
return Order::find($id);
}
public function findByUserId(int $userId): Collection {
return Order::where('user_id', $userId)->get();
}
public function create(array $attributes): Order {
return Order::create($attributes);
}
public function update(int $id, array $attributes): Order {
$order = Order::findOrFail($id);
$order->update($attributes);
return $order;
}
}Similarly, the mailer adapter implements ForSendingOrderNotifications by sending emails through Laravel’s mailer:
namespace App\Infrastructure\Notifications;
final class MailerOrderNotifications implements ForSendingOrderNotifications {
public function __construct(private Mailer $mailer) {}
public function notifyOrderCreated(User $user, Order $order): void {
$this->mailer->to($user->email)
->send(new OrderCreated($order));
}
}These adapters encapsulate framework-specific details, allowing the application to remain agnostic of the underlying technologies. This separation makes it easier to swap out components—like replacing the mailer with a Slack integration—without touching the core business logic.
The payoff: faster tests, cleaner migrations, and sustainable growth
By decoupling the application’s core logic from its infrastructure dependencies, the team transformed their CI pipeline from a sluggish 20-minute ordeal into a nimble 5-minute process. Tests no longer need to boot the entire Laravel environment; they can mock interfaces directly, reducing setup time and increasing reliability.
More importantly, the architecture future-proofs the codebase. As the team adds new features or migrates to different tools, the ports remain stable while the adapters adapt. This modularity isn’t just a technical achievement—it’s a foundation for sustainable development in an ecosystem where change is the only constant.
The lesson here isn’t about Laravel or even PHP. It’s about recognizing that every line of code that couples your business logic to external systems is a potential maintenance debt. Hexagonal architecture turns that debt into an asset by making dependencies explicit, tests lightweight, and migrations painless.
AI summary
Learn how to decouple business logic from infrastructure using hexagonal architecture, ports, and adapters to slash CI times and simplify testing.