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
PricingStrategybased on the resolved tenant inside awhen()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
DeferrableProviderso 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.