Index

A Hexagon Is Not a Directory Layout

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 fields

The 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 -> price

It 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 shapev1 with provider v1v1 with provider v2v2 with provider v2Provider-aware change
coupledpassintended failurepasscontroller
renamed onlypassintended failurepassApplication\QuoteProduct
application-owned portpassintended failurepassHTTP 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: 0

The 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.