Beyond app()->make(): The Container Features Most Devs Skip
The Laravel service container is the backbone of the framework, yet most codebases only scratch its surface with simple bind and singleton calls. Three features — contextual binding, tagging, and method injection — unlock genuinely cleaner architecture when applied deliberately.
Contextual Binding
Contextual binding lets you resolve a different concrete class depending on which class is requesting it. This is invaluable when two subsystems share an interface but need distinct implementations.
// AppServiceProvider::register()
$this->app
->when(\App\Http\Controllers\ReportController::class)
->needs(\App\Contracts\StorageDriver::class)
->give(\App\Services\S3StorageDriver::class);
$this->app
->when(\App\Console\Commands\ArchiveCommand::class)
->needs(\App\Contracts\StorageDriver::class)
->give(\App\Services\GcsStorageDriver::class);
Both consumers type-hint StorageDriver; the container silently injects the right driver. No factory, no if chain in the constructor.
Giving a primitive value contextually
$this->app
->when(\App\Services\MailgunMailer::class)
->needs('$apiKey')
->give(fn () => config('services.mailgun.key'));
This keeps environment-specific config out of constructors and out of service classes themselves.
Tagging
Tagging groups multiple bindings under a label so you can resolve all of them at once — perfect for plugin-style architectures, report generators, 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'
);
// Consume
class ExportManager
{
/** @param iterable<ExporterContract> $exporters */
public function __construct(
private readonly iterable $exporters,
) {}
public function export(string $format, mixed $data): string
{
foreach ($this->exporters as $exporter) {
if ($exporter->supports($format)) {
return $exporter->export($data);
}
}
throw new \InvalidArgumentException("No exporter for {$format}");
}
}
// Bind ExportManager with tagged collection
$this->app->bind(ExportManager::class, function ($app) {
return new ExportManager($app->tagged('exporters'));
});
$app->tagged('exporters') returns a lazy TaggedIterator — instances are only resolved when iterated, keeping boot time low.
Method Injection
The container can resolve dependencies directly into any callable, not just constructors. This is how route closures and controller methods already work, but you can leverage it explicitly.
// Dispatch a closure with auto-resolved dependencies
app()->call(function (
UserRepository $users,
CacheManager $cache,
int $userId = 42,
): void {
$user = $users->find($userId);
$cache->put("user:{$userId}", $user, 3600);
});
You can also call instance methods:
app()->call([$reportService, 'generate'], ['month' => 'June']);
The container merges type-hinted dependencies from the container with the explicitly passed primitives. This is particularly useful in console commands, Artisan closures, and test helpers where you want full DI without registering a class.
Practical pattern: action classes without constructor bloat
class GenerateInvoiceAction
{
public function handle(
Invoice $invoice,
PdfRenderer $renderer,
StorageDriver $storage,
): string {
$pdf = $renderer->render($invoice);
return $storage->put("invoices/{$invoice->id}.pdf", $pdf);
}
}
// Caller
$path = app()->call(
[app(GenerateInvoiceAction::class), 'handle'],
['invoice' => $invoice]
);
The action carries no constructor dependencies — they arrive at call time, making the class trivially testable with fakes.
Key Takeaways
- Contextual binding eliminates conditional logic in constructors when the same interface needs different implementations per consumer.
- Tagging enables open/closed plugin patterns — add a new exporter by registering and tagging it, zero changes to
ExportManager. $app->tagged()is lazy; prefer it over resolving all tagged services upfront.- Method injection via
app()->call()is the cleanest way to give action classes their dependencies without coupling them to the container in constructors. - All three features are fully supported by Laravel's test helpers — swap bindings in
setUpand your contextual or tagged services resolve fakes automatically.