Advanced 5 min · March 06, 2026

Laravel Singleton Under Octane — User Data Leak & Fix

User A's cart leaked to User B under Laravel Octane - singleton held auth state.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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.
Plain-English First

Imagine a huge restaurant kitchen. When a waiter needs a dish, they don't go hunting for ingredients, cook the meal, and plate it themselves — they just call out the order and the kitchen hands it over, perfectly prepared. The Laravel Service Container is that kitchen. Your code says 'I need a PaymentGateway' and the container builds it, with all its dependencies already assembled, no manual construction needed.

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 app()->make(OrderService::class) call.

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.

ContainerResolutionDemo.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<?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;
}
Output
460.00 EUR
395.00 GBP
Dependency: App\Services\CurrencyConverter
Internals Detail: The build() Loop
  • 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.
Production Insight
A team had a service with 12 constructor dependencies, each with their own sub-dependencies. The first resolution of this service triggered 47 reflection calls. Under load testing, this single service accounted for 3ms of the 8ms request overhead. The fix: register it as a singleton so reflection runs once, then all subsequent requests get the cached instance. The 3ms overhead dropped to 0ms on requests 2 through 10,000.
Key Takeaway
Auto-resolution via reflection is powerful but carries overhead per resolution. Register expensive-to-build services as singletons to pay the reflection cost once. For hot-path services (middleware, per-request handlers), this is not optional — it is a performance requirement.

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.

ServiceProviderBindings.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
<?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]);
    }
}
Output
// OrderController resolution — container injects EmailNotifier
// SupportController resolution — container injects SmsNotifier
// No if/switch statements. No service locator pattern. Clean, testable, SOLID.
Octane Gotcha: singleton + Request = Data Leak
  • bind(): new instance every resolution. Safe but slower. Use for stateful per-call objects.
  • singleton(): one instance per container. Fast but dangerous in Octane if stateful.
  • scoped(): one instance per request/job. Auto-flushed. The correct default for Octane.
  • Contextual binding: same interface, different implementation per consumer class.
  • Tagged binding: group multiple implementations, resolve as a collection.
Production Insight
A queue worker processed 10,000 jobs per hour. A singleton service held a rate-limiter counter that tracked API calls. Under php artisan queue:work, the singleton persisted across all 10,000 jobs — the counter accumulated correctly. But when the team switched to Horizon with multiple worker processes, each process had its own singleton instance. The counter was split across 4 processes, each counting independently. Rate limiting was 4x too lenient. The fix: store rate-limiter state in Redis (shared across processes) instead of in-memory singleton state. The lesson: singleton state is per-process, not per-application. In multi-process architectures, in-memory singletons are invisible to sibling processes.
Key Takeaway
The binding lifecycle determines instance lifetime and state isolation. 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 extend() to decorate or mutate instances after they're built. This is how Laravel's own core adds behaviour without subclassing.

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.

resolving() 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). 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.

ContainerDecoratorAndEvents.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
<?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);
Output
// First call — cache miss, hits Stripe API:
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}
Pro Tip: Decorator Pattern via extend()
  • 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.
Production Insight
A team used 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.
Key Takeaway
extend() is the correct pattern for adding cross-cutting concerns without modifying original classes. But decorator state must be stored in a shared backend (Redis, database) if your application runs across multiple processes. In-memory decorator state is per-process and invisible to sibling workers.

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: app()->instance(PaymentGatewayInterface::class, $fakeGateway). The container's instances array gets the fake, and every subsequent resolution returns it — for the rest of that test.

Laravel's app()->bind() inside a test also works, but instance() is preferred when you already have a pre-built mock (common with PHPUnit's createMock or Mockery). app()->forgetInstance() clears a specific singleton if you need to reset between sub-tests.

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 SomeService() inside a method is fundamentally harder to test — you can't intercept that construction. Container-resolved dependencies are always interceptable.

PaymentControllerTest.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<?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]);
    }
}
Output
PHPUnit 10.5.0
..
Time: 0.312s, Memory: 34.00 MB
OK (2 tests, 6 assertions)
Interview Gold: $this->swap() vs app()->instance()
  • $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.
Production Insight
A team had 200 feature tests. 15 tests used 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.
Key Takeaway
Always use $this->swap() instead of 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.

ServiceProviderLifecycleDemo.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?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];
    }
}
Output
// Deferred provider loads only when PaymentGatewayInterface is resolved.
// Requests that don't involve payments skip this provider entirely.
// Boot time reduction: ~15ms per deferred provider on a typical Laravel app.
Watch Out: Resolution in register() Causes Race Conditions
  • register(): bind things. Do NOT resolve things from other providers.
  • boot(): use things. All providers have registered by this point.
  • Deferred providers: only load when their services are requested. Use for performance.
  • provides(): required for deferred providers. Lists the services this provider offers.
  • Provider order in config/app.php determines load order. Last provider wins for conflicting bindings.
Production Insight
A team installed a new package that registered a service provider. Their app broke with 'Target [App\Contracts\CacheInterface] is not instantiable'. The error only appeared on production — local dev worked fine. Root cause: the new package's provider was loaded before the app's CacheServiceProvider. The app's provider called 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.
Key Takeaway
register() is for bindings, 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.

TaggedBindingsDemo.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?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));
    }
}
Output
// Dispatches to ALL channels:
$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 vs Tagged: The Decision
  • 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.
Production Insight
A team built a plugin system where each plugin registered itself via a tagged binding. They had 12 plugins. When a new developer added a 13th plugin but forgot to add it to the tag array, the plugin silently never executed. No error, no warning — the plugin just didn't run. The fix: created an Artisan command that scans for all classes implementing PluginInterface and compares them against the tagged binding array. If any implementation is missing from the tag, the command fails with a clear error. This runs in CI on every push.
Key Takeaway
Tagged bindings give consumers a collection of all implementations. Use them for plugin architectures and multi-channel dispatchers. Always validate that all implementations are tagged — missing a tag is a silent failure with no error.
● Production incidentPOST-MORTEMseverity: high

User A's Cart Data Returned to User B Under Laravel Octane

Symptom
Users reported seeing items in their cart that they did not add. Some users saw cart totals that did not match their actual selections. Support tickets escalated: 'I can see someone else's address in my checkout form.' The issue only appeared under production load — local development with php artisan serve never reproduced it.
Assumption
A caching bug was serving stale cart data from Redis. The team spent 2 days investigating cache key collisions and TTL issues.
Root cause
The CartService was registered as a singleton in AppServiceProvider. It held a $userId property set during construction via Auth::id(). Under php artisan serve (one request per process), this worked fine — the singleton was rebuilt on every request. Under Octane (one process handling thousands of requests), the singleton was built once and reused. When User A made a request, $userId was set to User A's ID. When User B's request hit the same Octane worker, the singleton was not rebuilt — it still held User A's $userId. Every cart operation for User B operated on User A's cart.
Fix
1. Changed CartService registration from 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.
Key lesson
  • singleton() persists across requests in Octane. Any service holding per-request state (auth, cart, tenant, locale) MUST use 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.6 entries
Symptom · 01
Target class [App\Services\Foo] does not exist.
Fix
The container cannot find the class. Check: namespace matches file path exactly, class is not in a directory excluded from composer autoload, and the class file is not missing from version control.
Symptom · 02
Unresolvable dependency resolving [Parameter #0] in class App\Services\Bar.
Fix
A constructor parameter has no type-hint or the type-hinted class/interface has no binding. Check: is the parameter a primitive (string, int) without a default value? If so, bind it explicitly: app()->when(Bar::class)->needs('apiKey')->give(config('services.api.key')).
Symptom · 03
Circular dependency detected between ClassA and ClassB.
Fix
ClassA depends on ClassB which depends on ClassA. The container enters an infinite recursion in 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.
Symptom · 04
Test mock is not being injected — real class still resolves.
Fix
You bound the concrete class but the type-hint uses the concrete class name. Auto-resolution bypasses bindings for concrete classes. Fix: type-hint the interface, bind the interface to the concrete class, then swap the interface in tests.
Symptom · 05
Data from one user appears in another user's session under Octane.
Fix
A singleton holds per-request state. Find it: grep -rn 'singleton' app/Providers/ and check each resolved class for Auth, Request, or session references. Change to scoped().
Symptom · 06
Service provider resolves dependencies in register() and gets 'Class not found' errors.
Fix
register() runs before all providers have registered their bindings. Move resolution logic to boot(), which runs after all register() phases complete.
★ Service Container Triage CommandsRapid commands to isolate container resolution and lifecycle issues.
Unresolvable dependency or class not found.
Immediate action
Check autoload and binding registration.
Commands
composer dump-autoload
php artisan tinker --execute="app()->bound(App\Contracts\PaymentGatewayInterface::class)"
Fix now
If autoload is stale, dump-autoload fixes it. If the interface is not bound, register it in a ServiceProvider. If binding exists but resolution fails, check the closure for errors.
Data leak between users under Octane.+
Immediate action
Audit singletons for request-scoped state.
Commands
grep -rn 'singleton' app/Providers/ | grep -v vendor
grep -rn '\$this->app->make.*Request\|Auth::' app/Services/
Fix now
Change any singleton that holds Auth, Request, or session state to scoped(). Add app()->forgetScopedInstances() to a middleware as a safety net.
Mock not injected in tests.+
Immediate action
Verify binding target is an interface, not a concrete class.
Commands
php artisan tinker --execute="app()->bound(App\Services\ConcreteService::class)"
grep -rn 'ConcreteService' app/Http/Controllers/
Fix now
If controllers type-hint the concrete class, auto-resolution bypasses your binding. Refactor to type-hint an interface, bind the interface, then swap the interface in tests.
Cyclic dependency causing infinite recursion or timeout.+
Immediate action
Identify the circular chain.
Commands
grep -rn 'function __construct' app/Services/ClassA.php
grep -rn 'function __construct' app/Services/ClassB.php
Fix now
Extract a shared interface, use setter injection for one direction, or introduce a mediator/service locator for the cyclic dependency.
Contextual binding not working — wrong implementation injected.+
Immediate action
Verify the when() target matches the class name exactly.
Commands
php artisan tinker --execute="app()->make(App\Http\Controllers\OrderController::class)"
grep -rn 'when.*needs.*give' app/Providers/
Fix now
Ensure the when() uses the fully qualified class name. Contextual bindings are matched on the class name string — a namespace mismatch silently fails.
Binding Lifecycles Compared
Feature / Aspectbind()singleton()scoped()
Instance lifetimeNew instance every resolutionOnce per container lifetimeOnce per request/job, then flushed
Memory usageHigher — multiple instancesLowest — one instance everLow — flushed between requests
Shared state riskNone — isolated per resolutionHigh in long-running processesNone — Octane-safe
Use caseStateful per-call objects (cart, form builder)Stateless services (loggers, config)Per-request state (auth context, tenant)
Octane safe?YesOnly if truly statelessYes — designed for Octane
Flush on request end?N/A — no cachingNo — persists across requestsYes — automatically flushed
PerformanceSlowest — rebuilds each timeFastest after first buildFast — rebuilds only per request
Test pollution riskNone — fresh every timeHigh — singleton persists across testsLow — flushed between requests
Queue worker safe?YesOnly if statelessYes — flushed between jobs

Key takeaways

1
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.
2
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.
3
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.
4
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.
5
register() is for bindings, boot() is for resolution. Never resolve external dependencies in register()
it creates a race condition with provider load order.
6
Always use $this->swap() instead of app()->instance() in tests. swap() handles cleanup; instance() causes silent test pollution.
7
Bind against interfaces, not concrete classes. Auto-resolution bypasses bindings for concrete type-hints, making test swaps silently fail.
8
Tagged bindings give consumers a collection of all implementations. Validate that all implementations are tagged
missing a tag is a silent failure.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the Laravel service container and why do I need it?
02
When should I register a binding in a service provider vs just using auto-resolution?
03
Does the service container slow down my Laravel application?
04
How do I handle cyclic dependencies in the service container?
05
What is the difference between extend() and decorating a class manually?
🔥

That's Laravel. Mark it forged?

5 min read · try the examples if you haven't

Previous
Laravel Queues and Jobs
11 / 15 · Laravel
Next
Laravel Events and Listeners