Laravel 12 Service Providers vs. Deferred Providers: When Lazy Binding Actually Saves You
Not every service needs to boot on every request. Here's how Laravel 12's deferred providers work under the hood, where they genuinely help, and where they silently bite you.
The Problem With Eager Everything
In a default Laravel 12 application, every service provider listed in bootstrap/providers.php is instantiated and its register() method is called on every single request — whether that request ever touches the service or not. For a small app this is a non-issue. For an app with 40+ providers, some of which pull in heavy third-party SDKs, it adds up fast. I had a project recently where a Stripe SDK client and an OpenSearch client were both being wired up on every php artisan queue:work tick. Neither service was needed for 90% of queued jobs.
Deferred providers are Laravel's answer to this. They've existed since Laravel 5, but I see developers ignore them constantly — and I see them misused just as often. Laravel 12 didn't change the underlying mechanic, but the new php artisan about command and the revised service container internals make it a good time to revisit what's actually happening.
How Deferred Providers Actually Work
A deferred provider implements the DeferrableProvider interface and declares a provides() method listing the abstract bindings it registers. Laravel caches this manifest at bootstrap time — specifically in bootstrap/cache/services.php — and skips booting the provider entirely until something resolves one of those listed abstracts from the container.
<?php
namespace App\Providers;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
use App\Services\SearchClient;
use App\Contracts\Search;
class SearchServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
$this->app->singleton(Search::class, function ($app) {
return new SearchClient(
$app['config']['search.host'],
$app['config']['search.api_key'],
);
});
}
public function provides(): array
{
return [Search::class];
}
}The provides() return value is the cache key. If you bind additional aliases inside register() that aren't listed in provides(), they will not trigger the deferred load — the alias just won't exist until the primary abstract is resolved first. I've been caught by this twice.
The Manifest Cache — Don't Forget to Clear It
The services manifest is generated by php artisan optimize (which subsumes the old package:discover and config:cache into one command in Laravel 12). If you add a new deferred provider and wonder why it's still loading eagerly on every request, the answer is almost always a stale bootstrap/cache/services.php. Run php artisan optimize:clear then php artisan optimize and the manifest will reflect the new provider correctly.
In development I keep the cache disabled entirely — set CACHE_STORE=array in .env and avoid running optimize locally. Deferred providers still work without the cache; Laravel falls back to scanning all providers. But on production, the cache is what makes deferral meaningful.
Where Deferral Genuinely Helps
- Heavy SDK clients — AWS, Stripe, Twilio clients that build HTTP stacks on instantiation.
- Queue workers — most workers handle a narrow slice of the application; deferring unrelated service trees is a real win.
- Artisan commands — same reasoning. A
db:seedcommand shouldn't pay the cost of building your payment provider singleton. - Test suites — deferred providers mean fewer real service instantiations in unit tests that only bootstrap the application partially.
Where It Silently Bites You
Deferred providers and event listeners registered inside boot() do not mix cleanly. If your provider registers event listeners in its boot() method and the provider is deferred, those listeners won't be attached until the abstract is resolved — which may be after the event fires. I've seen this cause race conditions in queue pipelines. The fix is either to move event registration to a separate eager provider or to use Laravel's $listen array on a dedicated EventServiceProvider which is never deferred.
Similarly, any service that other providers depend on during their own boot() phase should never be deferred. The container will resolve it when asked, but if another eager provider calls app(Search::class) inside its boot(), the deferred provider will fire at that moment — which is fine technically, but it means you've lost the deferral benefit and introduced ordering sensitivity.
Checking What's Actually Deferred
As of Laravel 12, php artisan about doesn't directly list deferred providers, but inspecting bootstrap/cache/services.php after running optimize gives you the full manifest in plain PHP. Cross-reference that with your providers list and you'll know immediately which ones are genuinely deferred versus which ones you thought were deferred.
Deferred providers are a small tool, but applied deliberately — especially in queue-heavy or CLI-heavy applications — they're one of the cheapest performance improvements you can make with zero architectural change.