Beyond app()->make(): The Container You're Not Using Fully
Most Laravel developers know bind, singleton, and make. That covers 80% of day-to-day container use. The remaining 20% — contextual binding, tagged services, and method injection — is where the container earns its keep in complex domain code. This article walks through each with production-grade examples.
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(OrderProcessor::class)
->needs(PaymentGatewayInterface::class)
->give(StripeGateway::class);
$this->app
->when(SubscriptionRenewalJob::class)
->needs(PaymentGatewayInterface::class)
->give(BraintreeGateway::class);
Both OrderProcessor and SubscriptionRenewalJob type-hint PaymentGatewayInterface. The container resolves each to a different concrete without any conditional logic inside the classes themselves.
Giving a Primitive Contextually
Contextual binding isn't limited to interfaces. You can inject config values or primitive scalars:
$this->app
->when(S3Uploader::class)
->needs('$bucketName')
->give(fn () => config('filesystems.disks.s3.bucket'));
The $bucketName constructor parameter is resolved from config at build time — no config() call inside the class, no hidden coupling.
Tagged Services
Tagging lets you resolve a collection of implementations registered under a shared label. This is the clean alternative to a hand-rolled registry array.
// Register
$this->app->bind(CsvExporter::class);
$this->app->bind(JsonExporter::class);
$this->app->bind(XlsxExporter::class);
$this->app->tag(
[CsvExporter::class, JsonExporter::class, XlsxExporter::class],
'exporters'
);
// Resolve all tagged exporters
class ExportManager
{
/** @param iterable<ExporterInterface> $exporters */
public function __construct(
private readonly iterable $exporters
) {}
}
// In the service provider
$this->app->bind(ExportManager::class, fn ($app) =>
new ExportManager($app->tagged('exporters'))
);
$app->tagged() returns a lazy generator — implementations are not instantiated until iterated. Adding a new exporter is a one-line tag registration; ExportManager never changes.
Method Injection
The container can inject dependencies into arbitrary methods, not just constructors. This is useful for controller actions, console commands, and domain handlers where you want to keep the constructor lean.
class ReportController extends Controller
{
public function generate(
Request $request,
ReportBuilder $builder, // injected by container
CacheManager $cache // injected by container
): JsonResponse {
// ...
}
}
Laravel's router already does this for controller methods. You can invoke the same mechanism manually:
$result = app()->call(
[SomeService::class, 'handle'],
['extraParam' => $value] // merge with container-resolved args
);
This is powerful inside pipeline stages, command handlers, or test helpers where you want full DI without making the class a singleton.
Method Injection in Artisan Commands
class SyncInventoryCommand extends Command
{
public function handle(InventorySync $sync, LoggerInterface $log): int
{
$log->info('Starting sync');
$sync->run();
return self::SUCCESS;
}
}
Artisan resolves handle() through the container automatically. No constructor injection needed for command-specific dependencies.
Testing Contextual Bindings
Swap a contextual binding in a test without touching the service provider:
it('uses the sandbox gateway in tests', function () {
$this->app
->when(OrderProcessor::class)
->needs(PaymentGatewayInterface::class)
->give(SandboxGateway::class);
$processor = app(OrderProcessor::class);
expect($processor->gateway())->toBeInstanceOf(SandboxGateway::class);
});
The override is scoped to the test; no global state leaks.
Takeaways
- Contextual binding eliminates conditional logic inside classes when multiple implementations of an interface are needed in different contexts.
- Primitive injection (
needs('$param')) keeps config and environment values out of class bodies. - Tagged services replace hand-rolled registries with a lazy, extensible collection resolved by the container.
- Method injection via
app()->call()gives you full DI on any callable — useful in pipelines, handlers, and tests. - All of these are testable in isolation: swap bindings per-test without touching service providers.