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 Devs Skip

The Laravel service container is the backbone of the framework, yet most codebases only scratch its surface with simple bind and singleton calls. Three features — contextual binding, tagging, and method injection — unlock genuinely cleaner architecture when applied deliberately.


Contextual Binding

Contextual binding lets you resolve a different concrete class depending on which class is requesting it. This is invaluable when two subsystems share an interface but need distinct implementations.

// AppServiceProvider::register()
$this->app
    ->when(\App\Http\Controllers\ReportController::class)
    ->needs(\App\Contracts\StorageDriver::class)
    ->give(\App\Services\S3StorageDriver::class);

$this->app
    ->when(\App\Console\Commands\ArchiveCommand::class)
    ->needs(\App\Contracts\StorageDriver::class)
    ->give(\App\Services\GcsStorageDriver::class);

Both consumers type-hint StorageDriver; the container silently injects the right driver. No factory, no if chain in the constructor.

Giving a primitive value contextually

$this->app
    ->when(\App\Services\MailgunMailer::class)
    ->needs('$apiKey')
    ->give(fn () => config('services.mailgun.key'));

This keeps environment-specific config out of constructors and out of service classes themselves.


Tagging

Tagging groups multiple bindings under a label so you can resolve all of them at once — perfect for plugin-style architectures, report generators, 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'
);
// Consume
class ExportManager
{
    /** @param iterable<ExporterContract> $exporters */
    public function __construct(
        private readonly iterable $exporters,
    ) {}

    public function export(string $format, mixed $data): string
    {
        foreach ($this->exporters as $exporter) {
            if ($exporter->supports($format)) {
                return $exporter->export($data);
            }
        }
        throw new \InvalidArgumentException("No exporter for {$format}");
    }
}
// Bind ExportManager with tagged collection
$this->app->bind(ExportManager::class, function ($app) {
    return new ExportManager($app->tagged('exporters'));
});

$app->tagged('exporters') returns a lazy TaggedIterator — instances are only resolved when iterated, keeping boot time low.


Method Injection

The container can resolve dependencies directly into any callable, not just constructors. This is how route closures and controller methods already work, but you can leverage it explicitly.

// Dispatch a closure with auto-resolved dependencies
app()->call(function (
    UserRepository $users,
    CacheManager $cache,
    int $userId = 42,
): void {
    $user = $users->find($userId);
    $cache->put("user:{$userId}", $user, 3600);
});

You can also call instance methods:

app()->call([$reportService, 'generate'], ['month' => 'June']);

The container merges type-hinted dependencies from the container with the explicitly passed primitives. This is particularly useful in console commands, Artisan closures, and test helpers where you want full DI without registering a class.

Practical pattern: action classes without constructor bloat

class GenerateInvoiceAction
{
    public function handle(
        Invoice $invoice,
        PdfRenderer $renderer,
        StorageDriver $storage,
    ): string {
        $pdf = $renderer->render($invoice);
        return $storage->put("invoices/{$invoice->id}.pdf", $pdf);
    }
}

// Caller
$path = app()->call(
    [app(GenerateInvoiceAction::class), 'handle'],
    ['invoice' => $invoice]
);

The action carries no constructor dependencies — they arrive at call time, making the class trivially testable with fakes.


Key Takeaways

  • Contextual binding eliminates conditional logic in constructors when the same interface needs different implementations per consumer.
  • Tagging enables open/closed plugin patterns — add a new exporter by registering and tagging it, zero changes to ExportManager.
  • $app->tagged() is lazy; prefer it over resolving all tagged services upfront.
  • Method injection via app()->call() is the cleanest way to give action classes their dependencies without coupling them to the container in constructors.
  • All three features are fully supported by Laravel's test helpers — swap bindings in setUp and your contextual or tagged services resolve fakes automatically.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does contextual binding work with interface type-hints or only concrete classes?
It works with both. The `needs()` call accepts an interface string just as well as a concrete class name, so you can contextually resolve different implementations of the same interface per consumer.
Q02 Is there a performance cost to using `app()->tagged()` with many bindings?
No significant cost at boot time because `tagged()` returns a lazy `TaggedIterator`. Concrete instances are only resolved when you iterate, so registering many tagged bindings does not slow down the container bootstrap.
Q03 Can I use method injection in Pest tests without bootstrapping the full application?
You need at least a partial application instance. In a Pest feature test the full app is available, so `app()->call()` works normally. For unit tests you can construct a minimal container, bind your fakes, and call the method — no HTTP kernel required.

Continue reading

More Articles

View all