Skip to content
AXVI BUILD · WRITE · SHIP
Blog

The 80% of Laravel's Container You've Never Used

Beyond app()->make() and type-hinted injection. Contextual bindings, tagged services, extend(), rebinding, beforeResolving() — the container features that separate framework users from framework understanders.

The 80% of Laravel's Container You've Never Used

Introduction

Every Laravel developer uses the service container. Most use about 20% of it: type-hint a class in a constructor, maybe bind an interface to an implementation, call it a day.

The other 80% sits in Illuminate\Container\Container — 1,800+ lines of resolution logic, contextual bindings, tagging, extending, rebinding callbacks, and an auto-wiring system built on PHP's Reflection API. These features exist because real applications need them, but the docs barely scratch the surface.

This article walks through the container features you probably haven't used, with practical examples and references to the actual source code.

How Resolution Actually Works

When you call app()->make(SomeClass::class), here's what happens inside Container::resolve():

protected function resolve($abstract, $parameters = [], $raiseEvents = true)
{
    $abstract = $this->getAlias($abstract);

    // 1. Check if we have a contextual binding
    $concrete = $this->getContextualConcrete($abstract);

    // 2. Check if we have a regular binding
    if (is_null($concrete)) {
        $concrete = $this->getConcrete($abstract);
    }

    // 3. Build the instance
    if ($this->isBuildable($concrete, $abstract)) {
        $object = $this->build($concrete);
    } else {
        $object = $this->make($concrete);
    }

    // 4. Apply extenders
    foreach ($this->getExtenders($abstract) as $extender) {
        $object = $extender($object, $this);
    }

    // 5. If singleton, store for reuse
    if ($this->isShared($abstract)) {
        $this->instances[$abstract] = $object;
    }

    $this->resolved[$abstract] = true;

    return $object;
}

Five steps, each with its own hook. Most developers only interact with step 2 (bindings) and step 5 (singletons). Let's explore them all.

The Alias System

Before resolution starts, the container resolves aliases:

$abstract = $this->getAlias($abstract);

Aliases let you reference the same binding by different names:

$this->app->alias(PaymentGateway::class, 'payment');
$this->app->alias(PaymentGateway::class, PaymentInterface::class);

// All three resolve to the same thing:
app(PaymentGateway::class);
app('payment');
app(PaymentInterface::class);

Laravel uses this heavily internally. When you type-hint Illuminate\Contracts\Cache\Repository, it resolves through an alias chain to the actual cache store implementation. The alias chain can be multiple levels deep — getAlias() loops until it finds a non-aliased name.

bind() vs singleton() vs scoped() vs instance()

These four methods all register bindings, but the lifecycle differs:

// New instance every time
$this->app->bind(Service::class, fn () => new Service());

// One instance for the entire application lifetime
$this->app->singleton(Service::class, fn () => new Service());

// One instance per request (Octane-safe) — flushed between requests
$this->app->scoped(Service::class, fn () => new Service());

// Pre-built instance — no factory, no resolution
$this->app->instance(Service::class, $existingObject);

Under the hood, all of these write to the same $bindings array. The difference is a shared flag:

public function bind($abstract, $concrete = null, $shared = false)
{
    $this->bindings[$abstract] = compact('concrete', 'shared');
}

public function singleton($abstract, $concrete = null)
{
    $this->bind($abstract, $concrete, true);
}

scoped() is a singleton that gets flushed when $this->forgetScopedInstances() is called — which Octane does between requests.

When to use scoped()

If you're running Octane, prefer scoped() over singleton() for anything that holds request-specific state. A singleton persists across requests in Octane — which means user A's data leaks into user B's request if you're not careful.

// Dangerous in Octane: persists across requests
$this->app->singleton(Cart::class, fn () => new Cart());

// Safe: fresh instance per request
$this->app->scoped(Cart::class, fn () => new Cart());

Auto-Resolution via Reflection

The container's most powerful feature is one you never explicitly call: auto-resolution. When there's no binding registered for a class, the container builds it automatically using PHP's Reflection API.

public function build($concrete)
{
    $reflector = new ReflectionClass($concrete);

    $constructor = $reflector->getConstructor();

    if (is_null($constructor)) {
        return new $concrete;  // No constructor — just instantiate
    }

    $dependencies = $constructor->getParameters();

    $instances = $this->resolveDependencies($dependencies);

    return $reflector->newInstanceArgs($instances);
}

The container reads constructor parameter type hints, recursively resolves each one, and instantiates the class. This is why you can type-hint any class — even one you never registered — and it just works.

The recursion

If class A depends on class B, which depends on class C, the container resolves C first, then B (with C injected), then A (with B injected). It's recursive make() calls all the way down.

When it fails

Auto-resolution fails when a parameter has no type hint, or the type is a primitive (string, int). The container can't guess what string you want:

class Service
{
    public function __construct(
        private HttpClient $client,  // ✓ Resolvable
        private string $apiKey,      // ✗ Can't auto-resolve
    ) {}
}

Solutions: bind the class explicitly, or use contextual bindings.

Contextual Bindings

This is the feature that changes how you think about DI. When class A needs an interface, give it implementation X. When class B needs the same interface, give it implementation Y.

$this->app->when(PhotoController::class)
    ->needs(StorageInterface::class)
    ->give(S3Storage::class);

$this->app->when(VideoController::class)
    ->needs(StorageInterface::class)
    ->give(LocalStorage::class);

Under the hood, this stores the binding in a separate $contextual array, indexed by the consuming class:

$this->contextual[PhotoController::class][StorageInterface::class] = S3Storage::class;

During resolution, getContextualConcrete() checks if the class being built has a contextual override for the dependency being resolved.

Contextual binding for primitives

You can also bind primitive values contextually — solving the "string can't be auto-resolved" problem:

$this->app->when(StripeGateway::class)
    ->needs('$apiKey')
    ->give(fn () => config('services.stripe.key'));

$this->app->when(PayPalGateway::class)
    ->needs('$apiKey')
    ->give(fn () => config('services.paypal.key'));

The $ prefix tells the container this is a parameter name, not a class.

Contextual binding with giveConfig()

A shortcut for the common case:

$this->app->when(StripeGateway::class)
    ->needs('$apiKey')
    ->giveConfig('services.stripe.key');

Tagged Services

Tags let you group related bindings and resolve them all at once:

$this->app->bind(CpuReport::class);
$this->app->bind(MemoryReport::class);
$this->app->bind(DiskReport::class);

$this->app->tag([CpuReport::class, MemoryReport::class, DiskReport::class], 'reports');

// Resolve all tagged services
$reports = $this->app->tagged('reports');

foreach ($reports as $report) {
    $report->generate();
}

tagged() returns a RewindableGenerator — it lazy-resolves each service as you iterate. No memory wasted on services you don't reach.

Practical use: plugin systems

// Each package registers its own report
$this->app->tag([CustomReport::class], 'reports');

// The dashboard resolves all of them
class Dashboard
{
    public function __construct(
        #[Tag('reports')]
        private iterable $reports,
    ) {}
}

extend() — The Decorator Pattern

extend() lets you modify or wrap a resolved instance after it's built:

$this->app->extend(Logger::class, function (Logger $logger, Container $app) {
    return new TimestampLogger($logger);  // Decorator
});

Every time Logger is resolved, the extender runs on the result. You can chain multiple extenders — they apply in registration order.

Practical use: adding behavior without changing bindings

// In a testing service provider
$this->app->extend(PaymentGateway::class, function ($gateway) {
    $gateway->enableSandboxMode();
    return $gateway;
});

You don't need to re-bind or override the factory. Just extend.

beforeResolving() and afterResolving()

These are resolution lifecycle hooks:

$this->app->beforeResolving(Service::class, function ($abstract, $parameters, $app) {
    // Runs before Service is resolved
    Log::debug("About to resolve: {$abstract}");
});

$this->app->afterResolving(Service::class, function ($resolved, $app) {
    // Runs after Service is resolved
    if ($resolved instanceof Configurable) {
        $resolved->configure($app['config']);
    }
});

afterResolving() is particularly useful for method injection patterns — calling a setup method on every instance of an interface:

$this->app->afterResolving(HasLocale::class, function ($service) {
    $service->setLocale(app()->getLocale());
});

Every class implementing HasLocale automatically gets the current locale set, no matter how it's resolved.

rebinding()

rebinding() fires when a binding is re-registered after it was already resolved:

$this->app->singleton('theme', fn () => 'light');

$this->app->rebinding('theme', function ($app, $theme) {
    // Fired when someone re-binds 'theme' after it was resolved
    Cache::forget('rendered-views');
});

// Later...
$this->app->singleton('theme', fn () => 'dark');  // Triggers rebinding callback

This is rare in application code but used internally by Laravel to propagate config changes through already-resolved services.

The $bindings and $singletons Shortcut

Since Laravel 11, service providers have a cleaner syntax:

class AppServiceProvider extends ServiceProvider
{
    public array $bindings = [
        PaymentInterface::class => StripePayment::class,
    ];

    public array $singletons = [
        CacheStore::class => RedisCacheStore::class,
    ];
}

No register() method needed. The framework reads these properties and registers them automatically during the provider registration phase.

Putting It All Together

The container isn't just a factory. It's a dependency graph resolver with lifecycle hooks, contextual overrides, and lazy loading. Here's the mental model:

Feature When to use
bind() Different instance each time
singleton() One instance forever (careful in Octane)
scoped() One instance per request (Octane-safe)
instance() Pre-built object, skip factory
Contextual binding Same interface, different implementations per consumer
tag() Group related services, resolve as collection
extend() Decorate or modify after resolution
afterResolving() Setup hook for all instances of a type
rebinding() React to binding changes

Most applications need bind(), singleton(), and maybe contextual bindings. But when you need the others — tagged services for plugin systems, extend() for testing overrides, afterResolving() for cross-cutting concerns — they're there. And now you know where to find them.


Found this useful? Follow me on GitHub, LinkedIn, or X.

Esc