Laravel Boot Order: The Class-Resolution Bugs That Only Bite in Production
Service provider boot order in Laravel looks harmless until it silently breaks class resolution in production. Here's what I learned the hard way.
Twice in the last three years I've pushed what looked like a clean deploy and watched a feature silently stop working in production — no exception, no log entry, just wrong behavior. Both times the root cause was service provider boot order. Not a missing env var, not a caching issue, not a race condition. Just providers resolving classes in an order that worked fine locally and failed quietly in prod.
This one is worth writing down because it's not obvious, the official docs don't dwell on it, and when it bites you it looks like a dozen other things first.
What Service Providers Actually Do (The Part That Matters Here)
You probably know the basics: register() binds things into the container, boot() uses things from the container. The contract is that all register() calls across every provider run before any boot() calls. That's Laravel's guarantee, and it mostly holds.
What the docs don't stress enough: the order within register() and within boot() is determined by the order providers appear in config/app.php — and by any implicit ordering from package discovery. If Provider B's boot() depends on a binding that Provider A's register() sets up, you're fine as long as A comes before B in the provider list. But if that ordering ever slips, you get behavior that depends on load order, and load order is not always stable across environments.
The subtle version of this isn't "I forgot to register something." It's "I registered something, but it gets overwritten or decorated by a later provider, and in production the optimized bootstrap changes which one wins."
The Bug Pattern I've Seen Twice
Here's a simplified version of what happened on a healthcare client's app. We had a custom mailer stack — a concrete TransactionalMailer class that got decorated with a AuditingMailer wrapper for HIPAA logging, then bound into the container as the Mailer contract.
Provider A (our MailServiceProvider):
// app/Providers/MailServiceProvider.php
public function register(): void
{
$this->app->singleton(Mailer::class, function ($app) {
return new TransactionalMailer(
$app->make(MailgunTransport::class),
$app->make(TemplateRenderer::class)
);
});
}
Provider B (our AuditServiceProvider), which we added three months later:
// app/Providers/AuditServiceProvider.php
public function register(): void
{
$this->app->extend(Mailer::class, function (Mailer $mailer, $app) {
return new AuditingMailer($mailer, $app->make(AuditLog::class));
});
}
Locally, MailServiceProvider appeared first in config/app.php. AuditServiceProvider was listed later. Everything worked: TransactionalMailer got registered, then AuditingMailer wrapped it.
In production we had the package discovery cache (bootstrap/cache/packages.php) plus the compiled service manifest (bootstrap/cache/services.php). A composer update had added a new first-party package whose provider registered after ours in the manifest, and somehow during a merge conflict resolution, AuditServiceProvider ended up listed before MailServiceProvider in config/app.php.
extend() ran before the base binding existed. Laravel doesn't throw on this — it queues the extension and applies it when the binding is first resolved. In practice that meant our AuditingMailer wrapper was being applied to Laravel's default Mailer, not our TransactionalMailer. No exception. Emails sent fine. Audit log had wrong metadata. We found it two weeks later during a compliance review.
How to Actually Debug This
First, dump the real resolution order. I keep this as an Artisan command on projects where DI complexity is high:
// app/Console/Commands/DiagnoseBindings.php
public function handle(): void
{
$bindings = [
Mailer::class,
SomeRepository::class,
// ... whatever you're suspicious of
];
foreach ($bindings as $abstract) {
$resolved = app($abstract);
$this->line(sprintf(
'%s => %s',
class_basename($abstract),
get_class($resolved)
));
}
}
Run this locally and in production after every deploy. If the class names differ, you have an ordering problem.
Second, check your compiled manifest against your config/app.php:
php artisan package:discover --ansi
cat bootstrap/cache/packages.php
Package-discovered providers are prepended or appended depending on the package's extra.laravel.providers declaration. They can slot in between your providers in ways you didn't intend.
Third — and this is the one I wish I'd been doing from the start — write a feature test that resolves the binding and asserts the concrete class:
// tests/Feature/ContainerBindingTest.php
public function test_mailer_resolves_to_auditing_wrapper(): void
{
$mailer = app(Mailer::class);
$this->assertInstanceOf(AuditingMailer::class, $mailer);
// Also assert the inner implementation
$reflection = new ReflectionProperty(AuditingMailer::class, 'inner');
$reflection->setAccessible(true);
$this->assertInstanceOf(TransactionalMailer::class, $reflection->getValue($mailer));
}
This test would have caught our issue in CI before it ever reached production. I've added it to my standard project scaffolding.
The boot() Timing Trap
Separate from registration order, there's a boot() trap that shows up when you do something like registering a view composer or an event listener that depends on a service that another provider's boot() sets up.
Classic example: you use boot() to register a route model binding that resolves via a repository, and that repository's concrete implementation gets configured in another provider's boot(). Locally, auto-discovery loads your providers in alphabetical order and it works. On the server, after php artisan optimize, the cached order is different.
// Don't do this in boot() if SomeService is configured in another provider's boot()
public function boot(): void
{
Route::bind('invoice', function ($value) {
// This resolves SomeService — what if it's not fully booted?
return app(InvoiceRepository::class)->findBySlug($value);
});
}
The fix is to use a closure that defers resolution until the route is actually hit, which it already does above — but if InvoiceRepository itself has a constructor dependency that's configured in another provider's boot(), you can still get caught. The safest version is to push complex boot-time wiring into register() as singleton closures, so the actual construction is deferred until first resolution, not until boot.
When the Optimized Bootstrap Changes Everything
php artisan optimize (or config:cache + route:cache + event:cache) collapses your bootstrap in ways that can reorder things. I've seen config:cache cause issues specifically because it flattens provider arrays before the environment-specific overrides you might be relying on.
Rule I follow now: always test with the production bootstrap locally before a deploy:
php artisan optimize
php artisan test --filter=ContainerBinding
php artisan optimize:clear
Yes, it's a bit tedious. It's less tedious than a compliance review on audit log integrity.
When I'd Reach for Explicit Provider Ordering
If you have providers that have genuine dependencies on each other's bindings, make it explicit. Don't rely on the order in config/app.php being preserved. A few patterns I use:
Option 1: Explicit defer. If a provider's bindings aren't needed until a specific namespace is resolved, implement DeferrableProvider and declare provides(). Deferred providers load on demand, sidestepping boot-order issues entirely for most cases.
Option 2: Register in boot() only when you have to. If you must call $this->app->extend() in boot() instead of register(), guard it:
public function boot(): void
{
if (! $this->app->bound(Mailer::class)) {
throw new \RuntimeException(
'MailServiceProvider must be registered before AuditServiceProvider.'
);
}
$this->app->extend(Mailer::class, function (Mailer $mailer, $app) {
return new AuditingMailer($mailer, $app->make(AuditLog::class));
});
}
This turns a silent wrong-class resolution into a loud boot exception. Loud failures are debuggable. Silent wrong-class resolutions are not.
Option 3: Document the dependency explicitly in the provider itself. I started adding a static $dependencies array at the top of providers that have ordering requirements — not enforced by the framework, but visible to the next person (usually me, six months later) who touches the file.
When I Wouldn't Worry About This
If you have a handful of providers, none of them decorating or extending each other's bindings, and you're running a standard Laravel app without heavy customization of the container, you're probably fine. Boot order bites when you're building layered infrastructure — audit wrappers, multi-tenant context injection, feature flag decorators, that kind of thing. The more decorator/proxy patterns you stack into the container, the more you're exposed to this.
Simple CRUD apps with framework-standard providers: don't overthink it. Complex service-oriented apps with cross-cutting concerns wired through the container: write the binding assertion tests and run them against the optimized bootstrap before every deploy.
Boot order bugs are the kind of thing that makes you question your own sanity because everything looks right. The binding is there, the class exists, no exceptions — the code just quietly does the wrong thing. Adding container binding assertions to your test suite costs maybe twenty minutes and has saved me real pain. Write the test, run it with caches warm, move on.
Need help shipping something like this? Get in touch.