Laravel Service Container Explained — Bindings, Resolution & Real-World Internals
- Auto-resolution via Reflection is powerful but carries overhead — every concrete class you resolve that isn't cached triggers a ReflectionClass inspection. Register singletons for expensive stateless services to pay that cost once.
- scoped() is not a fancy singleton — it's a request-scoped singleton that flushes between Octane requests and queue jobs. Choosing
singleton()for request-aware state is a data-leak waiting to happen in long-running processes. - Contextual binding removes conditional logic from business classes — instead of if/switch inside a service, you declare in one provider which implementation each consumer gets. This is the Open/Closed Principle made concrete.
- Auto-resolution: the container inspects constructor type-hints via ReflectionClass and recursively resolves every dependency
- Binding types: bind() (new every time), singleton() (once per container), scoped() (once per request/job)
- Contextual binding: same interface, different implementations per consumer class
- extend(): transparently wraps resolved instances with decorators (caching, logging, circuit-breaking)
- singleton() + request state = data leak between users in Octane
- Use scoped() for anything that touches per-request context
- Binding concrete classes to themselves instead of interfaces. Auto-resolution bypasses your binding, and test swaps silently fail.
Unresolvable dependency or class not found.
composer dump-autoloadphp artisan tinker --execute="app()->bound(App\Contracts\PaymentGatewayInterface::class)"Data leak between users under Octane.
grep -rn 'singleton' app/Providers/ | grep -v vendorgrep -rn '\$this->app->make.*Request\|Auth::' app/Services/Mock not injected in tests.
php artisan tinker --execute="app()->bound(App\Services\ConcreteService::class)"grep -rn 'ConcreteService' app/Http/Controllers/Cyclic dependency causing infinite recursion or timeout.
grep -rn 'function __construct' app/Services/ClassA.phpgrep -rn 'function __construct' app/Services/ClassB.phpContextual binding not working — wrong implementation injected.
php artisan tinker --execute="app()->make(App\Http\Controllers\OrderController::class)"grep -rn 'when.*needs.*give' app/Providers/Production Incident
singleton() to scoped(). Scoped bindings are flushed between Octane requests, ensuring a fresh instance per request.
2. Audited all singleton registrations for request-scoped state. Found 3 additional services holding Auth references that were also changed to scoped().
3. Added an Octane middleware that calls app()->forgetScopedInstances() at the start of each request as a safety net.
4. Added a test that resolves a scoped service, modifies its state, flushes scoped instances, resolves again, and asserts the state is clean.
5. Configured Octane's --max-requests flag to recycle workers every 500 requests as an additional safety layer.scoped().php artisan serve does not reproduce Octane lifecycle bugs. Always test under Octane before deploying.Audit all singleton registrations when adopting Octane. grep -r 'singleton' app/Providers/ and check each for request-scoped state.scoped() is not a performance downgrade — it rebuilds once per request, same as singleton under php artisan serve.Add --max-requests to Octane workers as a defense-in-depth measure. If a scoped binding is misconfigured, worker recycling limits the blast radius.Production Debug GuideSymptom-first investigation path for container resolution failures.
app()->when(Bar::class)->needs('apiKey')->give(config('services.api.key')).build(). Extract a shared interface, use lazy injection via app()->lazy(), or restructure so one class receives the dependency via a setter method instead of the constructor.scoped().register() and gets 'Class not found' errors.→register() runs before all providers have registered their bindings. Move resolution logic to boot(), which runs after all register() phases complete.Every non-trivial Laravel application eventually hits the same wall: classes that depend on other classes, that depend on yet more classes. Wire them all together by hand and you end up with constructor chains so deep they'd make a plumber wince. Swap one implementation — say, from Stripe to PayPal — and you're touching a dozen files.
The Service Container is Laravel's answer. Formally it's an IoC (Inversion of Control) container — a registry that knows how to build any object your application asks for, injecting its dependencies automatically. Instead of your code reaching out and constructing collaborators, the container pushes them in. That inversion is why testing becomes trivial: swap a real HTTP client for a fake one in two lines, no rewiring required.
By the end of this article you'll understand exactly how the container resolves classes, when to use bind vs singleton vs scoped, how contextual binding lets you serve different implementations to different consumers, and the performance and lifecycle gotchas that bite teams in production.
How the Container Actually Resolves a Class — Under the Hood
When you type-hint a dependency in a controller constructor, you're trusting the container to build it. But how? Laravel uses PHP's ReflectionClass API to inspect the constructor, discover each parameter's type-hint, recursively resolve each dependency, then instantiate the class with everything wired up. This happens automatically for any concrete class — no registration needed.
This auto-resolution ('autowiring') is why so much of Laravel 'just works'. The container walks the entire dependency graph. If OrderService needs PaymentProcessor, which needs HttpClient, which needs GuzzleClient — it resolves all of them in one call.app()->make(OrderService::class)
The cost is real though. Every reflection call carries overhead. For hot paths — middleware that runs on every request, for example — the container uses a resolved-instance cache ($this->resolved and $this->instances on the Illuminate\Container\Container class). Once a singleton is built, it's returned from memory on every subsequent resolution. Understanding this distinction between 'build every time' and 'build once' is what separates advanced container usage from beginner usage.
<?php // File: app/Services/CurrencyConverter.php namespace App\Services; class CurrencyConverter { private string $baseCurrency; public function __construct(string $baseCurrency = 'USD') { $this->baseCurrency = $baseCurrency; } public function convert(float $amount, string $targetCurrency): float { // Simplified — real implementation would call an API $rates = ['EUR' => 0.92, 'GBP' => 0.79, 'JPY' => 149.5]; return $amount * ($rates[$targetCurrency] ?? 1.0); } public function getBase(): string { return $this->baseCurrency; } } // File: app/Services/InvoiceService.php namespace App\Services; class InvoiceService { // Container sees this type-hint and auto-resolves CurrencyConverter public function __construct( private readonly CurrencyConverter $currencyConverter ) {} public function generateTotal(float $amountInUsd, string $clientCurrency): string { $converted = $this->currencyConverter->convert($amountInUsd, $clientCurrency); return number_format($converted, 2) . ' ' . $clientCurrency; } } // File: routes/web.php (or a tinker session) use App\Services\InvoiceService; // The container resolves InvoiceService AND CurrencyConverter automatically. // No manual `new InvoiceService(new CurrencyConverter())` needed. $invoiceService = app(InvoiceService::class); echo $invoiceService->generateTotal(500.00, 'EUR'); // 460.00 EUR echo PHP_EOL; echo $invoiceService->generateTotal(500.00, 'GBP'); // 395.00 GBP // Proving reflection is doing the work — the container inspects the constructor: $reflection = new ReflectionClass(InvoiceService::class); $params = $reflection->getConstructor()->getParameters(); foreach ($params as $param) { // Outputs the type-hint the container uses to resolve each dependency echo 'Dependency: ' . $param->getType()->getName() . PHP_EOL; }
395.00 GBP
Dependency: App\Services\CurrencyConverter
- ReflectionClass inspects the constructor at runtime — no compile-time wiring.
- Each parameter's type-hint triggers a recursive
make()call. - Resolved singletons are cached in $this->instances — subsequent calls skip reflection.
- Cyclic dependencies (A needs B, B needs A) cause infinite recursion with no guard.
- Auto-resolution works for any concrete class — no
bind()call needed.
bind vs singleton vs scoped — Choosing the Right Lifecycle
The binding method you choose controls how long an instance lives. Get this wrong in production and you'll either waste memory building objects repeatedly or — far worse — share mutable state between requests, causing subtle, impossible-to-reproduce bugs.
bind creates a new instance every single time the container resolves the abstract. Use it for stateful, request-specific objects where sharing would corrupt data — like a shopping cart builder that accumulates line items.
singleton builds the object once per container lifetime and caches it. In a standard HTTP request lifecycle that's effectively 'once per request'. But in a long-running process — Laravel Octane, queue workers, scheduled commands — singletons persist across multiple jobs or requests. This is where teams get burned.
scoped (introduced in Laravel 8) is the sweet spot for Octane: it behaves like a singleton within a single request or job, then gets flushed automatically when that lifecycle ends. It's the answer to 'I want singleton performance without the cross-request contamination'.
Contextual binding adds another dimension: the same interface can resolve to different concrete classes depending on which class is asking. An EmailNotifier and a SmsNotifier can both implement NotifierInterface, and you tell the container which to inject into which consumer — no if statements in business logic.
<?php // File: app/Providers/NotificationServiceProvider.php namespace App\Providers; use Illuminate\Support\ServiceProvider; use App\Contracts\NotifierInterface; use App\Contracts\ReportGeneratorInterface; use App\Services\EmailNotifier; use App\Services\SmsNotifier; use App\Services\PdfReportGenerator; use App\Services\CsvReportGenerator; use App\Http\Controllers\OrderController; use App\Http\Controllers\SupportController; use App\Jobs\WeeklyDigestJob; class NotificationServiceProvider extends ServiceProvider { public function register(): void { // BIND — fresh instance every resolution. // CartBuilder accumulates state, so sharing it between callers would corrupt totals. $this->app->bind( \App\Services\CartBuilder::class, fn ($app) => new \App\Services\CartBuilder( $app->make(\App\Repositories\ProductRepository::class) ) ); // SINGLETON — built once, reused for the container's lifetime. // DatabaseConnectionPool is expensive to initialise; we want one pool, not fifty. $this->app->singleton( \App\Services\DatabaseConnectionPool::class, fn ($app) => new \App\Services\DatabaseConnectionPool( config('database.connections.mysql') ) ); // SCOPED — singleton within a request/job, auto-flushed between them. // Perfect for Octane: AuthenticatedUser holds per-request identity. $this->app->scoped( \App\Services\AuthenticatedUser::class, fn ($app) => new \App\Services\AuthenticatedUser( $app->make(\Illuminate\Http\Request::class) ) ); // CONTEXTUAL BINDING — same interface, different implementations. // OrderController gets email notifications (customers expect email receipts). $this->app ->when(OrderController::class) ->needs(NotifierInterface::class) ->give(EmailNotifier::class); // SupportController gets SMS (support staff work from phones). $this->app ->when(SupportController::class) ->needs(NotifierInterface::class) ->give(SmsNotifier::class); // WeeklyDigestJob gets both — tagged binding returns a collection. $this->app->tag( [EmailNotifier::class, SmsNotifier::class], 'notifiers' ); // Bind the report generator with a closure for runtime config $this->app ->when(\App\Console\Commands\ExportCommand::class) ->needs(ReportGeneratorInterface::class) ->give(fn ($app) => new CsvReportGenerator( separator: config('exports.csv_separator', ','), includeHeaders: true )); } } // File: app/Http/Controllers/OrderController.php namespace App\Http\Controllers; use App\Contracts\NotifierInterface; class OrderController extends Controller { // Container automatically injects EmailNotifier here — no conditional logic needed public function __construct( private readonly NotifierInterface $notifier ) {} public function store(\Illuminate\Http\Request $request): \Illuminate\Http\JsonResponse { // ... order creation logic ... $this->notifier->send('Your order has been placed!'); return response()->json(['status' => 'created'], 201); } } // File: app/Http/Controllers/SupportController.php namespace App\Http\Controllers; use App\Contracts\NotifierInterface; class SupportController extends Controller { // Container automatically injects SmsNotifier here public function __construct( private readonly NotifierInterface $notifier ) {} public function escalate(int $ticketId): \Illuminate\Http\JsonResponse { $this->notifier->send("Ticket #{$ticketId} escalated to Level 2."); return response()->json(['escalated' => true]); } }
// SupportController resolution — container injects SmsNotifier
// No if/switch statements. No service locator pattern. Clean, testable, SOLID.
singleton and that class holds a reference to the Request object, you will serve one user's request data to another user in Octane. This is a data-leak vulnerability. Use scoped for anything that touches per-request state, or explicitly flush it in Octane's RequestHandled event listener.singleton() is per-process, not per-application — multi-process architectures (Horizon, Octane workers) each get their own copy. scoped() is the correct choice for any service that holds per-request state in Octane or per-job state in queue workers.Extending, Decorating & Resolving Events — Advanced Container Techniques
The container isn't just a factory — it's an event system. You can hook into the resolution lifecycle using , resolving()afterResolving(), and to decorate or mutate instances after they're built. This is how Laravel's own core adds behaviour without subclassing.extend()
extend() lets you wrap an already-registered binding. Every time the container builds the target, your closure receives the fresh instance and the container itself, and must return the final object. Use this to transparently wrap a service with a caching layer or a circuit breaker without touching the service's own code.
fires every time the container builds an instance of a type — even if it wasn't registered. This is perfect for initialisation work that shouldn't live in the constructor (setting a locale, attaching an observer, running a health check). resolving()afterResolving() fires after any resolving callbacks, giving you a post-init hook.
These hooks compose cleanly. A service can have multiple resolving callbacks registered by different service providers, and they all run in registration order. The pattern underpins how packages attach behaviour to your classes without requiring inheritance — it's open/closed principle in action.
<?php // File: app/Providers/AppServiceProvider.php namespace App\Providers; use Illuminate\Support\ServiceProvider; use App\Contracts\PaymentGatewayInterface; use App\Services\StripePaymentGateway; use App\Services\CachedPaymentGateway; use App\Services\ReportExporter; use Illuminate\Support\Facades\Log; class AppServiceProvider extends ServiceProvider { public function register(): void { // Register the real Stripe implementation as a singleton $this->app->singleton( PaymentGatewayInterface::class, fn ($app) => new StripePaymentGateway( apiKey: config('services.stripe.secret'), webhookSecret: config('services.stripe.webhook_secret') ) ); // EXTEND — transparently wrap StripePaymentGateway with a caching decorator. // Code that depends on PaymentGatewayInterface has no idea caching exists. $this->app->extend( PaymentGatewayInterface::class, function (PaymentGatewayInterface $gateway, $app) { // Wrap the resolved Stripe instance in our caching layer return new CachedPaymentGateway( inner: $gateway, // the real gateway cache: $app->make('cache.store'), // Laravel's cache ttlSeconds: 300 // cache fee lookups for 5 min ); } ); // RESOLVING callback — fires every time ReportExporter is built. // We set the timezone here because it comes from authenticated user context, // which isn't available at container-build time. $this->app->resolving( ReportExporter::class, function (ReportExporter $exporter, $app) { $userTimezone = $app->make('auth')->user()?->timezone ?? 'UTC'; // Configure the exporter with the authenticated user's timezone $exporter->setTimezone($userTimezone); } ); // AFTER RESOLVING — runs after all resolving() callbacks. // Useful for cross-cutting concerns like audit logging. $this->app->afterResolving( ReportExporter::class, function (ReportExporter $exporter, $app) { Log::debug('ReportExporter instantiated', [ 'timezone' => $exporter->getTimezone(), 'memory_peak_mb' => round(memory_get_peak_usage(true) / 1048576, 2), ]); } ); } } // File: app/Services/CachedPaymentGateway.php namespace App\Services; use App\Contracts\PaymentGatewayInterface; use Illuminate\Contracts\Cache\Repository as CacheRepository; class CachedPaymentGateway implements PaymentGatewayInterface { public function __construct( private readonly PaymentGatewayInterface $inner, private readonly CacheRepository $cache, private readonly int $ttlSeconds ) {} public function charge(float $amountInCents, string $currency, string $token): array { // Charges are never cached — idempotency must be handled at a higher level return $this->inner->charge($amountInCents, $currency, $token); } public function getFees(string $currency): array { $cacheKey = "payment_fees_{$currency}"; // Cache the fee schedule — this rarely changes and is expensive to fetch return $this->cache->remember( $cacheKey, $this->ttlSeconds, fn () => $this->inner->getFees($currency) ); } } // Anywhere in your app: $gateway = app(PaymentGatewayInterface::class); // $gateway is now a CachedPaymentGateway wrapping StripePaymentGateway. // Calling getFees() hits cache first — no Stripe API call if cached. $fees = $gateway->getFees('USD'); print_r($fees);
Array ( [percentage] => 2.9 [flat_cents] => 30 [currency] => USD )
// Second call within 300s — cache hit, no API call:
Array ( [percentage] => 2.9 [flat_cents] => 30 [currency] => USD )
// Debug log entry:
[2024-01-15 10:23:41] local.DEBUG: ReportExporter instantiated {"timezone":"America/New_York","memory_peak_mb":14.25}
- extend(): wraps an existing binding. Returns a new instance that delegates to the original.
- resolving(): fires on every resolution, even for unregistered classes. Good for init logic.
- afterResolving(): fires after all
resolving()callbacks. Good for audit logging. - Multiple
resolving()callbacks compose — they run in registration order. - extend() is the decorator pattern. Use it instead of inheritance for cross-cutting concerns.
extend() to wrap their PaymentGateway with a circuit breaker. The circuit breaker tracked failure counts in memory. Under Horizon with 4 worker processes, each process had its own circuit breaker instance with its own failure count. When Stripe had a partial outage, process 1 saw 5 failures and opened its circuit. But processes 2, 3, and 4 kept sending requests because their counters were at 0. The fix: store circuit breaker state in Redis (shared across processes) instead of in-memory. The decorator pattern via extend() was correct — the state storage was wrong.Testing With the Container — Swapping Real Services for Fakes
The container's real superpower only becomes obvious when you write tests. Because every dependency is injected rather than constructed internally, swapping a real implementation for a fake is a one-liner: . The container's app()->instance(PaymentGatewayInterface::class, $fakeGateway)instances array gets the fake, and every subsequent resolution returns it — for the rest of that test.
Laravel's inside a test also works, but app()->bind() is preferred when you already have a pre-built mock (common with PHPUnit's instance()createMock or Mockery). clears a specific singleton if you need to reset between sub-tests.app()->forgetInstance()
For Pest or PHPUnit feature tests, you can combine this with $this->swap() (a Laravel testing helper that calls instance and registers teardown cleanup automatically). This prevents test pollution — a critical concern when singletons persist across tests in a test suite that doesn't reboot the application between every test case.
The deeper point: the container makes your code testable by design, not by accident. Code that uses new inside a method is fundamentally harder to test — you can't intercept that construction. Container-resolved dependencies are always interceptable.SomeService()
<?php // File: tests/Feature/PaymentControllerTest.php namespace Tests\Feature; use Tests\TestCase; use App\Contracts\PaymentGatewayInterface; use App\Models\Order; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; class PaymentControllerTest extends TestCase { use RefreshDatabase; public function test_successful_charge_creates_order_and_returns_201(): void { // Arrange: build a mock that simulates a successful Stripe charge $fakeGateway = $this->createMock(PaymentGatewayInterface::class); $fakeGateway ->expects($this->once()) ->method('charge') ->with(4999, 'USD', 'tok_test_visa') // assert the right args are passed ->willReturn([ 'id' => 'ch_test_abc123', 'status' => 'succeeded', 'amount' => 4999, ]); // Swap the real gateway in the container with our fake. // $this->swap() registers cleanup so the real gateway is restored after this test. $this->swap(PaymentGatewayInterface::class, $fakeGateway); $user = User::factory()->create(); // Act: hit the endpoint as an authenticated user $response = $this->actingAs($user) ->postJson('/api/payments/charge', [ 'amount_cents' => 4999, 'currency' => 'USD', 'token' => 'tok_test_visa', ]); // Assert: correct HTTP status and database state $response->assertStatus(201) ->assertJsonFragment(['status' => 'succeeded']); $this->assertDatabaseHas('orders', [ 'user_id' => $user->id, 'stripe_charge_id' => 'ch_test_abc123', 'amount_cents' => 4999, ]); } public function test_failed_charge_returns_422_and_does_not_create_order(): void { $fakeGateway = $this->createMock(PaymentGatewayInterface::class); $fakeGateway ->method('charge') ->willThrowException( // Simulate a declined card from Stripe new \App\Exceptions\PaymentDeclinedException('Your card was declined.') ); $this->swap(PaymentGatewayInterface::class, $fakeGateway); $user = User::factory()->create(); $response = $this->actingAs($user) ->postJson('/api/payments/charge', [ 'amount_cents' => 9999, 'currency' => 'USD', 'token' => 'tok_test_declined', ]); $response->assertStatus(422) ->assertJsonFragment(['message' => 'Your card was declined.']); // Critical: no order should exist if payment failed $this->assertDatabaseMissing('orders', ['user_id' => $user->id]); } }
..
Time: 0.312s, Memory: 34.00 MB
OK (2 tests, 6 assertions)
- $this->swap(): sets the instance AND registers teardown cleanup. Preferred.
- app()->instance(): sets the instance but does NOT clean up. Causes test pollution.
- app()->forgetInstance(): manually clears a singleton between sub-tests.
- Always bind against interfaces, not concrete classes, for test swappability.
- Code that uses new
SomeService()inside a method is untestable. Container-resolved is always interceptable.
app()->instance() to swap in fakes. These 15 tests passed individually but 3 of them failed when run with the full suite. The issue: app()->instance() did not clean up after itself. Test #7 swapped in a fake PaymentGateway. Test #8 expected the real PaymentGateway but got the fake from test #7. The fix: replaced all app()->instance() calls with $this->swap(), which registers an after-test callback to flush the fake. All 200 tests passed consistently.app()->instance() in tests. swap() registers cleanup automatically; instance() causes test pollution that only manifests when tests run in specific order. Bind against interfaces, not concrete classes, to make swapping possible.Service Provider Lifecycle — register() vs boot() and Resolution Order
Service providers have two phases: register() and boot(). Understanding the difference is critical because putting resolution logic in the wrong phase causes 'Class not found' errors that only appear under certain provider load orders.
register() runs first, for all providers. This is where you bind things into the container. No other provider has registered yet, so you cannot safely call app()->make() for anything outside your own bindings.
boot() runs after ALL providers have completed their register() phase. This is where you use resolved services — configuring routes, registering event listeners, publishing assets, or calling app()->make() on dependencies provided by other providers.
The provider load order is determined by the providers array in config/app.php. Deferred providers (those implementing DeferrableProvider) only load when one of their provided services is actually requested. This is a performance optimization — a provider that only binds a rarely-used service should be deferred.
<?php // File: app/Providers/PaymentServiceProvider.php namespace App\Providers; use Illuminate\Support\ServiceProvider; use App\Contracts\PaymentGatewayInterface; use App\Services\StripePaymentGateway; class PaymentServiceProvider extends ServiceProvider { // DEFERRED — only loads when PaymentGatewayInterface is actually requested. // Reduces boot time for requests that don't involve payments. protected $defer = true; public function register(): void { // SAFE: register() is where bindings go. // No other provider needs to be loaded for this to work. $this->app->singleton( PaymentGatewayInterface::class, fn ($app) => new StripePaymentGateway( apiKey: config('services.stripe.secret'), webhookSecret: config('services.stripe.webhook_secret') ) ); } public function boot(): void { // SAFE: boot() runs after all register() phases. // We can now safely use other services. // Publish config file $this->publishes([ __DIR__.'/../../config/payment.php' => config_path('payment.php'), ], 'payment-config'); // Register a macro on the Response factory \Illuminate\Http\Response::macro('paymentError', function ($message, $code) { return response()->json(['error' => $message, 'code' => $code], 422); }); } public function provides(): array { // Required for deferred providers — tells the container which services this provides. return [PaymentGatewayInterface::class]; } }
// Requests that don't involve payments skip this provider entirely.
// Boot time reduction: ~15ms per deferred provider on a typical Laravel app.
app()->make(SomeService::class) inside register(), and SomeService depends on a binding registered by a provider that hasn't run yet, you get a 'Target is not instantiable' error. The error is intermittent — it depends on provider load order, which can change when you install new packages. Always put resolution logic in boot().app()->make(CacheInterface::class) in register() to configure a decorator. But CacheInterface was bound by the new package's provider, which hadn't run yet. The fix: move the decorator configuration to boot(), where all providers have completed registration. The intermittent nature (works locally, fails in production) was due to different provider load orders caused by different package discovery caches.boot() is for resolution. Never call app()->make() on external dependencies in register() — it creates a race condition with provider load order. Deferred providers reduce boot time but require a provides() method listing their services.Tagged Bindings & Service Locators — When Contextual Binding Isn't Enough
Contextual binding solves 'different implementation per consumer'. But sometimes a single consumer needs ALL implementations — a notification dispatcher that sends via email, SMS, and push simultaneously. Tagged bindings solve this.
$container->tag() groups multiple concrete classes under a tag name. $container->tagged() returns all of them as an array. This is cleaner than manually collecting implementations in a constructor and avoids the service locator anti-pattern where a class calls app()->make() for each implementation.
The key distinction from contextual binding: contextual binding gives ONE implementation to ONE consumer. Tagged binding gives ALL implementations to ONE consumer. Use contextual when the consumer needs a specific implementation. Use tagged when the consumer needs a collection of implementations.
<?php // File: app/Providers/NotificationServiceProvider.php namespace App\Providers; use Illuminate\Support\ServiceProvider; use App\Contracts\NotifierInterface; use App\Services\EmailNotifier; use App\Services\SmsNotifier; use App\Services\PushNotifier; use App\Services\NotificationDispatcher; class NotificationServiceProvider extends ServiceProvider { public function register(): void { // Tag all notifier implementations $this->app->tag( [EmailNotifier::class, SmsNotifier::class, PushNotifier::class], 'notifiers' ); // NotificationDispatcher receives ALL notifiers via tagged binding $this->app->bind( NotificationDispatcher::class, fn ($app) => new NotificationDispatcher( notifiers: $app->tagged('notifiers')->all() ) ); } } // File: app/Services/NotificationDispatcher.php namespace App\Services; use App\Contracts\NotifierInterface; use Illuminate\Support\Collection; class NotificationDispatcher { /** @param Collection<int, NotifierInterface> $notifiers */ public function __construct( private readonly Collection $notifiers ) {} public function dispatch(string $message, array $channels = []): void { $targets = $channels ? $this->notifiers->filter(fn ($n) => in_array($n->channel(), $channels)) : $this->notifiers; $targets->each(fn (NotifierInterface $notifier) => $notifier->send($message)); } }
$dispatcher->dispatch('Order shipped!');
// Email sent, SMS sent, Push sent.
// Dispatches to specific channels only:
$dispatcher->dispatch('Order shipped!', channels: ['email', 'push']);
// Email sent, Push sent. SMS skipped.
- Contextual: one interface, one implementation per consumer. Removes if/switch from business logic.
- Tagged: one tag, multiple implementations. Returns a collection to the consumer.
- Tagged bindings avoid the service locator anti-pattern (calling
app()->make() in business code). - Both compose: a consumer can have a contextual binding AND receive tagged bindings for other dependencies.
- Use tagged bindings for plugin architectures, notification channels, and middleware pipelines.
| Feature / Aspect | bind() | singleton() | scoped() |
|---|---|---|---|
| Instance lifetime | New instance every resolution | Once per container lifetime | Once per request/job, then flushed |
| Memory usage | Higher — multiple instances | Lowest — one instance ever | Low — flushed between requests |
| Shared state risk | None — isolated per resolution | High in long-running processes | None — Octane-safe |
| Use case | Stateful per-call objects (cart, form builder) | Stateless services (loggers, config) | Per-request state (auth context, tenant) |
| Octane safe? | Yes | Only if truly stateless | Yes — designed for Octane |
| Flush on request end? | N/A — no caching | No — persists across requests | Yes — automatically flushed |
| Performance | Slowest — rebuilds each time | Fastest after first build | Fast — rebuilds only per request |
| Test pollution risk | None — fresh every time | High — singleton persists across tests | Low — flushed between requests |
| Queue worker safe? | Yes | Only if stateless | Yes — flushed between jobs |
🎯 Key Takeaways
- Auto-resolution via Reflection is powerful but carries overhead — every concrete class you resolve that isn't cached triggers a ReflectionClass inspection. Register singletons for expensive stateless services to pay that cost once.
- scoped() is not a fancy singleton — it's a request-scoped singleton that flushes between Octane requests and queue jobs. Choosing
singleton()for request-aware state is a data-leak waiting to happen in long-running processes. - Contextual binding removes conditional logic from business classes — instead of if/switch inside a service, you declare in one provider which implementation each consumer gets. This is the Open/Closed Principle made concrete.
- The container's
extend()method lets you decorate any binding with caching, logging, or circuit-breaking without touching the original class or its callers — this is how you add cross-cutting concerns without violating SRP. - register() is for bindings,
boot()is for resolution. Never resolve external dependencies inregister()— it creates a race condition with provider load order. - Always use $this->swap() instead of
app()->instance() in tests.swap()handles cleanup;instance()causes silent test pollution. - Bind against interfaces, not concrete classes. Auto-resolution bypasses bindings for concrete type-hints, making test swaps silently fail.
- Tagged bindings give consumers a collection of all implementations. Validate that all implementations are tagged — missing a tag is a silent failure.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is the difference between
singleton()andscoped()in Laravel's service container, and why does that distinction matter when running under Laravel Octane? - QHow would you swap a real third-party API client for a fake implementation during feature tests without modifying the controller or service class that uses it?
- QIf two different controllers both type-hint the same interface but need different concrete implementations injected, how would you configure the container to handle that — and what's the feature called?
- QExplain the difference between
register()andboot()in a service provider. What happens if you resolve a dependency inregister()? - QHow does the container handle cyclic dependencies? What error do you get and how do you fix it?
- QWhat is the difference between
extend()andresolving()hooks? When would you use each? - QHow do tagged bindings differ from contextual binding? Give a scenario where you would use each.
- QWhy is binding against interfaces instead of concrete classes important for testability?
- QHow does auto-resolution (autowiring) work under the hood? What PHP API does the container use?
- QA queue worker processes 10,000 jobs per hour. A singleton service holds an in-memory counter. What happens to that counter across multiple Horizon worker processes?
Frequently Asked Questions
What is the Laravel service container and why do I need it?
The service container is Laravel's IoC (Inversion of Control) registry — it builds objects and injects their dependencies automatically. You need it because manually constructing dependency chains is brittle, makes testing painful, and makes swapping implementations (e.g. from one payment gateway to another) require touching many files. The container centralises all of that wiring in one place.
When should I register a binding in a service provider vs just using auto-resolution?
Use auto-resolution (no registration) for concrete classes with no special construction logic. Register a binding when you need to: bind an interface to a concrete class, pass constructor arguments that come from config or other services, control the lifetime (singleton/scoped), or use contextual/tagged bindings. If you're writing new in a constructor, that's a sign you should be using the container instead.ClassName()
Does the service container slow down my Laravel application?
The reflection-based resolution does carry a small overhead, but it's negligible for most bindings because resolved singletons are cached in memory. The real performance concern is building expensive objects (DB connections, HTTP clients) repeatedly with bind() when singleton() would suffice. Profile with Laravel Telescope or Clockwork — if you see the same class being built hundreds of times per request, switch it to a singleton or scoped binding.
How do I handle cyclic dependencies in the service container?
The container does not detect cycles — it enters infinite recursion in build() until PHP hits the memory limit or max nesting level. The fix: extract a shared interface that both classes depend on, use setter injection for one direction of the dependency, or introduce a mediator class that both classes depend on instead of depending on each other directly.
What is the difference between extend() and decorating a class manually?
extend() is transparent — every caller that type-hints the interface gets the decorated instance without any code change. Manual decoration requires changing the binding closure to return the decorated instance, which works but is less composable. extend() also composes: multiple extend() calls stack as nested decorators. The order matters — the last extend() call is the outermost wrapper.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.