Contextual Binding and Method Injection in Laravel's Service Container
#laravel #service-container #dependency-injection #architecture

Contextual Binding and Method Injection in Laravel's Service Container

3 min read Mohamed Said Mohamed Said

Why the Basics Are Not Enough

Most Laravel developers know app()->bind() and constructor injection. That covers 80 % of cases. The remaining 20 % — multiple implementations of the same interface, environment-specific drivers, or controller methods that need one-off dependencies — is where the container's advanced features earn their keep.


Contextual Binding

Contextual binding answers the question: "Which implementation should this specific class receive?"

// AppServiceProvider::register()
$this->app
    ->when(OrderExporter::class)
    ->needs(StorageContract::class)
    ->give(S3Storage::class);

$this->app
    ->when(ReportArchiver::class)
    ->needs(StorageContract::class)
    ->give(LocalStorage::class);

Both OrderExporter and ReportArchiver type-hint StorageContract. The container resolves each to a different concrete without touching either class. No factory, no if branch, no service locator.

Passing Primitive Values

Contextual binding also handles scalar config values:

$this->app
    ->when(SlackNotifier::class)
    ->needs('$webhookUrl')
    ->give(fn () => config('services.slack.webhook'));

The $webhookUrl constructor parameter is injected automatically. Combine this with giveConfig() (available since Laravel 10) for a one-liner:

$this->app
    ->when(SlackNotifier::class)
    ->needs('$webhookUrl')
    ->giveConfig('services.slack.webhook');

Tagging Services

When you need all implementations of a concept — think report generators, payment gateways, or notification channels — tagging is the right tool.

// Register
$this->app->bind(PdfReport::class);
$this->app->bind(CsvReport::class);
$this->app->bind(XlsxReport::class);

$this->app->tag(
    [PdfReport::class, CsvReport::class, XlsxReport::class],
    'reports'
);
// Consume
class ReportDispatcher
{
    public function __construct(
        /** @var ReportContract[] */
        private readonly iterable $reports,
    ) {}

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

    public function dispatch(string $format, array $data): void
    {
        foreach ($this->reports as $report) {
            if ($report->supports($format)) {
                $report->generate($data);
                return;
            }
        }
        throw new UnsupportedFormatException($format);
    }
}

$app->tagged() returns a lazy TaggedIterator — nothing is instantiated until the loop runs.


Method Injection

Constructor injection is the default, but the container can also inject into arbitrary methods. This is useful for controller actions, console commands, or one-off invokables where you don't want to pollute the constructor.

class GenerateMonthlyReport
{
    public function handle(
        Request $request,
        ReportDispatcher $dispatcher, // injected by container
        string $format = 'pdf',
    ): Response {
        $dispatcher->dispatch($format, $request->validated());
        return response()->noContent();
    }
}

// Resolve and call anywhere:
$result = app()->call(
    [app(GenerateMonthlyReport::class), 'handle'],
    ['format' => 'csv'] // override primitives
);

app()->call() merges your explicit parameters with whatever the container can resolve. This is exactly how Laravel's route model binding and controller dispatch work internally.

Invokable Classes

class SendWelcomeEmail
{
    public function __invoke(Mailer $mailer, User $user): void
    {
        $mailer->to($user)->send(new WelcomeMail($user));
    }
}

app()->call(SendWelcomeEmail::class, ['user' => $user]);

The container resolves Mailer from the IoC graph; you supply $user explicitly.


Practical Patterns

  • Feature flags per tenant: use contextual binding to swap a PricingStrategy based on the resolved tenant inside a when() closure.
  • Test doubles without mocking frameworks: bind a fake in setUp() using $this->app->instance(Contract::class, new FakeImpl()) — no Mockery needed for simple cases.
  • Deferred providers: wrap tagged registrations in a DeferrableProvider so the entire driver set is only loaded when first requested.

Takeaways

  • Contextual binding eliminates factory conditionals by moving the "which implementation" decision into the container.
  • giveConfig() is the cleanest way to inject scalar config into a single class.
  • Tagged services + tagged() give you a zero-cost open/closed extension point.
  • app()->call() enables method injection anywhere, not just in controllers.
  • Combine these features in a service provider, not scattered across the codebase.

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 choice of implementation depends solely on which class is being constructed. A factory is better when the decision requires runtime data (e.g., user input or a database value) that isn't available at container build time.
Q02 Does app()->call() work with static methods or closures?
Yes. app()->call() accepts any PHP callable: a [object, 'method'] array, a closure, a 'Class@method' string, or an invokable class name. The container injects type-hinted parameters for all forms.
Q03 Are tagged services instantiated eagerly when the tag is registered?
No. app()->tagged() returns a lazy TaggedIterator. Concrete classes are only instantiated when you iterate over the result, so registering many tagged drivers has no upfront cost.

Continue reading

More Articles

View all