Beyond app()->make(): The Container Features You're Probably Underusing
Most Laravel developers know how to bind an interface to a concrete class. Fewer use the container's more surgical tools — contextual binding, tagged groups, and method injection — which can dramatically simplify complex dependency graphs without introducing a service locator.
Contextual Binding
Contextual binding lets you resolve different implementations of the same interface depending on which class is requesting it. This is invaluable in multi-channel or multi-driver architectures.
// AppServiceProvider::register()
$this->app
->when(OrderNotificationService::class)
->needs(NotifierInterface::class)
->give(SlackNotifier::class);
$this->app
->when(InvoiceNotificationService::class)
->needs(NotifierInterface::class)
->give(EmailNotifier::class);
Both services type-hint NotifierInterface. The container resolves the correct driver per consumer — no factory, no if chain, no service locator.
You can also pass a closure for runtime logic:
$this->app
->when(ReportExporter::class)
->needs(StorageInterface::class)
->give(fn ($app) => $app->make(
config('exports.driver') === 's3' ? S3Storage::class : LocalStorage::class
));
Tagging: Collecting Related Bindings
Tags let you group related bindings and resolve them all at once — perfect for plugin-style architectures, validators, or pipeline stages.
// Register
$this->app->bind(CsvImporter::class);
$this->app->bind(XmlImporter::class);
$this->app->bind(JsonImporter::class);
$this->app->tag(
[CsvImporter::class, XmlImporter::class, JsonImporter::class],
'importers'
);
// Resolve all tagged bindings
class ImportOrchestrator
{
public function __construct(
private readonly iterable $importers,
) {}
public static function register(Application $app): void
{
$app->bind(self::class, fn ($app) => new self(
$app->tagged('importers')
));
}
public function handle(string $type, mixed $payload): void
{
foreach ($this->importers as $importer) {
if ($importer->supports($type)) {
$importer->import($payload);
return;
}
}
throw new UnsupportedImportTypeException($type);
}
}
Adding a new importer is a one-line registration — no changes to the orchestrator.
Method Injection
app()->call() resolves dependencies for any callable, not just constructors. This is useful for one-off invocations where you don't want a full class binding.
$result = app()->call(
[ReportGenerator::class, 'generate'],
['format' => 'pdf']
);
The container resolves all type-hinted parameters automatically, merging them with the explicit ['format' => 'pdf'] array. Works equally well with closures:
$result = app()->call(function (UserRepository $repo, string $format) {
return $repo->exportAll($format);
}, ['format' => 'csv']);
This pattern is used internally by Laravel's route model binding and controller dispatch — you can leverage it in console commands, custom runners, or test helpers.
Contextual Attributes (PHP 8.x)
Laravel 11+ supports #[\Illuminate\Container\Attributes\Config] and custom contextual attributes, letting you inject config values or tagged collections declaratively:
use Illuminate\Container\Attributes\Config;
class PaymentGateway
{
public function __construct(
#[Config('services.stripe.secret')] private string $secret,
) {}
}
You can create your own attribute by implementing ContextualAttribute and registering a resolver — a clean alternative to constructor pollution with scalar values.
Takeaways
- Contextual binding eliminates factory classes and
if/switchdriver selection by letting the container decide per consumer. - Tagging enables open/closed plugin systems — new implementations register themselves without touching orchestrators.
app()->call()is underused for ad-hoc dependency resolution in commands, test helpers, and event listeners.- Contextual attributes (Laravel 11+) keep constructors clean when injecting scalar config values.
- Prefer these container features over service locator calls (
app(Foo::class)inside methods) to keep dependencies explicit and testable.