Laravel Service Container: Contextual Binding, Tagging, and Method Injection
#laravel #service-container #dependency-injection #architecture

Laravel Service Container: Contextual Binding, Tagging, and Method Injection

3 min read Mohamed Said Mohamed Said

Beyond app()->make(): The Container Features Most Teams Ignore

Most Laravel developers register a singleton, call app(MyService::class), and move on. The container is capable of far more nuanced wiring — and using it well is the difference between a codebase that scales and one that turns into a ball of mud.

Contextual Binding

Contextual binding answers the question: "What should the container inject when class A asks for interface X, versus when class B asks for the same interface?"

// AppServiceProvider::register()
$this->app
    ->when(ReportExporter::class)
    ->needs(StorageDriver::class)
    ->give(S3StorageDriver::class);

$this->app
    ->when(LocalPreviewGenerator::class)
    ->needs(StorageDriver::class)
    ->give(LocalStorageDriver::class);

Both classes declare __construct(StorageDriver $storage). The container resolves the correct concrete without a single if statement in application code. This is particularly powerful in multi-tenant SaaS where different panels may need different implementations of the same interface.

You can also supply a closure for runtime logic:

$this->app
    ->when(InvoicePdfRenderer::class)
    ->needs(FontResolver::class)
    ->give(fn ($app) => new FontResolver(
        config('pdf.font_path'),
        $app->make(CacheManager::class)
    ));

Tags let you group bindings under a label and resolve all of them at once — ideal for plugin architectures, report generators, or notification channels.

// Register
$this->app->bind(SlackNotifier::class);
$this->app->bind(EmailNotifier::class);
$this->app->bind(WebhookNotifier::class);

$this->app->tag(
    [SlackNotifier::class, EmailNotifier::class, WebhookNotifier::class],
    'notifiers'
);
// Resolve all tagged bindings
class NotificationDispatcher
{
    /** @param iterable<NotifierContract> $notifiers */
    public function __construct(
        private readonly iterable $notifiers
    ) {}

    public function dispatch(Notification $notification): void
    {
        foreach ($this->notifiers as $notifier) {
            $notifier->send($notification);
        }
    }
}

// Wire it up
$this->app->bind(NotificationDispatcher::class, fn ($app) =>
    new NotificationDispatcher($app->tagged('notifiers'))
);

$app->tagged() returns a lazy TaggedIterator, so bindings are not instantiated until the loop runs. Adding a new notifier is a one-line tag registration — no modification to NotificationDispatcher.

Method Injection

The container can resolve dependencies for arbitrary method calls, not just constructors. This is how route model binding and controller methods work internally.

class ReportController
{
    public function generate(
        Request $request,
        ReportBuilder $builder,   // injected by container
        AuditLogger $audit        // injected by container
    ): JsonResponse {
        $audit->log('report.generate', $request->user());
        return response()->json($builder->build($request->validated()));
    }
}

You can invoke this pattern yourself with app()->call():

$result = app()->call(
    [new SomeService(), 'handle'],
    ['extraParam' => 'value']  // merged with container-resolved args
);

This is useful in console commands, job handlers, or anywhere you want to defer resolution until call time rather than construction time.

Avoiding the Service Locator Anti-Pattern

The temptation after learning app()->make() is to call it everywhere. Resist it. Inject through constructors or method signatures so dependencies are explicit and mockable in tests:

// Bad: hidden dependency
class OrderProcessor
{
    public function process(Order $order): void
    {
        $mailer = app(Mailer::class); // invisible, untestable
        $mailer->send(...);
    }
}

// Good: declared dependency
class OrderProcessor
{
    public function __construct(private readonly Mailer $mailer) {}

    public function process(Order $order): void
    {
        $this->mailer->send(...);
    }
}

The container wires it; your class stays ignorant of the container.

Takeaways

  • Contextual binding resolves the same interface to different concretes per consumer — no conditionals in application code.
  • Tagging enables open/closed plugin architectures; new implementations register themselves without touching existing classes.
  • Method injection via app()->call() defers resolution to invocation time and keeps constructors lean.
  • Never call app() inside domain classes — that couples your domain to the framework and kills testability.
  • Service providers are the correct place for all container wiring; keep them focused and split large providers by domain.

Found this useful?

Frequently Asked Questions

3 questions
Q01 When should I use contextual binding instead of just registering two separate interfaces?
Use contextual binding when multiple consumers share the same interface contract but need different implementations — for example, two storage drivers that satisfy the same interface. Creating separate interfaces for each concrete would violate interface segregation. Contextual binding keeps the interface unified while letting the container handle the routing.
Q02 Does `app()->tagged()` instantiate all bindings immediately?
No. `app()->tagged()` returns a lazy `TaggedIterator`. Each binding is resolved only when the iterator reaches it, so you pay no instantiation cost for notifiers or handlers that are never iterated in a given request.
Q03 Can I use contextual binding inside a package service provider?
Yes. Package service providers have full access to `$this->app` and can register contextual bindings in their `register()` method. Just ensure your package documents which interfaces it expects the host application to have bound, so consumers know what to provide.

Continue reading

More Articles

View all