Skip to content
Home PHP Laravel Service Container Explained — Bindings, Resolution & Real-World Internals

Laravel Service Container Explained — Bindings, Resolution & Real-World Internals

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Laravel → Topic 11 of 15
Laravel Service Container deep-dive: how bindings, singletons, contextual binding, and auto-resolution work under the hood, with production gotchas and interview prep.
🔥 Advanced — solid PHP foundation required
In this tutorial, you'll learn
Laravel Service Container deep-dive: how bindings, singletons, contextual binding, and auto-resolution work under the hood, with production gotchas and interview prep.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
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.
🚨 START HERE
Service Container Triage Commands
Rapid commands to isolate container resolution and lifecycle issues.
🟡Unresolvable dependency or class not found.
Immediate ActionCheck autoload and binding registration.
Commands
composer dump-autoload
php artisan tinker --execute="app()->bound(App\Contracts\PaymentGatewayInterface::class)"
Fix NowIf 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 ActionAudit singletons for request-scoped state.
Commands
grep -rn 'singleton' app/Providers/ | grep -v vendor
grep -rn '\$this->app->make.*Request\|Auth::' app/Services/
Fix NowChange 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 ActionVerify 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 NowIf 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 ActionIdentify the circular chain.
Commands
grep -rn 'function __construct' app/Services/ClassA.php
grep -rn 'function __construct' app/Services/ClassB.php
Fix NowExtract 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 ActionVerify 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 NowEnsure the when() uses the fully qualified class name. Contextual bindings are matched on the class name string — a namespace mismatch silently fails.
Production IncidentUser A's Cart Data Returned to User B Under Laravel OctaneAn e-commerce platform migrated to Laravel Octane for 3x throughput. Within 24 hours, customer support received 43 complaints about users seeing other customers' cart contents. The CartService was registered as a singleton and held a reference to the authenticated user's ID. Under Octane, singletons persist across requests — User A's cart data was served to User B.
SymptomUsers 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.
AssumptionA caching bug was serving stale cart data from Redis. The team spent 2 days investigating cache key collisions and TTL issues.
Root causeThe 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.
Fix1. 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.
Target class [App\Services\Foo] does not exist.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.
Unresolvable dependency resolving [Parameter #0] in class App\Services\Bar.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')).
Circular dependency detected between ClassA and ClassB.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.
Test mock is not being injected — real class still resolves.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.
Data from one user appears in another user's session under Octane.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().
Service provider resolves dependencies in 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 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.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
<?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.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
<?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
If you register a class with 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.
📊 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.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
<?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.php · PHP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
<?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.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
<?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
If you call 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().
📊 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.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
<?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.
Mental Model
Contextual vs Tagged: The Decision
If you find yourself writing a switch statement to pick an implementation, you need contextual binding. If you find yourself looping over implementations, you need tagged binding.
  • 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.
🗂 Binding Lifecycles Compared
Choosing the right lifecycle for your service binding.
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

  • 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 in register() — 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

    Registering a stateful class with singleton() in an Octane/queue environment
    Symptom

    User A's data bleeds into User B's request; authentication returns wrong user; tests pass locally but prod has ghost-data bugs —

    Fix

    Use scoped() for anything that touches per-request state (auth, tenant, locale). Audit all singletons when adding Octane by running php artisan octane:install and checking the generated stubs for guidance.

    Resolving dependencies inside the register() method of a service provider instead of boot()
    Symptom

    'Class not found' or 'Target is not instantiable' errors because the dependency hasn't been registered yet; error only appears in certain provider load orders —

    Fix

    Put registration logic (bind, singleton) in register(). Put any code that uses resolved services or calls app()->make() in boot(). The register() phase runs before any container resolution is safe to perform.

    Binding a concrete class to itself without a reason, then wondering why swapping in tests doesn't work
    Symptom

    app()->instance(ConcreteService::class, $mock) seems to work but the real class still gets injected; mock assertions never fire —

    Fix

    Always bind against an interface (PaymentGatewayInterface::class), not a concrete class. If you bind concrete-to-concrete, type-hinting the concrete in a constructor bypasses your binding because auto-resolution takes precedence. Interfaces are the seam that makes swapping possible.

    Using app()->instance() in tests instead of $this->swap()
    Symptom

    Tests pass individually but fail when run with the full suite; a fake from test 7 bleeds into test 8 —

    Fix

    $this->swap() registers automatic teardown cleanup. app()->instance() does not clean up. Replace all app()->instance() calls in tests with $this->swap() to prevent test pollution.

    Storing in-memory state in services decorated via extend() in multi-process architectures
    Symptom

    Circuit breaker counters, rate limiters, or metrics are per-process, not per-application; sibling workers have independent state that doesn't coordinate —

    Fix

    Store decorator state in a shared backend (Redis, database) instead of in-memory. In-memory state is per-process and invisible to sibling workers in Horizon or Octane.

    Forgetting to add provides() on deferred providers
    Symptom

    The deferred provider never loads, and the binding is never registered; resolution fails with 'Target is not instantiable' —

    Fix

    Deferred providers MUST implement provides() returning an array of service names they bind. Without provides(), the container does not know which provider to load when a service is requested.

    Tagging implementations but forgetting to include a new implementation in the tag array
    Symptom

    New plugin or channel silently never executes; no error, no warning —

    Fix

    Create a validation command that scans for all implementations of an interface and compares against the tagged binding array. Run it in CI.

Interview Questions on This Topic

  • QWhat is the difference between singleton() and scoped() 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() and boot() in a service provider. What happens if you resolve a dependency in register()?
  • QHow does the container handle cyclic dependencies? What error do you get and how do you fix it?
  • QWhat is the difference between extend() and resolving() 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 ClassName() in a constructor, that's a sign you should be using the container instead.

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.

🔥
Naren Founder & Author

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.

← PreviousLaravel Queues and JobsNext →Laravel Events and Listeners
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged