Index

A Composition Root Makes Choices Visible

Remove the ProductPrices binding from a Laravel service provider, then ask the container for QuoteProduct. Laravel refuses to construct it:

Target [App\Application\ProductPrices] is not instantiable while building
[App\Application\QuoteProduct].

Move the same lookup into QuoteProduct::execute() and construction succeeds. The missing dependency does not become visible until a quote request reaches that line. The application object looks simpler because its constructor says nothing. Its object graph is not simpler; part of the graph has moved into method code and configuration.

A composition root keeps that choice at the application’s entry edge. In a Laravel application, registered service providers can form that root. The use-case objects declare what they need, the providers select concrete implementations, and container resolution can expose an incomplete graph before application behavior starts.

The constructor and provider describe one graph

A Hexagon Is Not a Directory Layout ended with an application-owned port:

interface ProductPrices
{
    public function forSku(string $sku): int;
}

The quote use case depends on that conversation, not on Laravel’s HTTP client or the catalogue’s JSON:

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 constructor exposes one unfinished edge: something must supply ProductPrices. The reproducible Laravel experiment completes that edge in a service provider:

final class ArchitectureServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $adapter = config('services.catalogue.schema') === 'v2'
            ? V2HttpProductPrices::class
            : V1HttpProductPrices::class;

        $this->app->bind(ProductPrices::class, $adapter);
    }
}

Laravel’s official container documentation describes interface-to-implementation bindings and constructor injection for classes the container resolves. Its service-provider documentation places user-defined providers in bootstrap/providers.php and container bindings in register().

Those two files answer different questions:

bootstrap/providers.php
    -> ArchitectureServiceProvider
        -> ProductPrices => V1HttpProductPrices | V2HttpProductPrices

ExplicitQuoteController
    -> QuoteProduct
        -> ProductPrices

The application object declares the capability. The application edge chooses the mechanism. A reviewer does not need to execute QuoteProduct to discover either relationship.

Mark Seemann’s description of a composition root calls it a preferably unique location near the application entry point where modules are composed. It also limits container references to that root. “Unique” is a statement about ownership, not a demand for one enormous PHP file. More on that distinction shortly.

Service location erases an edge from the constructor

Laravel makes container access convenient enough to place almost anywhere. The experiment uses that convenience to build a behavior-equivalent comparison:

final readonly class LocatedQuoteProduct
{
    public function execute(string $sku, int $quantity): Quote
    {
        $prices = app()->make(
            config('services.catalogue.prices'),
        );

        return new Quote(
            sku: $sku,
            quantity: $quantity,
            unitPriceCents: $prices->forSku($sku),
        );
    }
}

The parameterless constructor is truthful only in the narrow PHP sense: the runtime can instantiate the class without arguments. It is incomplete as a description of what execute() requires. The real graph now crosses method code, a global helper, a configuration key, and a container entry:

LocatedQuoteController
    -> LocatedQuoteProduct
        -> app()->make(config(...))
            -> V1HttpProductPrices | V2HttpProductPrices | invalid

PSR-11’s recommended usage says users should not pass a container into an object so the object can retrieve its dependencies, identifying that shape as Service Locator. Calling Laravel’s global app() helper produces the same dependency direction even though no ContainerInterface constructor parameter appears.

The container is not the villain here. It can assemble an explicit graph or hide one, depending on where it is called. The useful boundary is between code which selects and assembles objects and code which performs application work.

Two failures show where the dependency lives

Both implementations return the same quote when configured with the v2 adapter and the matching local catalogue provider:

{
  "sku": "desk-lamp",
  "quantity": 2,
  "unit_price_cents": 10000,
  "subtotal_cents": 20000
}

Matching explicit v1, explicit v2, and located v2 graphs all produced that response. An explicit v1 adapter against the v2 provider failed inside the selected adapter because price_cents was absent. That mismatch matters: it proves the service-provider choice affects the graph’s behavior rather than serving as decorative configuration.

The sharper comparison removes the selected dependency and separates resolution from invocation. The test prints whether the application method ran:

CaseContainer resolutionApplication invocation
explicit binding presentQuoteProduct resolvesnot invoked
explicit binding missingresolution fails on ProductPricesnot invoked
located entry invalidLocatedQuoteProduct resolvesfails inside execute()

With the explicit binding omitted, Laravel reports:

resolving=App\Application\QuoteProduct
method_invoked=no
exception=Illuminate\Contracts\Container\BindingResolutionException
message=Target [App\Application\ProductPrices] is not instantiable while
building [App\Application\QuoteProduct].

With an invalid located entry, the object first resolves and then fails:

resolved=App\Located\LocatedQuoteProduct
method_invoked=yes
exception=Illuminate\Contracts\Container\BindingResolutionException
message=Target class [catalogue.prices.invalid] does not exist.

The exception class is the same. The failure boundary is not. Constructor injection lets Laravel inspect the required graph while constructing the use case. Service location postpones the same question until the selected method path performs the lookup.

That is the bounded advantage established here. The experiment does not measure debugging speed, onboarding time, defect rates, or maintenance cost. It proves that one dependency is declared and resolution-visible while the other is hidden until invocation.

One root can span several provider files

One conceptual root does not require every binding in AppServiceProvider. Catalogue, billing, and shipment modules can each have a provider or registration helper. bootstrap/providers.php remains the application-owned inventory which brings those pieces into one runtime.

Application entry edge
    -> CatalogueServiceProvider
    -> BillingServiceProvider
    -> ShipmentServiceProvider

This is one conceptual composition root spread across several physical files. The entry edge still owns which modules participate. Application services still receive dependencies rather than asking modules or the container to locate them.

The arrangement becomes harder to inspect when a package or feature silently self-registers choices, when application methods contain container lookups, or when a configured class name is the only record of a required collaborator. The problem is not the number of provider files. It is losing an inventory of the graph at the edge which starts the application.

A single provider file is worth keeping while it stays readable. Split it when unrelated modules cause merge pressure or conceal ownership. The reversal condition concerns navigability and module boundaries, not loyalty to a diagram.

Laravel has more than one honest selection mechanism

Provider bindings are not the only way to compose a Laravel application. Laravel 13 supports a Bind attribute on an interface, including environment-specific implementations. That is concise when the interface has a stable application default. It is less attractive when the application contract should not name an infrastructure adapter or when the selection differs by deployment.

Contextual bindings are appropriate when two consumers need different implementations of the same contract. Tagged bindings fit a set of participants, such as several importers or checks, better than one global interface-to-class choice. Both still describe composition as long as their registration remains at the application edge.

Pure DI is the smallest credible alternative for a small graph:

$quoteProduct = new QuoteProduct(
    new V2HttpProductPrices($http),
);

No container is required to have a composition root. Direct construction may be easier to inspect when the graph is short and stable. Laravel bindings earn their ceremony when the framework resolves an entry point, several entry points share a meaningful graph, or implementation selection varies in a way the application must configure.

There is also a different kind of selection which should not be pushed into a provider. If choosing a pricing strategy depends on the country, customer, or contents of the current request, that choice may be application policy. A domain or application factory can receive the available strategies through its constructor and select among them. Deployment wiring chooses which objects exist; application policy chooses what work the current input requires.

The graph needs both behavior and structure checks

The fixture generates its composition report from committed source. It finds zero container lookups under the explicit application namespace. The located comparison has one lookup and one dependency absent from its constructor.

Three independent negative controls make those checks falsifiable:

  • an application class calls app();
  • the provider exists but is absent from bootstrap/providers.php; and
  • the registered provider omits the ProductPrices binding.

Each control fails for its own rule. The request matrix separately proves that matching adapters preserve the response and that changing the selected adapter changes behavior against the provider schema. Structure checks cannot prove the quote is correct; feature behavior cannot prove that the dependency is visible. The two forms of evidence answer different questions.

The experiment uses PHP 8.4.12, Laravel 13.20.0, and PHPUnit 12.5.31 in a digest-pinned Docker image. The inbound Laravel feature request runs inside the test process, while the HTTP adapter crosses a real local process boundary to the catalogue fixture. The retained evidence therefore covers the owned container graph, adapter mapping, source inventory, and failure timing. It does not cover DNS, TLS, credentials, remote deployment, or production reliability.

Make selection inspectable before behavior starts

For each application entry point, list the object it resolves and follow its constructor dependencies. A required collaborator should lead back to an application-owned provider, explicit construction code, or another visible registration mechanism. Remove one binding and verify that graph resolution fails before the application method runs.

Keep direct construction when it exposes a small graph cleanly. Split provider files when module ownership makes the root easier to navigate. Use contextual or tagged bindings when cardinality requires them. Put input-dependent business selection in an injected factory.

The boundary is crossed when application code asks the container what it needs. At that point the constructor stops describing the object, and a broken graph can wait behind an unexecuted branch. A composition root earns its place when the application’s concrete choices can be found at the entry edge and an incomplete graph can be rejected before application behavior begins.