Beyond app()->make(): The Container Features You're Probably Underusing
Most Laravel developers know how to bind an interface to a concrete class. Fewer know how to tell the container to resolve different implementations depending on who is asking, group related services under a tag, or inject dependencies directly into arbitrary methods without a constructor. These three features — contextual binding, tagging, and method injection — unlock a cleaner architecture that scales without a service locator smell.
Contextual Binding
Contextual binding answers the question: "Give class A one implementation, but give class B a different one."
// AppServiceProvider::register()
$this->app
->when(OrderExporter::class)
->needs(FilesystemInterface::class)
->give(fn () => Storage::disk('s3'));
$this->app
->when(ReportGenerator::class)
->needs(FilesystemInterface::class)
->give(fn () => Storage::disk('local'));
Both classes type-hint FilesystemInterface, but the container resolves the correct disk for each. No factory, no service locator, no if chain inside the class.
Contextual Primitives
You can also inject scalar values contextually:
$this->app
->when(StripeGateway::class)
->needs('$apiKey')
->giveConfig('services.stripe.secret');
This keeps environment-specific values out of constructors and config facades inside domain classes.
Tagging
Tagging lets you group related bindings and resolve them all at once — perfect for plugin-style architectures, notification channels, or report drivers.
// Register
$this->app->bind(SlackNotifier::class);
$this->app->bind(EmailNotifier::class);
$this->app->bind(SmsNotifier::class);
$this->app->tag(
[SlackNotifier::class, EmailNotifier::class, SmsNotifier::class],
'notifiers'
);
// Resolve all tagged services
class BroadcastService
{
public function __construct(
private iterable $notifiers
) {}
public function notify(string $message): void
{
foreach ($this->notifiers as $notifier) {
$notifier->send($message);
}
}
}
// Bind the iterable via tagged
$this->app->bind(BroadcastService::class, function ($app) {
return new BroadcastService($app->tagged('notifiers'));
});
$app->tagged('notifiers') returns a lazy TaggedIterator — services are only resolved when iterated, keeping boot time low.
Package Integration Pattern
Packages can push their own implementations into a host application's tag without modifying the host's service provider:
// In the package's service provider
$this->app->tag(PushNotifier::class, 'notifiers');
The host's BroadcastService picks it up automatically.
Method Injection
The container can resolve dependencies for any callable, not just constructors. This is how route closures, controller methods, and console commands work internally.
$result = app()->call(
[ReportService::class, 'generate'],
['format' => 'pdf']
);
The container resolves ReportService and all type-hinted parameters of generate(), then merges in the explicitly passed $format.
Practical Use: Pipeline Steps Without Constructor Injection
class EnrichOrderData
{
public function handle(
Order $order,
TaxCalculator $tax, // resolved by container
CurrencyConverter $fx // resolved by container
): Order {
$order->tax = $tax->calculate($order);
$order->total = $fx->convert($order->subtotal, $order->currency);
return $order;
}
}
// Invoke it
$enriched = app()->call([new EnrichOrderData, 'handle'], ['order' => $order]);
This is cleaner than injecting every dependency into the constructor when a step only needs them once.
Testing Implications
All three features make testing straightforward:
// Swap a contextual binding in a test
app()->when(OrderExporter::class)
->needs(FilesystemInterface::class)
->give(fn () => new FakeFilesystem);
// Or use the standard swap
app()->instance(FilesystemInterface::class, new FakeFilesystem);
Tagged services can be replaced by re-tagging a fake before the test runs. Method injection means you can call pipeline steps directly with mocked dependencies without rebuilding the whole object graph.
Takeaways
- Contextual binding eliminates factory classes and
ifchains when the same interface needs different implementations in different contexts. - Scalar contextual binding (
needs('$apiKey')) keeps config out of domain classes cleanly. - Tagging enables open/closed plugin architectures — new implementations register themselves without touching existing code.
$app->tagged()is lazy; services are instantiated only when iterated.- Method injection via
app()->call()is the container's most underused feature for pipeline steps, console commands, and ad-hoc service invocations. - All three patterns improve testability by keeping the container as the single source of truth for dependency resolution.