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