A Laravel controller calls a catalogue API, reads price_cents, and returns a
quote. Moving that HTTP code into Application/QuoteProduct.php makes the
controller thinner. It also leaves the application service coupled to
Laravel’s HTTP client, the provider URL, and the provider’s JSON.
The tree looks more architectural. The source dependencies have not moved.
That difference becomes visible when the catalogue replaces price_cents
with price.amount_minor. A folder name cannot decide where the resulting
change lands. A useful hexagonal boundary can: application policy defines the
conversation it needs, while an outside adapter owns the provider mechanism.
Hexagonal architecture earns its cost when it makes that direction visible and enforceable. Renaming directories is neither necessary nor sufficient.
A thin controller can hide the same dependency
The reproducible Laravel experiment starts with one synthetic
POST /quotes request:
{
"sku": "desk-lamp",
"quantity": 2
}Docker is its only host dependency. ./run.sh rebuilds the pinned image, runs
the matrix, and retains each command, response, exit, failure, diff, and source
dependency report.
The first implementation validates the request, calls the provider, extracts the price, calculates the subtotal, and returns JSON in one controller:
final readonly class QuoteController
{
public function __construct(private Factory $http) {}
public function __invoke(Request $request): JsonResponse
{
$input = $request->validate([
'sku' => ['required', 'string'],
'quantity' => ['required', 'integer', 'min:1'],
]);
$response = $this->http
->get(config('services.catalogue.url').'/products/'.$input['sku'])
->throw();
$unitPriceCents = $response->json('price_cents');
return response()->json([
'sku' => $input['sku'],
'quantity' => $input['quantity'],
'unit_price_cents' => $unitPriceCents,
'subtotal_cents' => $unitPriceCents * $input['quantity'],
]);
}
}There is plenty to dislike here, but class size is not the interesting defect. The controller knows facts from three boundaries:
- HTTP request and response mechanics belong to the web edge;
- catalogue URL and payload mechanics belong to the provider edge; and
- quoting a quantity at a unit price belongs to the application conversation.
Extracting the middle of the method into Application\QuoteProduct produces a
thin controller. The new service still imports
Illuminate\Http\Client\Factory, calls config(), constructs /products/,
and reads price_cents.
Route -> QuoteController -> Application\QuoteProduct
-> Illuminate HTTP -> provider fieldsThe move may improve local readability. It does not isolate the application
from the provider. Calling the destination directory Application merely
makes the remaining dependency easier to overlook.
Runtime control and source direction are different arrows
A quote request must eventually reach the catalogue. Hexagonal architecture does not reverse that runtime conversation:
request -> quote use case -> catalogue provider -> priceIt reverses which source code defines the boundary. The application names the capability it requires. The HTTP adapter imports that application contract and translates it to a provider request:
HTTP controller -> QuoteProduct -> ProductPrices
^
|
HTTP adapter ----+Alistair Cockburn’s original Ports and Adapters article describes the port as a purposeful application conversation and the adapter as the technology-specific translation at the outside. The six sides are room for a drawing, not six prescribed modules.
This is the same ownership rule developed in Dependency Inversion Lets Policy Own the Boundary. Constructor injection only shows how an object receives another object. Source direction shows whether policy imports the mechanism or the mechanism imports a contract owned by policy.
The distinction matters because these two arrows legitimately point in
opposite directions. At runtime, QuoteProduct calls the provider adapter. In
source, the adapter implements ProductPrices from the application namespace.
The port names an application conversation
The experiment gives the application the smallest capability needed by the quote use case:
namespace App\Hexagonal\Application;
interface ProductPrices
{
public function forSku(string $sku): int;
}QuoteProduct knows neither Laravel nor the catalogue payload:
final readonly class QuoteProduct
{
public function __construct(private ProductPrices $prices) {}
public function execute(string $sku, int $quantity): Quote
{
return new Quote(
sku: $sku,
quantity: $quantity,
unitPriceCents: $this->prices->forSku($sku),
);
}
}The v1 adapter owns the HTTP vocabulary and provider field:
final readonly class V1HttpProductPrices implements ProductPrices
{
public function __construct(private Factory $http) {}
public function forSku(string $sku): int
{
return $this->http
->get(config('services.catalogue.url').'/products/'.$sku)
->throw()
->json('price_cents');
}
}An interface such as HttpClient::get(string $url): array would not achieve
this. It would leave the URL and external payload in application code while
changing only the HTTP client’s spelling. ProductPrices is useful because
its method and return value belong to the caller’s conversation.
That contract is intentionally narrow. Interfaces Belong to Their Clients explains why an application port should not copy the provider’s entire API. It also leaves an obligation: if several adapters claim to supply product prices, shared contract tests must verify their caller-visible behavior.
A provider change reveals the real boundary
Provider v1 returns:
{
"sku": "desk-lamp",
"price_cents": 10000
}Provider v2 nests the amount:
{
"sku": "desk-lamp",
"price": {
"amount_minor": 10000,
"currency": "EUR"
}
}The experiment runs three source shapes against both schemas. Each passing case receives the same request and returns the same application JSON. Each unadapted v1 case must fail after receiving the v2 provider response; otherwise a route, container, or process failure could masquerade as boundary evidence.
| Source shape | v1 with provider v1 | v1 with provider v2 | v2 with provider v2 | Provider-aware change |
|---|---|---|---|---|
| coupled | pass | intended failure | pass | controller |
| renamed only | pass | intended failure | pass | Application\QuoteProduct |
| application-owned port | pass | intended failure | pass | HTTP adapter |
The coupled and renamed v1 variants read a missing field, returning a null unit
price and zero subtotal before the assertion rejects them. The typed
ProductPrices port turns the same missing mapping into a return-type error.
Those failure surfaces differ, so the experiment does not pretend the designs
have identical failure semantics. They do prove that all three executions
reached the changed provider field.
After adaptation, all three variants return:
{
"sku": "desk-lamp",
"quantity": 2,
"unit_price_cents": 10000,
"subtotal_cents": 20000
}The source diffs identify where the new field is understood. Coupled code
changes the controller. Renamed-only code changes the transport-aware
application service. Hexagonal code changes
V1HttpProductPrices::forSku() to the v2 adapter’s
json('price.amount_minor'); its controller, use case, port, request, result,
route, and assertion remain unchanged.
That is the narrow result this example can support. It does not prove that ports reduce defects, shorten delivery time, or make every codebase easier to navigate. The explicit inference is smaller: when only the adapter contains the provider fields and only that adapter changes, this provider-schema change has stopped at that adapter in this fixture.
A boundary needs a failing check
Layer diagrams are promises. Source analysis can test part of the promise. The fixture generates its dependency report from the committed PHP files:
Coupled: Route -> QuoteController -> Illuminate HTTP -> provider fields
Renamed: Route -> QuoteController -> Application\QuoteProduct
-> Illuminate HTTP -> provider fields
Hexagonal: Route -> HTTP\QuoteController -> Application\QuoteProduct
-> Application\ProductPrices
Hexagonal: HTTP adapter -> Application\ProductPrices
Hexagonal application forbidden dependencies: 0The check rejects Laravel imports, infrastructure imports, configuration
lookups, provider URLs, and provider field names under the hexagonal
Application namespace. A negative-control class deliberately imports the
HTTP adapter from application code. The analyzer exits non-zero and reports
the forbidden direction, proving that the check can fail rather than merely
describing the current tree.
This is not a complete architecture proof. Static checks cannot decide whether
ProductPrices is good application language, whether its integer needs a
currency, or whether two modules should belong to the same business boundary.
They can stop a known mechanical regression after people have made those
decisions.
Laravel still has to select the adapter. Its official container documentation supports binding an interface to an implementation, and its service-provider documentation places application bootstrapping and container bindings in providers. The fixture keeps that choice at the edge:
$this->app->bind(ProductPrices::class, V2HttpProductPrices::class);Where all such choices should live—and how to keep service location out of application code—is the next composition problem. It is separate from whether this port points in the right direction.
Sometimes the direct flow is the honest design
The hexagonal version adds an interface, an adapter, and container wiring. A small direct controller remains credible when the provider is stable, application policy is trivial, and there is no useful application conversation to protect. Moving two obvious HTTP lines through three types can make the change harder to follow without containing anything likely to vary.
A conventional layered design can enforce the same inward source rule. The
hexagonal drawing is useful because it emphasizes inside versus outside and
allows several adapters around one port. It does not require directories named
Ports, Adapters, or Hexagonal.
The decision moves toward a port when provider details change independently
from application policy, several mechanisms serve the same capability, or the
application genuinely needs to run without the framework. It moves back
toward direct code when the proposed port says get(), accepts a URL, returns
provider arrays, and exists mainly to make the tree resemble a diagram.
The experiment itself is bounded. It uses Laravel 13.20.0 on PHP 8.4.12, one internally simulated Laravel request per matrix row, and a real local HTTP process for the outbound catalogue. Laravel’s HTTP test documentation is explicit that the inbound feature-test request is not a real network request. The fixture therefore proves the owned request path and provider-body translation, not DNS, TLS, credentials, deployment, or remote availability.
Draw the arrows before drawing the hexagon
Start with the next likely outside change. Name the application conversation which should remain stable, then inspect the imports and field references. If policy still knows URLs, framework clients, and provider payloads, a directory move has not protected it.
Extract a port when the application can name a smaller, durable capability and the outside mechanism is likely to vary independently. Put translation in an adapter, wire the choice at the edge, and add a failing direction check for the rule that matters.
If that sentence cannot name anything more meaningful than HTTP, keep the direct code. A hexagon is evidence about dependency direction, not a floor plan.