Dependency Injection in Laravel 10, Container Patterns That Earn Their Keep
TL;DR — Laravel’s container does more than you probably use / Contextual bindings replace 90% of the “service locator” anti-pattern / Tagged and scoped bindings exist; learn them before reaching for a third-party DI library
Laravel’s service container has a reputation for being magical. Some of that is fair — auto-resolution feels like sleight of hand the first time you see it. Some of it is the docs’ fault, because the features that earn their keep in real codebases (contextual binding, tagged services, scoped instances) are buried under the basics.
I want to focus on the patterns I actually reach for in production. Each one solves a real problem, each one has an idiomatic Laravel implementation, and each one is undocumented enough that I see senior engineers reinvent the wheel for them. After holding the line on domain layer purity, the DI wiring is where the rubber meets the road.
If your service provider has more than 30 lines of $this->app->bind(...) calls, this post is for you.
The Baseline: Interface to Implementation
Every Laravel codebase has this. It is fine:
public function register(): void
{
$this->app->bind(
\App\Domain\Billing\InvoiceRepository::class,
\App\Infrastructure\Billing\EloquentInvoiceRepository::class,
);
}
When something asks for InvoiceRepository via constructor injection, the container constructs EloquentInvoiceRepository. Default. Boring. Works.
Two variations worth knowing:
// singleton: same instance returned each time within the request
$this->app->singleton(InvoiceRepository::class, EloquentInvoiceRepository::class);
// closure: when construction is non-trivial
$this->app->singleton(StripeClient::class, function (Container $app) {
return new StripeClient(
secret: $app->make(Config::class)->get('services.stripe.secret'),
timeout: 5,
retries: 3,
);
});
Use singleton for stateless services, expensive constructions (HTTP clients with connection pools), and anything that holds an in-memory cache. Use bind (the default) for things that hold mutable state per use.
Contextual Binding: The Underused Hero
This is the feature most teams do not know exists, and it solves the most common DI problem cleanly: “I want the same interface to resolve to different implementations depending on who is asking.”
Concrete case: we have two storage backends, S3 for user uploads and a self-hosted MinIO for system-generated PDFs that should never leak to a CDN. Both implement App\Domain\Storage\BlobStorage.
public function register(): void
{
$this->app->when(\App\Application\Pdf\InvoicePdfRenderer::class)
->needs(BlobStorage::class)
->give(MinioBlobStorage::class);
$this->app->when(\App\Application\Uploads\UserUploadHandler::class)
->needs(BlobStorage::class)
->give(S3BlobStorage::class);
}
Now InvoicePdfRenderer gets MinIO; UserUploadHandler gets S3. Neither one knows the difference; both just receive a BlobStorage. No service locator, no factory class, no global config switch.
A more interesting variant — contextual binding by tag:
$this->app->when(InvoicePdfRenderer::class)
->needs('$tempDirectory')
->give(fn () => storage_path('app/tmp/invoices'));
$this->app->when(ReportPdfRenderer::class)
->needs('$tempDirectory')
->give(fn () => storage_path('app/tmp/reports'));
Notice the $ prefix — that is the syntax for binding to a parameter name rather than a type. Useful for primitives where type-based resolution does not work.
I have replaced entire Factory classes with three lines of contextual binding. The factory existed only to give a different implementation depending on context; the container can do that itself.
Tagged Services: Plugin Architectures
When you have a variable-length set of “things that all do X”, tagging is the right answer. We use this for our notification system — every notification type registers itself, and the dispatcher gets the list at runtime:
// in each NotifierServiceProvider:
public function register(): void
{
$this->app->singleton(SlackNotifier::class);
$this->app->singleton(EmailNotifier::class);
$this->app->singleton(SmsNotifier::class);
$this->app->tag([
SlackNotifier::class,
EmailNotifier::class,
SmsNotifier::class,
], 'notifiers');
}
// in NotificationDispatcher:
public function __construct(
#[Tag('notifiers')] private readonly iterable $notifiers,
) {}
public function dispatch(Notification $n): void
{
foreach ($this->notifiers as $notifier) {
if ($notifier->supports($n)) {
$notifier->send($n);
}
}
}
The #[Tag] attribute is a Laravel 10 addition that makes this clean. Before it, you had to do $app->make(NotificationDispatcher::class, ['notifiers' => $app->tagged('notifiers')]), which is uglier.
Adding a new notifier becomes: write the class, register it, tag it. The dispatcher does not change. No switch statement to update. This is the kind of decoupling that hexagonal architecture promises and that the container delivers without ceremony.
Scoped Instances: The Subtle One
Laravel 10 has three lifetime scopes:
bind(transient — new instance every resolve)singleton(one instance per application)scoped(one instance per request / Octane worker / job)
The last one is the subtle one and the one that matters in long-running processes. If you run on Octane, Vapor, or in a queue worker, “singleton” means “until the process restarts” — which could be hours. scoped resets per request.
$this->app->scoped(RequestContext::class, function ($app) {
return new RequestContext(
userId: auth()->id(),
traceId: request()->header('X-Trace-Id') ?? Str::uuid()->toString(),
startedAt: now(),
);
});
Before Octane, you could get away with singleton here because the process died at request end. After Octane, the same code would leak the previous request’s user ID into the next request. scoped fixes it. If you have not audited your singletons since enabling Octane, do it.
A real bug we shipped and then fixed: a singleton Audit service that held a currentActor field. Worked fine on FPM; once Octane was on, action attributions started showing the wrong user. The fix was changing the binding from singleton to scoped, two characters.
Method Injection in Controllers
Most teams use constructor injection in controllers and stop there. Laravel also resolves dependencies on individual action methods:
class InvoiceController
{
public function pay(
Request $request,
string $invoiceId,
MarkInvoicePaidHandler $handler, // <-- injected per-action
AuditLog $audit, // <-- injected per-action
): JsonResponse {
$handler(new MarkInvoicePaidCommand(new InvoiceId($invoiceId)));
$audit->record('invoice.payment.attempted', ['invoice' => $invoiceId]);
return response()->json(status: 204);
}
}
The benefit of method injection over constructor injection in controllers: actions that do not need a dependency do not get one. If MarkInvoicePaidHandler is expensive to construct (it is not, usually, but hypothetically), only the pay action pays the cost.
The other benefit: when you have a fat controller with 10 actions, the constructor stops growing. Each action declares what it needs.
I do not use this for application services in the domain layer, but for HTTP-adjacent dependencies in controllers, it is the right default.
When to Avoid the Container
The container is a tool. It can be wrong:
- Inside a tight loop.
app(SomeService::class)insideforeachresolves the binding every iteration. Resolve once outside the loop and pass it in. - Inside value objects and entities. Domain objects should never see the container. The whole point of dependency injection is they receive what they need; if they reach for the container, you have a service locator.
- Inside long-running jobs. Octane and Vapor change the semantics of resolution. Test job behaviour explicitly, not just unit tests of the handler.
- For things that should be configuration. If you find yourself binding strings to constants via the container, you want
config()or a typed config object.
The service locator anti-pattern is the one to watch. It looks like app(Mailer::class)->send(...) inside a class that has no constructor parameters. The class has hidden dependencies — you cannot tell what it needs without reading every line. Constructor injection makes the dependencies obvious.
Common Pitfalls
The recurring ones from code reviews:
newinside a service.new HttpClient()inside a service that you registered with the container is a missed injection point. The container cannot replace what it does not construct.- Conditional bindings via
if (app()->environment()). Use environment-specific service providers instead.register()inLocalServiceProvideronly loads in local. Cleaner, testable. - Circular bindings. Service A needs B, B needs C, C needs A. The container throws on this, but the error message is opaque. Break the cycle by injecting a factory closure instead of the direct dependency.
- Binding the wrong side of an interface.
bind(EloquentInvoiceRepository::class, EloquentInvoiceRepository::class)is a no-op. You meantbind(InvoiceRepository::class, EloquentInvoiceRepository::class). The diagnostic: if your domain code type-hints the concrete class, you missed the interface. - Forgetting
--optimizein production.php artisan optimizecompiles route, view, and config caches. Without it, the container is doing a lot more work per request than it needs to. - Macros and singletons. Macros are global state. Registering a macro inside a singleton’s constructor means the macro registration runs once, which is usually what you want, but if the singleton is
scoped, the macro re-registers per request. Confusing. Register macros inboot(), never in a constructor.
The “new inside a service” pattern is the most common one I flag in code review. It usually started with “I just need a new DateTimeImmutable()” which is fine, then grew to “I need an HttpClient for this one call” which is not. Inject everything that has a non-trivial constructor.
A Practical Provider Layout
For a medium-sized codebase, I split providers by bounded context, not by technical concern:
app/Providers/
AppServiceProvider.php <-- truly app-wide things only
BillingServiceProvider.php <-- bindings for the billing context
TenancyServiceProvider.php
NotificationsServiceProvider.php
IntegrationsServiceProvider.php
Each provider knows its context. BillingServiceProvider binds InvoiceRepository, PaymentGateway, BillingClock. It does not know about tenants or notifications. When you delete the billing module, you delete one provider, not nine lines from a 400-line AppServiceProvider.
class BillingServiceProvider extends ServiceProvider
{
public array $singletons = [
InvoiceRepository::class => EloquentInvoiceRepository::class,
PaymentGateway::class => StripePaymentGateway::class,
BillingClock::class => SystemBillingClock::class,
];
public function register(): void
{
$this->app->when(StripePaymentGateway::class)
->needs('$secret')
->give(fn () => config('services.stripe.secret'));
}
public function boot(): void
{
// event listeners, route bindings, etc.
}
}
The public $singletons array property is a Laravel 10 shortcut for simple bindings — no closure needed, container reads the property. For 80% of bindings, this is enough. The full Laravel container documentation lists every feature; most teams use a third of them.
Wrapping Up
Laravel’s container is genuinely capable. The patterns that pay off are not the basic ones from the docs intro — they are contextual binding, tagging, and proper scope selection. Learning these well replaces a lot of “framework patterns” people import from elsewhere, including most uses of factory classes and service locators.
Next post I want to get into something subtler: how to decouple your application code from Eloquent specifically. The container helps, but Eloquent has gravity, and resisting it is its own skill.