Beyond app()->make(): The Container Features You're Probably Underusing
Most Laravel developers know bind, singleton, and make. What separates a well-architected application from a tangled one is often the other container features: contextual binding, tagging, and method injection. Let's go deep on each.
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(ReportExporter::class)
->needs(StorageInterface::class)
->give(S3Storage::class);
$this->app
->when(LocalPreviewGenerator::class)
->needs(StorageInterface::class)
->give(LocalDiskStorage::class);
Both classes declare StorageInterface in their constructors. The container resolves the correct implementation based on who is asking, not just what is needed. No factory, no service locator, no if branch in a shared provider.
You can also pass a closure for runtime logic:
$this->app
->when(TenantMailer::class)
->needs(TransportInterface::class)
->give(function ($app) {
return $app->make(
config('mail.tenant_transport') === 'ses'
? SesTransport::class
: SmtpTransport::class
);
});
Contextual Primitives
Since Laravel 10 you can also inject primitive values contextually using giveConfig or a plain closure:
$this->app
->when(StripeGateway::class)
->needs('$apiKey')
->giveConfig('services.stripe.secret');
This removes the need for a dedicated config-reading constructor or a value object just to carry a string.
Container Tagging
Tagging lets you group related bindings and resolve them all at once — perfect for plugin systems, report drivers, 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'
);
// Resolve all tagged bindings
class ExportManager
{
/** @param iterable<ExporterInterface> $exporters */
public function __construct(
private readonly iterable $exporters
) {}
}
// In the provider
$this->app->bind(ExportManager::class, function ($app) {
return new ExportManager($app->tagged('exporters'));
});
$app->tagged() returns a lazy Generator, so bindings are not instantiated until iterated. This matters when exporters have heavy constructors.
Method Injection
The container can inject dependencies into arbitrary methods, not just constructors. This is how route closures and controller methods work internally, and you can use the same mechanism in your own code.
class ReportController
{
public function generate(
Request $request,
ReportBuilder $builder, // injected by container
AuditLogger $logger // injected by container
): JsonResponse {
// ...
}
}
You can call any callable through the container with app()->call():
$result = app()->call(
[new InvoiceProcessor(), 'process'],
['invoiceId' => $id] // extra primitives merged in
);
This is particularly useful in pipeline stages, console commands, or action classes where you want the container to satisfy type-hinted dependencies without making every class a full service.
// Action resolved and called without manual wiring
$result = app()->call(GenerateInvoiceAction::class, [
'order' => $order,
]);
Practical Takeaways
- Contextual binding eliminates conditional logic in providers when the same interface needs different implementations per consumer.
giveConfigkeeps primitive injection declarative and avoids leaking config calls into constructors.- Tagging is the cleanest way to implement open/closed plugin systems — add a new driver by registering and tagging it, nothing else changes.
app()->call()gives you full DI on any callable, making action classes and pipeline stages first-class container citizens.- Prefer contextual binding over abstract factories when the variation is per-consumer, not per-runtime-value.