Skip to main content

Dependency injection and the Laravel container — automatic resolution and binding interfaces

In chapter 10 we handed OrderPlacementService to the constructor of OrderController. Nowhere did we write code like new OrderPlacementService(...). That is because Laravel looks at the constructor's type declarations and creates the instances it needs automatically. This chapter looks at how that automatic resolution works, and at how the dependency on an interface from chapter 6 is realized in Laravel.

Automatic resolution in the constructor​

The OrderPlacementService from chapter 10 depended on the concrete class StockChecker.

class OrderPlacementService
{
public function __construct(
private readonly StockChecker $stockChecker,
) {
}
}

When you type-declare OrderPlacementService in the constructor of OrderController, Laravel's service container works like this1.

  1. It notices that building OrderController requires an OrderPlacementService
  2. It notices that building OrderPlacementService requires a StockChecker
  3. StockChecker has no constructor arguments, so it simply does new StockChecker()
  4. It passes the StockChecker in and does new OrderPlacementService($stockChecker)
  5. It passes the OrderPlacementService in and does new OrderController($orderPlacementService)

As long as the dependencies are between concrete classes, this resolution happens automatically with no configuration at all.

Passing an interface requires a binding​

Chapter 6 showed an example where OrderService depends on an interface called Logger.

class OrderService
{
public function __construct(
private readonly Logger $logger,
) {
}
}

Logger is an interface and has no substance of its own. When the container tries to build OrderService and reaches Logger, it has no way to decide whether to new a FileLogger or a CloudWatchLogger.

So you tell the container in advance which implementation class corresponds to which interface.

✅ Good: binding an interface to an implementation class
// app/Providers/AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(Logger::class, FileLogger::class);
}
}

Calling bind() inside the register() method registers the correspondence "when a Logger is asked for, hand over a FileLogger"2. From then on, simply type-declaring Logger in the OrderService constructor is enough for the container to pass in a FileLogger instance.

When you want to send logs to CloudWatch instead, you change nothing in OrderService; you just swap the second argument of bind() for CloudWatchLogger::class. The benefit from chapter 6 — being able to swap the implementation without touching OrderService — is realized by this bind() registration.

Sharing one instance with singleton​

bind() creates a new instance every time it resolves. When you want to reuse the same instance across the whole application, use singleton()3.

✅ Good: sharing a single instance
$this->app->singleton(Logger::class, FileLogger::class);

bind() and singleton() are the same in that both register which implementation class to hand over when an interface is asked for. The difference is whether a new instance is created on every call, or created once and reused.

Handing a different implementation to each injection site​

A binding registered with bind() returns the same implementation no matter which class asks for it. When you want the implementation to differ by injection site — ordinary logs going to a file while the order flow goes to CloudWatch — use contextual binding4.

✅ Good: sending only the order flow logs elsewhere
// The default goes to a file
$this->app->bind(Logger::class, FileLogger::class);

// Only the Logger that OrderService asks for goes to CloudWatch
$this->app->when(OrderService::class)
->needs(Logger::class)
->give(fn () => new CloudWatchLogger());

when() takes the injection site, needs() the requested type, and give() the implementation to hand over. when() accepts an array, so classes that share an implementation can be listed together. Whatever the closure passed to give() returns is what that injection site receives. The bind() registration stays in place and is overridden only at the sites you named.

Its reach is narrow. It only helps when the choice of implementation is decided by the class being injected into. If the injection site stays the same and you want the implementation to change with a config value or the contents of a request, contextual binding cannot express that. You can write that branch inside the closure passed to give(), but it then hides in the container registration and gets hard to follow. Write a factory that picks the implementation, and register that factory with bind().

What this chapter covers​

bind(), singleton(), and contextual binding are the central uses of the service container. Beyond them are tag() and tagged(), which resolve several implementations of the same role together, and extend(), which modifies an instance after it is resolved4. Both only change how the registration is written; the mechanism of asking for an interface and receiving an implementation stays the same.

Summary​

  • When a constructor's type declaration is a concrete class, Laravel's service container resolves it automatically with no configuration
  • When the type declaration is an interface, you have to register the corresponding implementation class in advance with bind()
  • bind() creates a new instance on every resolution; singleton() creates one the first time and reuses it
  • When the implementation should differ by injection site, register it with when()->needs()->give(); when it should switch on a runtime value, write a factory
  • The benefit from chapter 6 — swapping the implementation without changing code — is realized by this binding registration

The next chapter covers exception design and validation. We look at custom exception classes and at checking the shape of a request with a Form Request.

Exercises​

For each situation below, say whether a container registration is needed, and why
  1. The constructor of Notification type-declares the concrete class EmailChannel
  2. The constructor of Notification type-declares the interface NotificationChannel

Sample answer

  1. Not needed: EmailChannel is a concrete class, so the container can look at the constructor and new it directly
  2. Needed: NotificationChannel is an interface, so unless you register which implementation class to hand over with bind(), the container cannot resolve it

Footnotes​

  1. Source: Laravel documentation — Service Container. It explains that automatic resolution via type declarations is the practical way most objects get resolved. ↩

  2. Source: Laravel documentation — Service Container: Binding Interfaces to Implementations. ↩

  3. Source: Laravel documentation — Service Container: Registering a Singleton. ↩

  4. Source: Laravel documentation — Service Container. It covers contextual binding with when()->needs()->give(), grouping several implementations with tag() and tagged(), and modifying a resolved instance with extend(). ↩ ↩2