Introduction

Laravel’s Service Container is the quiet engine that wires your app together. It handles Dependency Injection, auto‑wiring, and the little decisions that make big apps feel simple. In this article, we’ll demystify how it resolves classes, how to register bindings the right way, how to pick different implementations per context, and how to test and debug your application with confidence.

At its core, the container maps “I need X” to “here’s how to build X.” That’s it. Once you get that mental model, everything else clicks into place.

The Big Picture

Inversion of Control flips the usual “new up everything yourself” approach. Instead of classes constructing their own dependencies, they ask for them. Dependency Injection is simply how those dependencies are provided. Laravel’s Service Container is the glue: it knows how to build objects, how long they should live, and which implementation to hand out in each scenario.

Think of the container as a smart factory plus a registry. You say “I need X,” it answers with “here is X, built correctly.” If you give it clear type hints and sensible bindings, it does the heavy lifting for you. Here’s a tiny and realistic example to anchor the idea:

// PaymentGateway.php
interface PaymentGateway
{
    public function charge(int $amountInCents, string $currency): string;
}

// HttpClient.php
interface HttpClient
{
    public function post(string $url, array $payload): array;
}

// BasicHttpClient.php
final class BasicHttpClient implements HttpClient
{
    public function post(string $url, array $payload): array
    {
        // Execute HTTP request
    }
}

// StripeGateway.php
final class StripeGateway implements PaymentGateway
{
    public function __construct(
        private HttpClient $http,
        private string $apiKey, // primitive dependency
    ) {}

    public function charge(int $amountInCents, string $currency): string
    {
        $response = $this->http->post(
            'https://api.stripe.example/charge',
            [
                'amount' => $amountInCents,
                'currency' => $currency,
                'key' => $this->apiKey,
            ],
        );

        return $response['id'] ?? 'unknown';
    }
}

// AppServiceProvider.php
final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(HttpClient::class, BasicHttpClient::class);
        $this->app->bind(PaymentGateway::class, StripeGateway::class);

        $this->app->when(StripeGateway::class)
            ->needs('$apiKey')
            ->give(fn () => config('services.stripe.key'));
    }
}

// CheckoutController.php
final class CheckoutController
{
    public function __construct(private PaymentGateway $gateway) {}

    public function __invoke(): string
    {
        // $gateway is auto‑resolved by the container
        return $this->gateway->charge(2499, 'USD');
    }
}

What just happened when Laravel resolved CheckoutController in a simple way was:

  • It saw a controller class and asked the container to build it.
  • It used the constructor’s type hints to resolve dependencies.
  • For PaymentGateway, it found a binding to StripeGateway.
  • To build StripeGateway, it recursively resolved HttpClient (bound to BasicHttpClient) and the $apiKey primitive (provided via the contextual binding).
  • It returned the fully constructed controller with everything ready to go.

You could trigger the same process manually:

$gateway = app()->make(PaymentGateway::class);
$chargeId = $gateway->charge(1299, 'EUR');

// Override a specific argument at resolve time
$gatewayWithSandboxKey = app()->makeWith(
    PaymentGateway::class,
    ['$apiKey' => 'sandbox-test-key']
);

If something isn’t bound, the container still tries to help. For concrete classes (not interfaces), it can use reflection to auto‑wire nested dependencies. For interfaces and primitives, it needs your guidance via a binding or contextual rule. When resolution fails, the error message usually tells you which parameter couldn’t be resolved. If you bind that interface or supply that primitive, everything is back on track.

Two practical tips to keep resolution smooth within your applications:

Keep constructors explicit and small. Clear type hints make the container prevent surprises during resolution. If you need runtime values (like an API key), prefer contextual bindings or config calls inside closures rather than hard‑coding.

Avoid using new in your app code. If you manually instantiate deep dependency graphs, you lose the container’s magic. Lean on type hints, and let Laravel build the object graph for you.

By having this flow in mind, where you provide type hints and the container gives you instances back, you get the mental model behind IoC (Invesrion of Control) and DI (Dependency Injection) in Laravel.

Binding 101

Bindings tell the container how to build something and how long it should live. Once you understand this concept, the rest of the container feels simpler.

The default method of binding is bind. It creates a fresh instance every time you resolve it. That’s perfect for lightweight, stateless services or anything that must not leak state across calls.

// UuidGenerator.php
interface UuidGenerator
{
    public function generate(): string;
}

// RandomUuidGenerator.php
final class RandomUuidGenerator implements UuidGenerator
{
    public function generate(): string
    {
        return (string) Str::uuid();
    }
}

// AppServiceProvider.php
final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(UuidGenerator::class, RandomUuidGenerator::class);
    }
}

Resolve it twice and you’ll get two distinct objects:

$one = app()->make(UuidGenerator::class);
$two = app()->make(UuidGenerator::class);

// $one !== $two

We also have the singleton, that returns the same instance for the application’s lifetime. In a typical HTTP request that means “one per request.” In long‑lived workers (Laravel Octane, queues, daemons) it can persist across multiple requests/jobs, so never put request/user state in a Singleton.

// BasicHttpClient.php
final readonly class BasicHttpClient
{
    public function __construct(public string $baseUrl) {}
}

// AppServiceProvider@register
$this->app->singleton(
    BasicHttpClient::class,
    fn () => new BasicHttpClient(config('services.api.base_url'))
);

// Usage
$first = app(BasicHttpClient::class);
$second = app(BasicHttpClient::class);

// $first === $second

A subtle gotcha with it: the makeWith arguments are ignored once a Singleton is built. If you need per‑call variation, don’t use singleton for that abstraction.

The next one is the instance, that lets you hand the container an already constructed object. This is great for external clients, preconfigured loggers, or swapping fakes in tests.

// Build it however you like
$client = new BasicHttpClient('https://sandbox.example');

// Then hand that exact instance to the container
app()->instance(BasicHttpClient::class, $client);

// Anywhere later resolves the same object
$resolved = app(BasicHttpClient::class); // === $client

And we also have the bindIf, that registers a binding only if the key isn’t bound yet. It’s perfect for packages and modular code: provide a sensible default, but providing a way for the application to override it. Laravel also provides singletonIf with the same semantics.

// In a reusable package's service provider:
$this->app->bindIf(PaymentGateway::class, StripeGateway::class);

// In the application (overrides the package default):
$this->app->bind(PaymentGateway::class, BraintreeGateway::class);

You can use class names or closures for bindings. Closures are handy when you need to compute dependencies or pull configuration at resolve time. The container will resolve nested dependencies for you.

// AppServiceProvider@register
$this->app->singleton(PaymentGateway::class, function ($app) {
    $http = $app->make(BasicHttpClient::class);
    $key = config('services.stripe.key');

    return new StripeGateway($http, $key);
});

If a service is stateless and somewhat expensive to construct (HTTP clients, serializers, SDKs), singleton is a good fit. If a service carries request‑specific or user‑specific data, prefer bind (or a request‑scoped binding) so you never leak state across boundaries. When you must supply an exact object like a preconfigured client in tests, use instance. And when you’re writing a package, default to bindIf and/or singletonIf so applications can override you without issues.

Remember to avoid singleton if you’re running long‑lived processes. They will persist beyond a single request in that environment, which is usually not what you want.

Auto‑Wiring in Action

Auto‑wiring is the container reading your type hints and doing the wiring for you. If it sees a class or interface, it knows how to build it. If it sees a primitive, it needs a default value or a contextual rule. This way you can let Laravel build the object graphs for you.

Let’s start with a simple constructor injection and watch the container resolve nested dependencies without us lifting a finger.

// ExchangeRateProvider.php
interface ExchangeRateProvider
{
    public function rate(string $from, string $to): float;
}

// HttpClient.php
interface HttpClient
{
    public function get(string $url, array $query = []): array;
}

// BasicHttpClient.php
final class BasicHttpClient implements HttpClient
{
    public function get(string $url, array $query = []): array
    {
        // Execute HTTP request
    }
}

// ApiExchangeRates.php
final class ApiExchangeRates implements ExchangeRateProvider
{
    public function __construct(
        private HttpClient $http,
        private string $apiKey,
    ) {}

    public function rate(string $from, string $to): float
    {
        $data = $this->http->get('https://rates.example/api', [
            'from' => $from,
            'to' => $to,
            'key' => $this->apiKey,
        ]);

        return (float) ($data['rate'] ?? 1.0);
    }
}

// InvoiceService.php
final class InvoiceService
{
    public function __construct(private ExchangeRateProvider $rates) {}

    public function totalIn(string $currency, int $cents): int
    {
        $rate = $this->rates->rate('USD', $currency);
        return (int) round($cents * $rate);
    }
}

// AppServiceProvider.php
final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(HttpClient::class, BasicHttpClient::class);
        $this->app->bind(ExchangeRateProvider::class, ApiExchangeRates::class);

        $this->app->when(ApiExchangeRates::class)
            ->needs('$apiKey')
            ->give(fn () => config('services.rates.key'));
    }
}

Resolve anything that touches InvoiceService and the container walks the tree: it sees that the InvoiceService needs a ExchangeRateProvider, which maps to the ApiExchangeRates, which needs an HttpClient and an $apiKey. It builds all of that and hands you a ready‑to‑use instance.

Constructor injection is the everyday pattern, but method injection is where Laravel shines in Controllers, Routes, Jobs, Listeners, and even plain callables. The container inspects the method’s parameters and injects what it can by type.

final class InvoiceController
{
    public function __construct(private InvoiceService $invoices) {}

    public function show(Request $request, LoggerInterface $logger): string
    {
        $total = $this->invoices->totalIn('EUR', 2999);
        $logger->info('Invoice total computed', ['total_eur_cents' => $total]);

        return (string) $total;
    }
}

The Request and LoggerInterface are method‑injected. You didn’t bind the LoggerInterface yourself because Laravel already aliases it to the app logger.

Route closures get the same treatment. Type‑hint a service and it appears, no manual wiring.

Route::get('/convert', function (InvoiceService $invoices) {
    return $invoices->totalIn('GBP', 1999);
});

You can inject into any callable with app()->call, and even override scalars by name while letting the container resolve the rest.

final class ReportController
{
    public function generate(InvoiceService $invoices, string $since = '2025-01-01'): array
    {
        return [
            'from' => $since,
            'sample' => $invoices->totalIn('EUR', 999),
        ];
    }
}

$result = app()->call(
    [ReportController::class, 'generate'],
    ['since' => '2025-06-01']
);

Optional dependencies are easy. Use nullable types or defaults and the container will inject when it can and fall back when it can’t.

final class CachedRates implements ExchangeRateProvider
{
    public function __construct(
        private ApiExchangeRates $inner,
        private ?CacheInterface $cache = null, // optional
    ) {}

    public function rate(string $from, string $to): float
    {
        if (! $this->cache) {
            return $this->inner->rate($from, $to);
        }

        $key = "rate:$from:$to";
        $cached = $this->cache->get($key);

        if ($cached !== null) {
            return $cached;
        }

        $rate = $this->inner->rate($from, $to);
        $this->cache->set($key, $rate, 3600);

        return $rate;
    }
}

When you need to override something on the fly, use the makeWith method for constructors or pass an array of named parameters to app()->call for methods. The container fills in type‑hints while you supply the rest, if needed.

// Override the primitive $apiKey just for this instance:
$rates = app()->makeWith(ApiExchangeRates::class, ['$apiKey' => 'sandbox-key']);

// Override a method’s scalar while still injecting services:
$result = app()->call(
    [ReportController::class, 'generate'],
    ['since' => 'yesterday']
);

A couple of guardrails keep auto‑wiring predictable. Bind interfaces and abstract classes. The container can reflect concrete classes, but it can’t guess which implementation of an interface you want. Give primitives a default or a contextual rule, otherwise, resolution fails (with a helpful error naming the parameter that couldn’t be resolved). And remember that once a Singleton is built, the makeWith method won’t change its constructor arguments.

Contextual Binding

Sometimes one interface needs different implementations depending on where it’s used. That’s what contextual binding is for. Instead of adding if/else logic across your codebase, you teach the container to choose the right implementation per consumer, supply specific primitives per class, or even compute dependencies at resolve time.

Let’s pick up our PaymentGateway example and add a second implementation. We’ll use Stripe for one flow and Braintree for another.

// PaymentGateway.php
interface PaymentGateway
{
    public function charge(int $amountInCents, string $currency): string;
}

// StripeGateway.php
final class StripeGateway implements PaymentGateway
{
    public function __construct(
        private HttpClient $http,
        private string $apiKey,
    ) {}

    public function charge(int $amountInCents, string $currency): string
    {
        $resp = $this->http->post('https://api.stripe.example/charge', [
            'amount' => $amountInCents,
            'currency' => $currency,
            'key' => $this->apiKey,
        ]);

        return $resp['id'] ?? 'unknown';
    }
}

// BraintreeGateway.php
final class BraintreeGateway implements PaymentGateway
{
    public function __construct(
        private HttpClient $http,
        private string $merchantId,
    ) {}

    public function charge(int $amountInCents, string $currency): string
    {
        $resp = $this->http->post('https://api.braintree.example/sale', [
            'amount' => $amountInCents,
            'currency' => $currency,
            'merchant_id' => $this->merchantId,
        ]);

        return $resp['id'] ?? 'unknown';
    }
}

// HttpClient.php
interface HttpClient
{
    public function post(string $url, array $payload): array;
}

// BasicHttpClient.php
final class BasicHttpClient implements HttpClient
{
    public function post(string $url, array $payload): array
    {
        // Execute HTTP request
    }
}

Now we’ll tell the container which gateway to inject for each consumer, and how to supply their primitives.

// CheckoutController.php
final class CheckoutController
{
    public function __construct(private PaymentGateway $gateway) {}

    public function __invoke(): string
    {
        return $this->gateway->charge(2499, 'USD');
    }
}

// SubscriptionRenewal.php
final readonly class SubscriptionRenewal
{
    public function __construct(public int $userId) {}

    public function handle(PaymentGateway $gateway): void
    {
        $gateway->charge(999, 'USD');
    }
}

// AppServiceProvider.php
final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // A shared HTTP client
        $this->app->singleton(HttpClient::class, BasicHttpClient::class);

        // Default binding
        $this->app->bind(PaymentGateway::class, StripeGateway::class);

        // Different implementations per consumer
        $this->app->when(CheckoutController::class)
            ->needs(PaymentGateway::class)
            ->give(StripeGateway::class);

        $this->app->when(SubscriptionRenewal::class)
            ->needs(PaymentGateway::class)
            ->give(BraintreeGateway::class);

        // Contextual primitives for each implementation
        $this->app->when(StripeGateway::class)
            ->needs('$apiKey')
            ->give(fn () => (string) config('services.stripe.key'));

        $this->app->when(BraintreeGateway::class)
            ->needs('$merchantId')
            ->give(fn () => (string) config('services.braintree.merchant_id'));
    }
}

With those rules in place, resolving the CheckoutController injects the StripeGateway, while the SubscriptionRenewal gets the BraintreeGateway. Each gateway gets its own primitives, without any conditional logic leaking into your classes.

You can also compute a dependency at resolve time based on configuration or runtime context. Closures passed to the give() method run when the target is being built, so they’re great for picking implementations dynamically.

// AppServiceProvider@register
$this->app->when(BillingFacade::class)
    ->needs(PaymentGateway::class)
    ->give(function ($app) {
        $driver = config('billing.driver');

        return match ($driver) {
            'stripe' => $app->make(StripeGateway::class),
            'braintree' => $app->make(BraintreeGateway::class),
            default => $app->make(StripeGateway::class),
        };
    });

A small but powerful variant is contextual binding for primitives in method injection. If a class needs a scalar value and you don’t want to hard‑code it, bind it contextually with the exact parameter name, including the dollar sign.

// AppServiceProvider@register
$this->app->when(WebhookVerifier::class)
    ->needs('$secret')
    ->give(fn () => (string) config('services.webhooks.secret'));

Working with Multiple Bindings

When your app grows, you often need “a bunch of things that implement the same contract.” Tags and Aliases make that easy. Tags let you collect multiple bindings under a single name and resolve them as a group. Aliases give friendly names to services so you can refer to them without repeating long class strings everywhere.

Let’s build a small, real‑world example: a set of discount rules applied to a cart. Each rule is its own class, but we want to run all of them in order, without hard‑coding a list in the service.

// DiscountRule.php
interface DiscountRule
{
    public function apply(int $subtotalCents, int $userId): int;

    public function name(): string;
}

// FirstPurchaseRule.php
final class FirstPurchaseRule implements DiscountRule
{
    public function apply(int $subtotalCents, int $userId): int
    {
        // Check if it's first purchase
        if (! $firstPurchase) {
            return $subtotalCents;
        }

        return (int) round($subtotalCents * 0.9);
    }

    public function name(): string
    {
        return 'first_purchase';
    }
}

// SeasonalRule.php
final readonly class SeasonalRule implements DiscountRule
{
    public function __construct(private string $season = 'none') {}

    public function apply(int $subtotalCents, int $userId): int
    {
        if ($this->season !== 'black_friday') {
            return $subtotalCents;
        }

        return (int) round($subtotalCents * 0.8);
    }

    public function name(): string
    {
        return 'seasonal';
    }
}

Now we’ll tag those implementations and inject them as one iterable array.

// AppServiceProvider.php
final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(FirstPurchaseRule::class);
        $this->app->bind(
            SeasonalRule::class,
            fn () => new SeasonalRule(config('shop.season'))
        );

        // Tag them so we can resolve "all discount rules" at once
        $this->app->tag(
            [FirstPurchaseRule::class, SeasonalRule::class],
            'discount.rule'
        );

        // Feed the tagged collection into CartDiscountService automatically
        // giveTagged() resolves all services with the given tag (in order)
        $this->app->when(CartDiscountService::class)
            ->needs('$rules')
            ->giveTagged('discount.rule');
    }
}

// CartDiscountService.php
final readonly class CartDiscountService
{
    public function __construct(private array $rules) {}

    public function total(int $subtotalCents, int $userId): int
    {
        $total = $subtotalCents;

        foreach ($this->rules as $rule) {
            $before = $total;
            $total = $rule->apply($total, $userId);
            logger()->debug('rule', [
                'name' => $rule->name(),
                'before' => $before,
                'after' => $total
            ]);
        }

        return max(0, $total);
    }
}

You can also resolve the collection on demand, without contextual binding:

$rules = app()->tagged('discount.rule');
$total = (new CartDiscountService($rules))->total(10000, 42);

Tagging order matters. The array you pass to tag() sets the resolution order. If you call tag() multiple times, Laravel maintains the registration order. That gives you predictable rule execution without hard‑coding class lists inside your services.

Aliases are a separate but complementary feature. They give alternate names to a binding so you can resolve it by a short, semantic key.

// ReportExporter.php
interface ReportExporter
{
    public function export(array $rows): string;
}

// CsvExporter.php
final class CsvExporter implements ReportExporter
{
    public function export(array $rows): string
    {
        $out = fopen('php://temp', 'r+');
        foreach ($rows as $row) {
            fputcsv($out, $row);
        }
        rewind($out);

        return stream_get_contents($out) ?: '';
    }
}

// AppServiceProvider@register
$this->app->bind(ReportExporter::class, CsvExporter::class);
// Give it a friendly alias for quick resolution
$this->app->alias(ReportExporter::class, 'report.exporter');

// Usage
$exporter = app('report.exporter'); // same instance as app(ReportExporter::class)
$csv = $exporter->export([['id', 'name'], [1, 'Ada']]);

An alias is just another key for the same abstract. If you swap the underlying binding, both the class and its alias now point to the new implementation. That makes aliases ideal for decoupling “what I want” from “how it’s built,” especially when using configuration or environment to switch implementations.

You can combine aliases with tags to create named collections. Bind a string key that resolves to your tagged set and inject it contextually by parameter name.

// Bind a named collection
$this->app->bind('discount.rules', fn ($app) => $app->tagged('discount.rule'));
$this->app->alias('discount.rules', 'pricing.rules'); // extra alias

// Inject by parameter name using contextual binding
$this->app->when(CartController::class)
    ->needs('$rules')
    ->give(fn ($app) => $app->make('discount.rules'));
    // ->give(fn ($app) => $app->make('pricing.rules')); - Would be the same

Some things to keep in mind. Tag concrete classes you actually want in the set, tagging an interface won’t give you “all the implementations” it just points to whatever that interface currently resolves to. Be careful about the order you pass to tag() so your pipeline is deterministic.

Service Providers and Lifecycle

Service Providers are the container’s home base. They’re where you declare bindings, set up contextual rules, tag services, and hook into the application boot process. Understanding what happens during register and boot makes your container code predictable and easy to maintain.

Laravel loads all the providers, calls register on each, then boots them in order. During register you should only define bindings and configuration. Don’t resolve services or touch request-specific state here. During boot it’s safe to use resolved services, set up event listeners, gates, morph maps, and route or Eloquent macros, because the container is ready.

final class BillingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Pure bindings
        // Define how things are built
        // Avoid resolving anything here

        // shared, stateless -> a good singleton
        $this->app->singleton(HttpClient::class, BasicHttpClient::class);
        // default implementation
        $this->app->bind(PaymentGateway::class, StripeGateway::class);

        // Contextual implementations
        $this->app->when(CheckoutController::class)
            ->needs(PaymentGateway::class)
            ->give(StripeGateway::class);

        $this->app->when(SubscriptionRenewal::class)
            ->needs(PaymentGateway::class)
            ->give(BraintreeGateway::class);

        // Contextual primitives
        $this->app->when(StripeGateway::class)
            ->needs('$apiKey')
            ->give(fn () => (string) config('services.stripe.key'));

        $this->app->when(BraintreeGateway::class)
            ->needs('$merchantId')
            ->give(fn () => (string) config('services.braintree.merchant_id'));

        // Scoped services: new instance per request without being a global singleton
        $this->app->scoped(
            RequestContext::class,
            // This closure runs at resolve-time within the request scope
            fn () => new RequestContext(request()->user()?->id)
        );

        // Aliases and tags keep larger compositions tidy
        $this->app->alias(PaymentGateway::class, 'billing.gateway');

        $this->app->bind(FirstPurchaseRule::class);
        $this->app->bind(
            SeasonalRule::class,
            fn () => new SeasonalRule(config('shop.season'))
        );
        $this->app->tag(
            [FirstPurchaseRule::class, SeasonalRule::class],
            'discount.rule'
        );

        // Provide a named collection
        $this->app->bind(
            'discount.rules',
            fn ($app) => $app->tagged('discount.rule')
        );
    }

    public function boot(): void
    {
        // Safe to resolve services
        // Register listeners/macros
        // Use runtime info

        // Example: feed tagged collection into a service via contextual primitive
        $this->app->when(CartDiscountService::class)
            ->needs('$rules')
            ->give(fn ($app) => $app->make('discount.rules'));

        // Other examples:
        // Register an Event Listener
        // Define an authorization gate
    }
}

Add your provider to bootstrap/providers.php so Laravel can load it, or rely on package discovery for vendor packages. Order matters when two providers bind the same abstract: later providers can override earlier bindings. That’s powerful for applications that want to override package defaults.

Sometimes you want a provider to load only when one of its services is actually needed. Deferrable providers let you do that by declaring what they provide. Laravel won’t boot them until one of those abstractions is resolved.

final class ReportsServiceProvider extends ServiceProvider implements DeferrableProvider
{
    public function register(): void
    {
        $this->app->singleton(ReportExporter::class, CsvExporter::class);
        $this->app->alias(ReportExporter::class, 'report.exporter');
    }

    // Tells Laravel which bindings this provider is responsible for
    public function provides(): array
    {
        return [ReportExporter::class, 'report.exporter'];
    }
}

Keep register side-effect free. Don’t call request(), auth(), the DB, or external services here. If you must compute something from runtime state, do it inside a closure that runs at resolve time, or move it to boot.

As your app grows, split providers by domain. Example: BillingServiceProvider, SearchServiceProvider, ReportingServiceProvider. This way each domain owns its bindings, tags, and hooks. This keeps your container configuration discoverable, testable, and easy to maintain.

Extending and Hooks

Sometimes you want to change how a service behaves without editing its class or touching every place it’s used. The container gives you a few elegant tools for that: extend to decorate an existing binding, resolving and afterResolving to run logic when something is built, and rebinding/refresh to keep long-lived singletons in sync when dependencies change.

Let’s start with a classic decorator. We’ll wrap our PaymentGateway with logging, with no changes to controllers, jobs, or the original gateway.

// LoggingGateway.php
final class LoggingGateway implements PaymentGateway
{
    public function __construct(
        private PaymentGateway $inner,
        private LoggerInterface $logger,
    ) {}

    public function charge(int $amountInCents, string $currency): string
    {
        $this->logger->info('Charging', compact('amountInCents', 'currency'));

        $id = $this->inner->charge($amountInCents, $currency);

        $this->logger->info('Charge complete', ['id' => $id]);

        return $id;
    }
}

// AppServiceProvider.php
final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Base binding
        $this->app->bind(PaymentGateway::class, StripeGateway::class);

        // Decorate with logging
        $this->app->extend(
            PaymentGateway::class,
            fn (PaymentGateway $core, $app) => LoggingGateway($core, $app->make(LoggerInterface::class)),
        );
    }
}

Each extend receives the current instance for the abstract and returns a replacement. The order you register extend calls is the order of wrapping. The example yields Logging(StripeGateway) without editing StripeGateway or changing any consumer code.

You can also listen when specific services are resolved to set defaults, attach instrumentation, or validate configuration. Use resolving for setup, and afterResolving for post-setup actions that should run after all resolving callbacks.

// BasicHttpClient.php
final class BasicHttpClient implements HttpClient
{
    public function __construct(public string $baseUrl = '') {}

    private array $headers = [];

    public function setDefaultHeaders(array $headers): void
    {
        $this->headers = [...$this->headers, ...$headers];
    }

    public function post(string $url, array $payload): array
    {
        // Execute HTTP request
    }
}

// AppServiceProvider@boot
$this->app->resolving(HttpClient::class, function (HttpClient $http, $app) {
    if ($http instanceof BasicHttpClient) {
        $http->setDefaultHeaders([
            'X-App' => config('app.name'),
            'X-Trace' => (string) Str::uuid(),
        ]);
    }
});
// After full resolution
$this->app->afterResolving(PaymentGateway::class, function (PaymentGateway $gateway, $app) {
    if ($gateway instanceof StripeGateway && empty(config('services.stripe.key'))) {
        throw new LogicException('Stripe key is missing.');
    }
});

When a Singleton depends on another service that may change, you can keep it fresh with refresh or a manual rebinding hook. This is especially handy in tests.

// ReportManager.php
final class ReportManager
{
    public function __construct(private LoggerInterface $logger) {}

    public function setLogger(LoggerInterface $logger): void
    {
        $this->logger = $logger;
    }

    public function export(array $rows): void
    {
        $this->logger->info('Exporting report', ['rows' => count($rows)]);
    }
}

// AppServiceProvider@register
$this->app->singleton(
    ReportManager::class,
    fn ($app) => new ReportManager($app->make(LoggerInterface::class))
);

// Keep ReportManager's logger in sync if the logger binding changes
$this->app->refresh(LoggerInterface::class, ReportManager::class, 'setLogger');

// Alternatively, a manual hook with rebinding:
$this->app->rebinding(
    LoggerInterface::class,
    fn ($app, $newLogger) => $app->make(ReportManager::class)->setLogger($newLogger),
);

Now, if you do this in a test:

$fake = new \Monolog\Logger('fake');
$this->app->instance(LoggerInterface::class, $fake);

// The existing ReportManager singleton is updated automatically
app(ReportManager::class)->export([['id' => 1]]);

Laravel also lets you customize how method injection happens for a specific callable. bindMethod is a neat "workaround" when you must supply extra parameters alongside container-injected services.

// SendInvoiceReport.php
final readonly class SendInvoiceReport
{
    public function __construct(public int $userId) {}

    public function handle(InvoiceService $invoices, string $since): void
    {
        $total = $invoices->totalIn('USD', 5000);
        // generate a report since $since...
    }
}

// AppServiceProvider@boot
$this->app->bindMethod(
    [SendInvoiceReport::class, 'handle'],
    fn ($job, $app) => $job->handle(
        $app->make(InvoiceService::class),
        since: now()->subMonth()->toDateString(),
    );
);

Remember, extenders run when the abstract is resolved, on Singletons, that’s once. So avoid capturing per-request data in extend closures if you use long-lived workers. When a dependency of a Singleton can change at runtime, prefer refresh or rebinding over rebuilding the singleton manually.

With decorators and resolution hooks, you can evolve behavior, add observability, and enforce invariants from a single, central place. Your services remain focused, and the container does the orchestration quietly behind the scenes.

Conclusion

The Service Container maps “I need X” to “here’s how to build X.” With that mental model, everything else in Laravel’s IoC becomes straightforward. You’ve seen how to bind and choose lifetimes, how auto‑wiring resolves nested graphs from type hints, how contextual bindings pick the right implementation per consumer, how tags and aliases keep large compositions tidy, how providers shape app lifecycle and how decorators and hooks evolve behavior.

Put this into practice today by picking one area of your app and letting the container do the hard work for you. Replace a new with a binding, add a contextual rule where you previously had conditionals, group related services with a tag, and wrap a core service with an extend‑based decorator.

I hope that you liked this article and if you do, don’t forget to share this article with your friends!!! See ya!