Beyond app()->make(): The Container Features Most Teams Ignore
Most Laravel developers register a singleton, call app(MyService::class), and move on. The container is capable of far more nuanced wiring — and using it well is the difference between a codebase that scales and one that turns into a ball of mud.
Contextual Binding
Contextual binding answers the question: "What should the container inject when class A asks for interface X, versus when class B asks for the same interface?"
// AppServiceProvider::register()
$this->app
->when(ReportExporter::class)
->needs(StorageDriver::class)
->give(S3StorageDriver::class);
$this->app
->when(LocalPreviewGenerator::class)
->needs(StorageDriver::class)
->give(LocalStorageDriver::class);
Both classes declare __construct(StorageDriver $storage). The container resolves the correct concrete without a single if statement in application code. This is particularly powerful in multi-tenant SaaS where different panels may need different implementations of the same interface.
You can also supply a closure for runtime logic:
$this->app
->when(InvoicePdfRenderer::class)
->needs(FontResolver::class)
->give(fn ($app) => new FontResolver(
config('pdf.font_path'),
$app->make(CacheManager::class)
));
Tagging: Collecting Related Bindings
Tags let you group bindings under a label and resolve all of them at once — ideal for plugin architectures, report generators, or notification channels.
// Register
$this->app->bind(SlackNotifier::class);
$this->app->bind(EmailNotifier::class);
$this->app->bind(WebhookNotifier::class);
$this->app->tag(
[SlackNotifier::class, EmailNotifier::class, WebhookNotifier::class],
'notifiers'
);
// Resolve all tagged bindings
class NotificationDispatcher
{
/** @param iterable<NotifierContract> $notifiers */
public function __construct(
private readonly iterable $notifiers
) {}
public function dispatch(Notification $notification): void
{
foreach ($this->notifiers as $notifier) {
$notifier->send($notification);
}
}
}
// Wire it up
$this->app->bind(NotificationDispatcher::class, fn ($app) =>
new NotificationDispatcher($app->tagged('notifiers'))
);
$app->tagged() returns a lazy TaggedIterator, so bindings are not instantiated until the loop runs. Adding a new notifier is a one-line tag registration — no modification to NotificationDispatcher.
Method Injection
The container can resolve dependencies for arbitrary method calls, not just constructors. This is how route model binding and controller methods work internally.
class ReportController
{
public function generate(
Request $request,
ReportBuilder $builder, // injected by container
AuditLogger $audit // injected by container
): JsonResponse {
$audit->log('report.generate', $request->user());
return response()->json($builder->build($request->validated()));
}
}
You can invoke this pattern yourself with app()->call():
$result = app()->call(
[new SomeService(), 'handle'],
['extraParam' => 'value'] // merged with container-resolved args
);
This is useful in console commands, job handlers, or anywhere you want to defer resolution until call time rather than construction time.
Avoiding the Service Locator Anti-Pattern
The temptation after learning app()->make() is to call it everywhere. Resist it. Inject through constructors or method signatures so dependencies are explicit and mockable in tests:
// Bad: hidden dependency
class OrderProcessor
{
public function process(Order $order): void
{
$mailer = app(Mailer::class); // invisible, untestable
$mailer->send(...);
}
}
// Good: declared dependency
class OrderProcessor
{
public function __construct(private readonly Mailer $mailer) {}
public function process(Order $order): void
{
$this->mailer->send(...);
}
}
The container wires it; your class stays ignorant of the container.
Takeaways
- Contextual binding resolves the same interface to different concretes per consumer — no conditionals in application code.
- Tagging enables open/closed plugin architectures; new implementations register themselves without touching existing classes.
- Method injection via
app()->call()defers resolution to invocation time and keeps constructors lean. - Never call
app()inside domain classes — that couples your domain to the framework and kills testability. - Service providers are the correct place for all container wiring; keep them focused and split large providers by domain.