Binding interfaces to implementations, contextual binding, tagged services, extending resolved instances, and the moment the container clicks — the complete guide to understanding what’s actually happening when Laravel resolves your dependencies.
Every Laravel developer uses the service container. Most of them use it through muscle memory: type-hint a class in a controller constructor, it appears. Add a binding in a service provider, it resolves. It works, so they stop thinking about it. The container becomes a magic box — stuff goes in, stuff comes out, the details stay blurry.
That’s fine until the day you need to bind the same interface to two different implementations depending on context, or resolve a service with runtime parameters, or extend a third-party binding without touching the original code. Then the magic box stops being useful and you need to understand what’s actually in it.
This is that explanation.
What the Container Actually Is
The service container is a dependency injection container — a registry that maps abstract types (interfaces, strings, class names) to concrete implementations (closures, class names, already-instantiated objects), and resolves the dependency graph when you ask for something.
When Laravel boots, Application extends Container. The container isn’t a separate thing you interact with via a facade — it is the application instance. When you call app(), resolve(), or app()->make(), you’re calling methods on the same object that handled the HTTP request.
The simplest mental model:
// The container is a map: abstract → how to build it
$bindings = [
'cache' => fn() => new CacheManager(...),
PaymentGateway::class => fn() => new StripeGateway(...),
LoggerInterface::class => fn() => new FileLogger(...),
];
// Resolution: look up the abstract, call the closure, return the result
$gateway = $bindings[PaymentGateway::class](); // new StripeGateway(...)
In reality the container handles recursive resolution, singleton caching, contextual overrides, and extension callbacks. But the core loop is always: look up the abstract, build it, return it.
Automatic Resolution — Why Most Apps Need Zero Bindings
The first thing most developers discover: the container can resolve concrete classes without any binding at all.
class OrderController extends Controller
{
public function __construct(
private readonly OrderRepository $orders, // ← no binding registered
private readonly PaymentService $payments, // ← no binding registered
) {}
}
This works because the container inspects the constructor via reflection, sees that OrderRepository is a concrete class (not an interface), instantiates it, recursively resolves its own dependencies, and injects it. For concrete classes with resolvable dependencies, the container handles everything.
The binding system exists for situations the automatic resolver can’t handle:
Concrete class with no dependencies → automatic, no binding needed
Concrete class with resolvable deps → automatic, no binding needed
Interface or abstract class → binding required
Class with primitive constructor args → binding required (or use factories)
Class requiring a specific instance → binding required
Same interface, different impls → contextual binding
Understanding that split — what needs a binding vs what doesn’t — is most of what you need to use the container fluently.
bind() vs singleton() vs instance()
Three methods, three distinct lifecycles:
// bind(): new instance on every resolution
$this->app->bind(ReportGenerator::class, function ($app) {
return new PdfReportGenerator(
$app->make(StorageManager::class),
config('reports.page_size'),
);
});
// singleton(): resolved once, same instance every time after
$this->app->singleton(PaymentGateway::class, function ($app) {
return new StripeGateway(
config('services.stripe.key'),
$app->make(HttpClient::class),
);
});
// instance(): skip the closure, register a pre-built object
$gateway = new StripeGateway(config('services.stripe.key'));
$this->app->instance(PaymentGateway::class, $gateway);
The behavioral difference matters in practice:
// With bind() — two resolutions, two instances
$a = app(ReportGenerator::class);
$b = app(ReportGenerator::class);
$a === $b; // false — different objects
// With singleton() — two resolutions, same instance
$a = app(PaymentGateway::class);
$b = app(PaymentGateway::class);
$a === $b; // true — same object
// With instance() — always returns exactly the object you registered
$a = app(PaymentGateway::class);
$a === $gateway; // true
Use singleton() for services that are expensive to instantiate or need to maintain state across the request lifecycle — database connections, HTTP clients, cache managers. Use bind() for services that generate output (reports, notifications) where you genuinely want a fresh instance each time. Use instance() in tests to swap in a pre-configured mock without triggering the container’s build logic.
Binding Interfaces to Implementations
This is the primary purpose of the binding system. Your application code depends on contracts; the service provider maps those contracts to implementations; nothing else needs to change when you swap implementations.
// The contract — lives in App\Contracts or a dedicated namespace
interface NotificationDriver
{
public function send(Notification $notification, User $recipient): void;
}
// Two implementations
class MailNotificationDriver implements NotificationDriver
{
public function send(Notification $notification, User $recipient): void
{
Mail::to($recipient)->send(new NotificationMailable($notification));
}
}
class SlackNotificationDriver implements NotificationDriver
{
public function send(Notification $notification, User $recipient): void
{
Http::post(config('services.slack.webhook'), [
'text' => $notification->message,
'channel' => $recipient->slack_channel,
]);
}
}
The service provider decides which implementation to bind:
// NotificationServiceProvider.php
public function register(): void
{
$this->app->singleton(NotificationDriver::class, function ($app) {
return match(config('notifications.driver')) {
'slack' => new SlackNotificationDriver(),
default => new MailNotificationDriver(),
};
});
}
Now every class that type-hints NotificationDriver gets the configured implementation — without knowing or caring which one it is:
class OrderService
{
public function __construct(
private readonly NotificationDriver $notifications,
) {}
public function complete(Order $order): void
{
// ... order completion logic
$this->notifications->send(
new OrderConfirmationNotification($order),
$order->customer,
);
// This code doesn't change when you switch from Mail to Slack
}
}
Swap the driver in config. Nothing else changes. That’s the point.
Contextual Binding — Same Interface, Different Implementations
The binding system above has one limitation: one interface, one implementation globally. Contextual binding lifts that restriction.
Suppose you have two repositories — one reads from your primary MySQL database, one reads from a read replica. Both implement the same interface. Different controllers should use different implementations.
interface UserRepository
{
public function find(int $id): User;
public function search(string $query): Collection;
}
class MysqlUserRepository implements UserRepository { /* primary write DB */ }
class ReadReplicaUserRepository implements UserRepository { /* read replica */ }
Without contextual binding you’d register one implementation globally and type-hint around it, or inject the concrete class directly. With contextual binding:
// AppServiceProvider.php
public function register(): void
{
// DashboardController gets the read replica — heavy reporting queries
$this->app->when(DashboardController::class)
->needs(UserRepository::class)
->give(ReadReplicaUserRepository::class);
// UserController gets the primary — needs consistent reads after writes
$this->app->when(UserController::class)
->needs(UserRepository::class)
->give(MysqlUserRepository::class);
}
Both controllers type-hint UserRepository. The container resolves the correct implementation based on which class is being built. Neither controller knows a replica exists.
You can also use closures when the implementation needs runtime configuration:
$this->app->when(ReportExportJob::class)
->needs(StorageDriver::class)
->give(function ($app) {
return new S3StorageDriver(
config('filesystems.disks.s3'),
$app->make(HttpClient::class),
);
});
Contextual binding composes cleanly with constructor injection. The container resolves the dependency graph for each class independently, applying the appropriate overrides at each level.
Passing Primitives with giveConfig() and givePrimitive()
Contextual binding also handles the case where a class needs a primitive value — a string, integer, or array — rather than a resolved service.
class ElasticsearchClient
{
public function __construct(
private readonly string $host,
private readonly int $port,
private readonly string $indexPrefix,
) {}
}
You can’t type-hint your way out of primitive constructor arguments. Contextual binding solves it:
$this->app->when(ElasticsearchClient::class)
->needs('$host')
->giveConfig('services.elasticsearch.host');
$this->app->when(ElasticsearchClient::class)
->needs('$port')
->giveConfig('services.elasticsearch.port');
$this->app->when(ElasticsearchClient::class)
->needs('$indexPrefix')
->give(fn () => config('app.env') . '_');
giveConfig() is syntactic sugar — it’s equivalent to give(fn () => config('key')). The $ prefix in needs('$paramName') tells the container you’re targeting a constructor parameter by name, not a type-hint.
Tagged Services — Resolving a Group
When multiple implementations of the same concept need to be collected and iterated, tags let you group bindings and resolve them all at once.
// Three audit loggers — each logs to a different destination
$this->app->bind(DatabaseAuditLogger::class);
$this->app->bind(SlackAuditLogger::class);
$this->app->bind(SplunkAuditLogger::class);
// Tag them all under a shared label
$this->app->tag(
[DatabaseAuditLogger::class, SlackAuditLogger::class, SplunkAuditLogger::class],
'audit-loggers'
);
Resolve the tag to get all three as an iterable:
class AuditService
{
public function __construct(
private readonly iterable $loggers,
) {}
public function log(AuditEvent $event): void
{
foreach ($this->loggers as $logger) {
$logger->log($event);
}
}
}
// In the service provider
$this->app->bind(AuditService::class, function ($app) {
return new AuditService(
$app->tagged('audit-loggers'),
);
});
$app->tagged('audit-loggers') returns a ContextualBindingBuilder that lazily resolves each tagged service when iterated. Adding a fourth logger is one line in the service provider — the AuditService code doesn’t change.
Extending Resolved Instances
extend() lets you decorate or modify a resolved instance after the container builds it, without changing the binding itself. This is the correct way to wrap third-party services without modifying the original binding.
// The original binding — registered in a third-party service provider
// you don't control
$this->app->singleton(CacheManager::class, function ($app) {
return new CacheManager($app);
});
// Your extension — adds a metrics wrapper
$this->app->extend(CacheManager::class, function ($cache, $app) {
return new MetricsCacheManager(
$cache, // original instance
$app->make(MetricsCollector::class), // your service
);
});
The closure receives the resolved instance and the container. Whatever you return from the closure becomes the resolved value going forward. You can wrap it, replace it, configure it, or call methods on it before returning.
Extension applies after every resolution — including the first. This means you can chain multiple extend() calls and each one wraps the previous result:
$this->app->extend(LoggerInterface::class, function ($logger, $app) {
return new ContextEnrichingLogger($logger, $app->make(RequestContext::class));
});
$this->app->extend(LoggerInterface::class, function ($logger, $app) {
return new SamplingLogger($logger, rate: 0.1); // log 10% of debug messages
});
The final resolved LoggerInterface is SamplingLogger → ContextEnrichingLogger → original Logger. The order of extend() calls determines the wrapping order.
Resolving with Runtime Parameters
Sometimes a binding can’t be fully configured at registration time — you need to pass data you only have at resolution time. makeWith() handles this:
class ReportExporter
{
public function __construct(
private readonly StorageDriver $storage,
private readonly string $format, // known at runtime, not boot
private readonly DateRange $period, // known at runtime, not boot
) {}
}
// Resolution with runtime parameters
$exporter = app()->makeWith(ReportExporter::class, [
'format' => 'pdf',
'period' => new DateRange($from, $to),
]);
The container resolves StorageDriver through the normal binding system and injects the runtime values for $format and $period by constructor parameter name. You get DI for the complex dependencies and direct injection for the runtime values — without a factory class.
scoped() — Singletons That Reset Per Request
scoped() was added in Laravel 8 and is underused. It registers a singleton that the container clears at the end of each request lifecycle — or manually when you call $this->app->forgetScopedInstances().
$this->app->scoped(TenantContext::class, function ($app) {
return new TenantContext(
$app->make(Request::class)->header('X-Tenant-ID'),
);
});
With singleton(), the TenantContext resolves once and persists for the process lifetime — dangerous in long-running contexts like Octane or queue workers where requests share a process. With scoped(), it resolves once per request and resets between requests. Same singleton behavior within a request, none of the cross-request contamination risk.
For Octane applications, scoped() is the safer default for anything that reads from the current request.
Binding Without the Facade
Service providers are the right place for bindings, but you’re not limited to $this->app. The app() helper and App facade are the same container:
// All equivalent
$this->app->singleton(PaymentGateway::class, $closure);
app()->singleton(PaymentGateway::class, $closure);
App::singleton(PaymentGateway::class, $closure);
resolve(PaymentGateway::class); // ← resolution, not binding
In tests, you often want to swap a binding for a specific test case:
public function test_order_completion_sends_notification(): void
{
$mock = Mockery::mock(NotificationDriver::class);
$mock->shouldReceive('send')->once();
// Swap the binding for this test only
$this->app->instance(NotificationDriver::class, $mock);
$this->postJson('/orders/1/complete');
$mock->shouldHaveReceived('send')->once();
}
instance() in a test registers the mock as the resolved value. Every class that needs NotificationDriver during this test request gets the mock — no manual injection required.
When the Container Clicks
There’s a moment in most developers’ relationship with Laravel where the container stops being mysterious. It usually happens when you’re looking at a service provider and you realize what register() actually is: it’s not configuration, it’s a deferred recipe. You’re not building anything — you’re telling the container how to build it, when asked.
public function register(): void
{
// Nothing is instantiated here
// This closure runs when something first asks for PaymentGateway::class
$this->app->singleton(PaymentGateway::class, function ($app) {
return new StripeGateway(
config('services.stripe.key'), // read at resolution time
$app->make(HttpClient::class), // resolved at resolution time
);
});
}
config('services.stripe.key') is called when the gateway is first resolved — not when the provider registers. $app->make(HttpClient::class) resolves the HTTP client at that same moment. The entire dependency graph builds lazily, on demand, starting from whatever you asked for.
That laziness is why register() exists separately from boot(). By the time boot() runs, all providers have registered their bindings. You can safely call $this->app->make() in boot() and get a fully configured service. In register(), other providers may not have run yet — call make() there and you might get an unbound class.
public function register(): void
{
// ✅ Safe — just registering a recipe, not resolving anything
$this->app->singleton(PaymentGateway::class, fn ($app) => new StripeGateway(
$app->make(HttpClient::class), // resolved later, when gateway is requested
));
}
public function boot(): void
{
// ✅ Safe — all providers have registered by now
$gateway = $this->app->make(PaymentGateway::class);
$gateway->setWebhookSecret(config('services.stripe.webhook_secret'));
}
The moment you internalize the deferred recipe model, the rest of the container’s behavior becomes predictable. Binding is writing a recipe. Resolution is cooking it. The container manages the cookbook.
A Real-World Pattern: The Strategy Container
One pattern that makes the container genuinely elegant is the strategy registry — a service that holds multiple implementations of the same interface and selects one at runtime based on a discriminator.
interface ShippingProvider
{
public function calculateRate(Shipment $shipment): Money;
public function book(Shipment $shipment): ShippingLabel;
public function supports(string $carrier): bool;
}
Without the container, you’d write a factory with a switch statement and manually instantiate each provider. With the container and tags:
// Register each provider
$this->app->singleton(FedExProvider::class);
$this->app->singleton(UpsProvider::class);
$this->app->singleton(DhlProvider::class);
$this->app->tag(
[FedExProvider::class, UpsProvider::class, DhlProvider::class],
'shipping-providers'
);
// Register the router
$this->app->singleton(ShippingRouter::class, function ($app) {
return new ShippingRouter($app->tagged('shipping-providers'));
});
class ShippingRouter
{
public function __construct(private readonly iterable $providers) {}
public function for(string $carrier): ShippingProvider
{
foreach ($this->providers as $provider) {
if ($provider->supports($carrier)) {
return $provider;
}
}
throw new UnsupportedCarrierException($carrier);
}
}
Adding a new carrier is one new class, one tag registration. The router, the controller, the order service — nothing changes. The container’s tag system absorbed the extension point.
Container Events
The container fires events when it resolves a class. You can hook into resolution to add behavior that runs every time a specific type is built — without modifying the class or its binding.
// Runs every time an Auditable instance is resolved
$this->app->resolving(Auditable::class, function ($object, $app) {
$object->setAuditLogger($app->make(AuditLogger::class));
});
// Runs after every resolution, regardless of type
$this->app->afterResolving(function ($object, $app) {
if ($object instanceof HasMiddleware) {
$object->applyMiddleware($app->make(MiddlewareStack::class));
}
});
resolving() fires before the resolved instance is returned. afterResolving() fires after. Both receive the instance and the container. Neither requires you to modify the class being resolved or the binding that built it.
This is how Laravel’s own internals work — the framework hooks into resolution to configure objects after they’re built, without coupling the configuration logic to the object’s constructor.
What You Actually Need to Know
The container has a lot of surface area. Most of it maps to a small set of situations:
You have a concrete class with injectable deps
→ No binding needed. Just type-hint it.
You have an interface
→ bind() or singleton() in a service provider.
Same interface, different implementations per context
→ when()->needs()->give() contextual binding.
Multiple implementations to collect and iterate
→ tag() them, resolve with tagged().
Need to decorate a third-party binding
→ extend() wraps the resolved instance.
Primitive constructor args
→ when()->needs('$param')->giveConfig() or give().
Singleton that should reset per request (Octane)
→ scoped() instead of singleton().
Runtime parameters the binding doesn't know at boot
→ makeWith() at resolution time.
The container isn’t magic. It’s a recursive, lazy dependency resolver with a few well-designed extension points. Once you have the mental model — abstract → closure → resolved instance, deferred until first request — you can read any service provider and understand exactly what it’s doing.
Most of the complexity in real applications doesn’t come from the container. It comes from not using the container correctly — concrete class dependencies, manual new calls buried in business logic, factories that duplicate what the container already handles. The developers who understand the container write less code. The ones who treat it as a magic box write more.
