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

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 how to bind an interface to a concrete class. Fewer use the container's more surgical tools — contextual binding, tagged groups, and method injection — which can dramatically simplify complex dependency graphs without introducing a service locator.


Contextual Binding

Contextual binding lets you resolve different implementations of the same interface depending on which class is requesting it. This is invaluable in multi-channel or multi-driver architectures.

// AppServiceProvider::register()
$this->app
    ->when(OrderNotificationService::class)
    ->needs(NotifierInterface::class)
    ->give(SlackNotifier::class);

$this->app
    ->when(InvoiceNotificationService::class)
    ->needs(NotifierInterface::class)
    ->give(EmailNotifier::class);

Both services type-hint NotifierInterface. The container resolves the correct driver per consumer — no factory, no if chain, no service locator.

You can also pass a closure for runtime logic:

$this->app
    ->when(ReportExporter::class)
    ->needs(StorageInterface::class)
    ->give(fn ($app) => $app->make(
        config('exports.driver') === 's3' ? S3Storage::class : LocalStorage::class
    ));

Tags let you group related bindings and resolve them all at once — perfect for plugin-style architectures, validators, or pipeline stages.

// Register
$this->app->bind(CsvImporter::class);
$this->app->bind(XmlImporter::class);
$this->app->bind(JsonImporter::class);

$this->app->tag(
    [CsvImporter::class, XmlImporter::class, JsonImporter::class],
    'importers'
);
// Resolve all tagged bindings
class ImportOrchestrator
{
    public function __construct(
        private readonly iterable $importers,
    ) {}

    public static function register(Application $app): void
    {
        $app->bind(self::class, fn ($app) => new self(
            $app->tagged('importers')
        ));
    }

    public function handle(string $type, mixed $payload): void
    {
        foreach ($this->importers as $importer) {
            if ($importer->supports($type)) {
                $importer->import($payload);
                return;
            }
        }

        throw new UnsupportedImportTypeException($type);
    }
}

Adding a new importer is a one-line registration — no changes to the orchestrator.


Method Injection

app()->call() resolves dependencies for any callable, not just constructors. This is useful for one-off invocations where you don't want a full class binding.

$result = app()->call(
    [ReportGenerator::class, 'generate'],
    ['format' => 'pdf']
);

The container resolves all type-hinted parameters automatically, merging them with the explicit ['format' => 'pdf'] array. Works equally well with closures:

$result = app()->call(function (UserRepository $repo, string $format) {
    return $repo->exportAll($format);
}, ['format' => 'csv']);

This pattern is used internally by Laravel's route model binding and controller dispatch — you can leverage it in console commands, custom runners, or test helpers.


Contextual Attributes (PHP 8.x)

Laravel 11+ supports #[\Illuminate\Container\Attributes\Config] and custom contextual attributes, letting you inject config values or tagged collections declaratively:

use Illuminate\Container\Attributes\Config;

class PaymentGateway
{
    public function __construct(
        #[Config('services.stripe.secret')] private string $secret,
    ) {}
}

You can create your own attribute by implementing ContextualAttribute and registering a resolver — a clean alternative to constructor pollution with scalar values.


Takeaways

  • Contextual binding eliminates factory classes and if/switch driver selection by letting the container decide per consumer.
  • Tagging enables open/closed plugin systems — new implementations register themselves without touching orchestrators.
  • app()->call() is underused for ad-hoc dependency resolution in commands, test helpers, and event listeners.
  • Contextual attributes (Laravel 11+) keep constructors clean when injecting scalar config values.
  • Prefer these container features over service locator calls (app(Foo::class) inside methods) to keep dependencies explicit and testable.

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 purely about which consumer is requesting the dependency and the selection logic is static or config-driven. Factories are better when the variation depends on runtime data (e.g., a value from the database) that isn't available at container build time.
Q02 Does resolving tagged bindings instantiate all of them eagerly?
Yes — `$app->tagged('importers')` returns a generator-like `ContextualBindingBuilder` that resolves each binding on iteration, but all tagged classes will be instantiated as you iterate. If instantiation is expensive, consider lazy wrappers or resolving only the matching implementation.
Q03 Can I use method injection in Artisan commands?
Artisan commands use constructor injection for their dependencies. Method injection via `app()->call()` is available but you'd invoke it manually inside `handle()`. A cleaner pattern is to inject a single orchestrator or action class into the constructor and delegate there.

Continue reading

More Articles

View all