background-shape
Laravel Service Container for Dependency Injection
June 22, 2022 · 4 min read · by Muhammad Amal programming

TL;DR — Laravel’s container resolves dependencies automatically via constructor injection. Bind interface → impl in service providers. Use singleton() for stateful services, bind() for transient. Contextual binding for when the same interface has different impls per consumer.

After Laravel + Clean Architecture layout, the actual mechanics of wiring it up. Laravel’s container is what lets you type-hint interfaces and get implementations back. Used right, it’s invisible. Used wrong, it’s magic that hides bugs.

How auto-resolution works

class StartSubscription
{
    public function __construct(
        private SubscriptionRepository $subs,
        private PlanRepository $plans,
    ) {}
}

When Laravel needs a StartSubscription (via a route controller, a queue handler, etc.), it reads the constructor, sees the type hints, and resolves each from the container. The container looks up bindings; if none exists, it tries to auto-construct.

For concrete classes with no bindings: container news them up. For interfaces: you must register a binding.

Bindings in service providers

namespace App\Providers;

use App\Repository\SubscriptionRepository;
use App\Repository\PlanRepository;
use App\Repository\Eloquent\EloquentSubscriptionRepository;
use App\Repository\Eloquent\EloquentPlanRepository;
use Illuminate\Support\ServiceProvider;

class DomainServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(SubscriptionRepository::class, EloquentSubscriptionRepository::class);
        $this->app->bind(PlanRepository::class, EloquentPlanRepository::class);
    }
}

Add to config/app.php:

'providers' => [
    // ...
    App\Providers\DomainServiceProvider::class,
],

Now every class type-hinting SubscriptionRepository gets EloquentSubscriptionRepository.

bind vs singleton

// Transient: new instance each time
$this->app->bind(SubscriptionRepository::class, EloquentSubscriptionRepository::class);

// Singleton: same instance per request
$this->app->singleton(StripeClient::class, function ($app) {
    return new StripeClient(config('services.stripe.secret'));
});

When to use each:

  • bind: stateless services (repositories, use cases). Fresh per use.
  • singleton: services with expensive setup (HTTP clients with connection pools, Redis clients). Created once, reused.

In Laravel, “per request” is the lifetime — the container is rebuilt on every HTTP request. Singletons last for one request, not across the process. So singletons are essentially free; they’re a perf optimization, not a state-sharing mechanism.

Closure bindings for parameterized services

$this->app->singleton(StripeClient::class, function ($app) {
    return new StripeClient(
        secretKey: config('services.stripe.secret'),
        timeout: config('services.stripe.timeout', 5),
    );
});

When the implementation needs config, env, or other runtime values, use a closure. The closure runs lazily — only when the binding is first resolved per request.

Contextual binding

Sometimes the same interface needs different implementations depending on who’s asking:

$this->app->when(StartSubscription::class)
    ->needs(EventPublisher::class)
    ->give(KafkaEventPublisher::class);

$this->app->when(StandupBot::class)
    ->needs(EventPublisher::class)
    ->give(SlackEventPublisher::class);

Now StartSubscription gets a Kafka publisher; StandupBot gets a Slack publisher. Same interface, different impl per consumer.

Use sparingly. Often a sign that the interface is too generic.

Resolving via container directly

For cases where you can’t type-hint (eg, inside a closure or static context):

$startSub = app(StartSubscription::class);
$result = $startSub->execute($input);

// or with explicit container
$startSub = app()->make(StartSubscription::class);

app() is the global container helper. Avoid in user code; use constructor injection where possible. Useful in tests and in some framework hooks.

Testing — overriding bindings

public function test_start_subscription_publishes_event()
{
    $this->app->bind(SubscriptionRepository::class, fn() => new InMemorySubscriptionRepository());
    $this->app->bind(PlanRepository::class, fn() => new InMemoryPlanRepository());

    $events = $this->getMockBuilder(Dispatcher::class)->getMock();
    $events->expects($this->once())->method('dispatch');
    $this->app->bind(Dispatcher::class, fn() => $events);

    $useCase = $this->app->make(StartSubscription::class);
    $useCase->execute(new StartSubscriptionInput(
        customerId: Uuid::uuid4(),
        planId: 'pro_monthly',
    ));
}

Per-test bindings override the production ones. Use cases get the test doubles automatically.

For most tests, hand-written fakes (the InMemory*Repository classes) are clearer than mock libraries. Mocks are heavier; fakes hold state.

What NOT to put in the container

  • Configuration objects. Use config() directly.
  • Single-use values. Pass as method args.
  • Request-specific state. Use the Request object.
  • Eloquent models. Construct directly; the container isn’t useful for them.

The container is for services with non-trivial dependencies. Not a global hashmap.

A complete provider

namespace App\Providers;

use App\Repository\PlanRepository;
use App\Repository\SubscriptionRepository;
use App\Repository\Eloquent\EloquentPlanRepository;
use App\Repository\Eloquent\EloquentSubscriptionRepository;
use App\Service\Billing\StripeClient;
use Illuminate\Support\ServiceProvider;

class DomainServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Repositories
        $this->app->bind(SubscriptionRepository::class, EloquentSubscriptionRepository::class);
        $this->app->bind(PlanRepository::class, EloquentPlanRepository::class);

        // Services
        $this->app->singleton(StripeClient::class, function () {
            return new StripeClient(
                secretKey: config('services.stripe.secret'),
                timeout: 10,
            );
        });
    }

    public function boot(): void
    {
        // Runs after all providers registered. Use for things needing other providers to be ready.
    }
}

Bindings in register(). Things that depend on bound services going boot().

Common Pitfalls

new EloquentSubscriptionRepository() inside a use case. Bypasses DI. Now you can’t swap for tests. Type-hint the interface; let the container resolve.

app() calls scattered everywhere. Defeats DI. Use type hints in constructors.

Singletons holding request-specific state. Container is per-request anyway, but singletons across requests in a long-lived worker (like Laravel Octane) can leak state.

Auto-discovery of providers in non-Laravel-managed code. Stick to Laravel’s lifecycle. Don’t try to wire providers manually.

Binding interface → interface. Doesn’t help. Bind interface → concrete.

Circular dependencies. A → B → A. Container will fail at resolution time. Refactor.

Wrapping Up

Service container for DI: bind interface to impl in providers; type-hint constructors; override in tests. Friday: Eloquent vs domain models — the most common Laravel-specific Clean Architecture friction.