Skip to content
Tech

Understanding Laravel's Service Container and Dependency Injection

A practical look at how the Laravel service container resolves classes, and when to reach for bindings instead of new.

Understanding Laravel's Service Container and Dependency Injection
Understanding Laravel's Service Container and Dependency Injection
In this article

    Most Laravel developers use the service container every day without thinking about it — type-hint a class in a controller constructor and it simply appears. That convenience hides a genuinely useful tool. Understanding how and when to bind things explicitly makes it much easier to swap implementations, write testable code, and avoid tangled constructors full of manually instantiated dependencies.

    The Problem the Container Solves

    Without dependency injection, a class that needs a payment gateway typically instantiates it directly: new StripeGateway(). That works until you need a different gateway for testing, a different provider for a specific region, or a mock for a unit test. Hardcoded instantiation ties a class to one concrete implementation forever.

    The container inverts this. Classes declare what they need through their constructor's type hints, and the container figures out how to build it, recursively resolving any dependencies those dependencies have in turn. This is why a controller can type-hint seven levels of nested services and still "just work."

    class CheckoutController extends Controller
    {
        public function __construct(
            private readonly PaymentGateway $gateway,
            private readonly OrderRepository $orders,
        ) {}
    }

    Laravel resolves PaymentGateway and OrderRepository automatically when it instantiates this controller, as long as it knows how to build them.

    Binding Interfaces to Implementations

    For concrete classes with no dependencies of their own, the container can usually auto-resolve them without any configuration. The binding step becomes necessary once you introduce an interface, since PHP has no way to know which implementation satisfies it.

    // app/Providers/AppServiceProvider.php
    public function register(): void
    {
        $this->app->bind(PaymentGateway::class, function () {
            return match (config('services.payments.driver')) {
                'stripe' => new StripeGateway(config('services.stripe.secret')),
                'sandbox' => new SandboxGateway(),
                default => throw new InvalidArgumentException('Unknown payment driver.'),
            };
        });
    }

    Now every class that type-hints PaymentGateway gets whichever implementation the configuration selects, without those classes knowing or caring which one it is. Swapping providers becomes a configuration change, not a code change scattered across the codebase.

    Singleton, Bind and Instance

    bind() builds a fresh instance every time the interface is resolved. singleton() builds it once per request lifecycle and reuses that instance for every subsequent resolution — appropriate for things like a connection wrapper or an in-memory cache of configuration. instance() registers an object you already constructed, useful when wiring up something built outside the container's normal flow.

    $this->app->singleton(FeatureFlags::class, function ($app) {
        return new FeatureFlags($app->make(Repository::class));
    });
    Tip: Default to bind() unless you have a specific reason to share state across a single request. Singletons that accidentally hold request-specific data become a source of subtle bugs under Octane or queue workers, where the container instance can outlive a single request.

    Contextual Binding

    Sometimes two classes need different implementations of the same interface. Contextual binding lets you scope a binding to a specific consumer instead of making it global.

    $this->app->when(ExportReportJob::class)
        ->needs(Filesystem::class)
        ->give(fn () => Storage::disk('s3'));
    
    $this->app->when(ImportCsvController::class)
        ->needs(Filesystem::class)
        ->give(fn () => Storage::disk('local'));

    This avoids introducing an extra interface or constructor parameter purely to express "this one class is different," which keeps the rest of the codebase simpler.

    Resolving Dependencies Outside Constructors

    Constructor injection is preferred, but there are legitimate cases — a static helper, a Blade directive, a one-off script — where you need to pull something from the container directly.

    $gateway = app(PaymentGateway::class);
    
    // or, equivalently
    $gateway = resolve(PaymentGateway::class);

    Use this sparingly. Reaching for app() inside a class that could just as easily accept the dependency through its constructor hides a real dependency behind a function call, which makes the class harder to test in isolation.

    If a class's dependencies aren't visible in its constructor, they aren't really dependencies — they're secrets.

    Wrapping Up

    The service container is not magic, it is a registry of "how to build this" rules that Laravel consults recursively. Reach for explicit bindings when an interface has more than one implementation, use singletons deliberately rather than by default, and prefer constructor injection over pulling from the container inside a method body. The payoff is code that stays easy to test and easy to reconfigure.

    Written by Rajasekar

    Founder & Editor

    I write HorizonDesk: practical, no-hype articles on technology, money, business and the vehicles we drive. Everything here is something I’ve researched, tested or wanted explained clearly myself.