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 You're Probably Underusing

Most Laravel developers know bind, singleton, and make. What separates a well-architected application from a tangled one is often the other container features: contextual binding, tagging, and method injection. Let's go deep on each.


Contextual Binding

Contextual binding answers the question: what should the container inject when two different classes both depend on the same interface, but need different implementations?

// AppServiceProvider::register()

$this->app
    ->when(ReportExporter::class)
    ->needs(StorageInterface::class)
    ->give(S3Storage::class);

$this->app
    ->when(LocalPreviewGenerator::class)
    ->needs(StorageInterface::class)
    ->give(LocalDiskStorage::class);

Both classes declare StorageInterface in their constructors. The container resolves the correct implementation based on who is asking, not just what is needed. No factory, no service locator, no if branch in a shared provider.

You can also pass a closure for runtime logic:

$this->app
    ->when(TenantMailer::class)
    ->needs(TransportInterface::class)
    ->give(function ($app) {
        return $app->make(
            config('mail.tenant_transport') === 'ses'
                ? SesTransport::class
                : SmtpTransport::class
        );
    });

Contextual Primitives

Since Laravel 10 you can also inject primitive values contextually using giveConfig or a plain closure:

$this->app
    ->when(StripeGateway::class)
    ->needs('$apiKey')
    ->giveConfig('services.stripe.secret');

This removes the need for a dedicated config-reading constructor or a value object just to carry a string.


Container Tagging

Tagging lets you group related bindings and resolve them all at once — perfect for plugin systems, report drivers, or notification channels.

// Register
$this->app->bind(CsvExporter::class);
$this->app->bind(XlsxExporter::class);
$this->app->bind(PdfExporter::class);

$this->app->tag(
    [CsvExporter::class, XlsxExporter::class, PdfExporter::class],
    'exporters'
);
// Resolve all tagged bindings
class ExportManager
{
    /** @param iterable<ExporterInterface> $exporters */
    public function __construct(
        private readonly iterable $exporters
    ) {}
}

// In the provider
$this->app->bind(ExportManager::class, function ($app) {
    return new ExportManager($app->tagged('exporters'));
});

$app->tagged() returns a lazy Generator, so bindings are not instantiated until iterated. This matters when exporters have heavy constructors.


Method Injection

The container can inject dependencies into arbitrary methods, not just constructors. This is how route closures and controller methods work internally, and you can use the same mechanism in your own code.

class ReportController
{
    public function generate(
        Request $request,
        ReportBuilder $builder,   // injected by container
        AuditLogger $logger       // injected by container
    ): JsonResponse {
        // ...
    }
}

You can call any callable through the container with app()->call():

$result = app()->call(
    [new InvoiceProcessor(), 'process'],
    ['invoiceId' => $id]   // extra primitives merged in
);

This is particularly useful in pipeline stages, console commands, or action classes where you want the container to satisfy type-hinted dependencies without making every class a full service.

// Action resolved and called without manual wiring
$result = app()->call(GenerateInvoiceAction::class, [
    'order' => $order,
]);

Practical Takeaways

  • Contextual binding eliminates conditional logic in providers when the same interface needs different implementations per consumer.
  • giveConfig keeps primitive injection declarative and avoids leaking config calls into constructors.
  • Tagging is the cleanest way to implement open/closed plugin systems — add a new driver by registering and tagging it, nothing else changes.
  • app()->call() gives you full DI on any callable, making action classes and pipeline stages first-class container citizens.
  • Prefer contextual binding over abstract factories when the variation is per-consumer, not per-runtime-value.

Found this useful?

Frequently Asked Questions

3 questions
Q01 When should I use contextual binding instead of a factory class?
Use contextual binding when the variation is static and known at registration time — different consumers need different implementations. Use a factory when the correct implementation depends on runtime data such as a tenant ID or user preference.
Q02 Does `app()->tagged()` instantiate all bindings immediately?
No. `tagged()` returns a Generator, so each binding is resolved lazily as you iterate. This avoids unnecessary construction costs if you break out of the loop early or only need a subset of tagged services.
Q03 Can method injection be used outside of controllers?
Yes. Any callable — a closure, an array callable like `[$object, 'method']`, or a class@method string — can be resolved through `app()->call()`. Extra primitive arguments can be passed as the second parameter and are merged with container-resolved dependencies.

Continue reading

More Articles

View all