The Laravel service container is the object graph behind every controller, job, and command you write. When a class asks for a dependency in its constructor, the container decides what to build, when to reuse it, and which concrete class satisfies an interface. Get the bindings right and your code becomes testable and explicit; get them wrong and you get silent misresolution, duplicated connections, and tests that pass locally but fail in production. This guide covers binding types, interface resolution, contextual binding, and the debugging commands that tell you what the container actually resolved.
How does the Laravel service container resolve dependencies?
Resolution is recursive. When you call app(ReportGenerator::class), the container reflects on the constructor, resolves each type-hinted parameter in turn, and instantiates the class. Scalar parameters without defaults throw a BindingResolutionException, which is why config values should be injected as typed objects or read inside the class.
Two resolution paths exist: explicit bindings registered in a service provider, and zero-config auto-wiring for concrete classes. Auto-wiring is fine for stateless helpers. Anything that touches I/O, external APIs, or shared state should be bound explicitly so tests can swap it.
Laravel checks for an explicit binding first, then falls back to reflection-based auto-wiring. Interfaces have no auto-wiring path, so an unbound interface always fails at resolution time, not at boot time.
In production systems I've tuned, the most common container bug is an interface type-hint that was never bound in a provider. The failure surfaces only when that code path executes, which is why the php artisan about and container inspection commands matter during review.
Laravel dependency injection: bindings that actually matter
Four registration styles cover nearly every case. Choose deliberately, because each has different lifetime semantics.
| Method | Lifetime | Use when |
|---|---|---|
bind() | New instance per resolution | Stateless services, cheap objects |
singleton() | One instance per request/process | Connection pools, clients with handshakes |
scoped() | One instance per request or job | Per-request state under Octane |
instance() | Pre-built object | Tests, config-derived values |
scoped() is the correct choice for anything holding request state when running under Laravel Octane, because a plain singleton survives across requests in a long-lived worker and leaks state between users. That distinction is the difference between a stable Octane deployment and intermittent cross-request contamination.
namespace App\Providers;
use App\Services\ExchangeRateClient;
use App\Contracts\RateProvider;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ExchangeRateClient::class, function ($app) {
return new ExchangeRateClient(
baseUrl: config('services.rates.url'),
timeout: config('services.rates.timeout', 3),
);
});
$this->app->scoped(RequestContext::class);
}
}
Never resolve a singleton inside a constructor if it depends on the current request. Inject a factory closure or resolve it lazily with app() at call time, otherwise you capture the first request's state forever.
Laravel interface binding and testability
Type-hint interfaces, not concrete classes. This is the single change that makes a codebase testable without mocking internals.
namespace App\Contracts;
interface RateProvider
{
public function rateFor(string $base, string $quote): float;
}
// In AppServiceProvider::register()
$this->app->bind(RateProvider::class, ExchangeRateClient::class);
Any class that type-hints RateProvider now receives the bound implementation. In a test, rebind it to a fake and the consumer never changes:
public function test_invoice_uses_provider_rate(): void
{
$this->app->instance(RateProvider::class, new FakeRateProvider(rate: 110.5));
$invoice = app(InvoiceBuilder::class)->build(orderId: 42);
$this->assertSame(110.5, $invoice->rate);
}
Bind the interface in a dedicated provider rather than AppServiceProvider once you have more than a handful of bindings. A DomainServiceProvider keeps registration order readable and makes it obvious which module owns which contract. The Laravel container documentation covers the full binding API if you need the rarer variants.
Laravel contextual binding for shared interfaces
One interface, two implementations, different consumers. Contextual binding solves this without factories or conditionals at the call site.
$this->app->when(ReportExporter::class)
->needs(RateProvider::class)
->give(fn () => new CachedRateProvider(
inner: new ExchangeRateClient(config('services.rates.url')),
cache: app('cache.store'),
));
$this->app->when(InvoiceBuilder::class)
->needs(RateProvider::class)
->give(ExchangeRateClient::class);
Contextual bindings are evaluated before the global interface binding, so they take precedence for the listed consumers only. Keep the list short: once five classes need five different implementations, the interface is doing too much and should be split. Contextual binding is a precision tool, not an architecture.
The Laravel service provider is where wiring lives
Providers run in two phases. register() must only bind things into the container and must never resolve other services, because the provider you depend on may not have run yet. boot() runs after every provider has registered, so it is the correct place for event listeners, route model bindings, and anything that calls app().
| Phase | Safe to do | Never do |
|---|---|---|
register() | Bindings, aliases, singletons | Resolve services, hit the database |
boot() | Listeners, macros, view composers | Heavy I/O on every request |
Deferred providers implement provides() and are only loaded when one of their bindings is requested. For a provider with several bindings that most requests never touch, deferring it removes boot cost from every request. Verify the effect with php artisan about rather than assuming.
Debugging what the container resolved
When resolution surprises you, inspect it instead of guessing. Tinker gives you the resolved class and its dependencies directly:
php artisan tinker
>>> app(App\Contracts\RateProvider::class)::class;
=> "App\Services\ExchangeRateClient"
>>> app()->bound(App\Contracts\RateProvider::class);
=> true
For a full dump of every registered binding, use php artisan about to confirm environment and cache state, then clear a stale compiled container with php artisan optimize:clear. A cached container from a previous deploy is a frequent cause of "my new binding is ignored" reports. For queue workers and Octane, remember that the container is built once per worker process, so restart workers after changing providers.
Add a container smoke test that resolves every interface your app binds. It fails fast in CI when someone adds a type-hint without a matching binding, instead of failing at 2am in a job.
FAQ
What is the difference between bind and singleton?
bind() returns a new instance on every resolution. singleton() builds once and returns the same instance for the rest of the request or worker lifetime. Use singletons only for objects that are safe to share.
Why does resolving an interface throw BindingResolutionException?
Interfaces cannot be auto-wired because PHP reflection cannot instantiate them. Register a binding from the interface to a concrete class in a service provider, then resolution succeeds.
Can I use contextual binding with closures?
Yes. give() accepts a class name, an instance, or a closure. Closures let you build the implementation with its own dependencies while still scoping it to one consumer.
Does the container cache bindings in production?
php artisan config:cache and route:cache do not cache container bindings, but compiled service manifests affect deferred providers. Run php artisan optimize:clear after changing provider registration.
Related reading: Laravel queue best practices for how container lifetimes interact with workers, and fixing Eloquent N+1 queries for the data layer these services wrap. More Laravel and PostgreSQL deep dives live on the blog index.
