- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You add a payment gateway to a Laravel app. A package binds the concrete client into the container. You want to point it at your own base URL, so you wire it up in your AppServiceProvider. Deploy goes out. Payments hit the sandbox endpoint in production. No exception, no log line, nothing red. The object you configured is not the object the app is using.
The bug is not in your config. It is in which method you put the wiring in. register() and boot() look interchangeable. They are not. Get the phase wrong and Laravel hands you a fully-built object that quietly skipped every customization a later provider would have applied.
The two-phase lifecycle
Every service provider has two entry points, and they run in two separate passes over all providers.
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Pass 1: only bind things into the container.
}
public function boot(): void
{
// Pass 2: everything is bound now. Wire it up.
}
}
The order is fixed. During bootstrap Laravel calls register() on every provider first, in order, and only after the last one returns does it start calling boot(). So when your boot() runs, the whole container is populated. When your register() runs, it is half-built. Some providers before you have bound their services. Every provider after you has not.
In Laravel 11 and 12 the provider order lives in bootstrap/providers.php for your app providers, plus whatever package auto-discovery appends. Framework providers register before package providers, which register before your bootstrap/providers.php entries. That order is the whole story behind the bug.
What belongs in register()
register() has one job: put bindings into the container. Nothing else. Bind an interface to an implementation, register a singleton, merge a config file. All of these are declarations, not executions.
public function register(): void
{
$this->app->singleton(
PaymentGateway::class,
fn ($app) => new StripeGateway(
$app->make(HttpClient::class),
config('services.stripe.secret'),
),
);
$this->mergeConfigFrom(
__DIR__.'/../../config/payments.php',
'payments',
);
}
The closure passed to singleton() does not run here. It runs the first time someone resolves PaymentGateway, which happens long after every provider has booted. That laziness is what keeps register() safe. You are describing how to build the object, not building it.
Config is available in register(), by the way. The LoadConfiguration bootstrapper runs before any provider registers, so config() calls work in both methods. What is not safe in register() is resolving a service out of the container.
What belongs in boot()
By the time boot() runs, every binding exists. This is where you do anything that reaches into another service: register event listeners, routes, view composers, gates, model observers, macros, or Blade directives.
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
Event::listen(
OrderPaid::class,
SendReceiptListener::class,
);
Collection::macro('toUpperKeys', function () {
return $this->mapWithKeys(
fn ($v, $k) => [strtoupper($k) => $v],
);
});
}
Registering a listener means touching the event dispatcher. Adding a macro means the Collection class is loaded and ready. Both depend on the container being complete, so both belong in boot(). Put them in register() and you are betting the dispatcher is already bound, which it might be today and might not be after a framework upgrade reorders bootstrappers.
The ordering bug: resolved too early
Here is the version that ships to production without a single error. You want to decorate the HTTP client the payment package binds. So you resolve it in register():
public function register(): void
{
// BUG: resolving inside register()
$client = $this->app->make(HttpClient::class);
$client->setBaseUrl(config('services.stripe.base'));
$this->app->singleton(
PaymentGateway::class,
fn () => new StripeGateway($client),
);
}
Read what actually happens. You cannot assume your provider runs after the package's. Depending on whether the package is auto-discovered or registered manually, and where it sits in the provider array, yours may register first. When make(HttpClient::class) runs, the package has not bound HttpClient yet. If HttpClient is a concrete, instantiable class, the container does not error. It auto-resolves a fresh default-constructed instance through reflection. Your setBaseUrl() call configures that throwaway object.
Moments later the payment package registers its own HttpClient singleton with the right retry policy, auth headers, and timeouts. Too late. Your PaymentGateway closure already closed over the wrong client. Everything resolves without complaint and points at the wrong endpoint.
If HttpClient were an interface with no binding yet, you would at least get a BindingResolutionException at boot. The concrete-class case is worse because it fails silently.
The fix is to stop resolving anything in register(). Keep resolution lazy so it happens at runtime, when the container is whole:
public function register(): void
{
$this->app->singleton(PaymentGateway::class, function ($app) {
$client = $app->make(HttpClient::class);
$client->setBaseUrl(config('services.stripe.base'));
return new StripeGateway($client);
});
}
Same lines of code, moved inside the closure. Now make(HttpClient::class) runs when someone first asks for a PaymentGateway, well after boot, when the package has already bound its configured client. The rule is short: register() may reference the container, but must never resolve from it.
If you genuinely need to reconfigure another package's already-bound service, do it in boot() with extend(), which hooks the object after it is resolved:
public function boot(): void
{
$this->app->extend(HttpClient::class, function ($client) {
$client->setBaseUrl(config('services.stripe.base'));
return $client;
});
}
Deferred providers, and when not to defer
A deferred provider does not register until something asks for one of its bindings. That saves the cost of building bindings on every request when most requests never touch them. You opt in by implementing DeferrableProvider and listing what you provide:
<?php
namespace App\Providers;
use App\Reports\ReportGenerator;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
class ReportServiceProvider extends ServiceProvider
implements DeferrableProvider
{
public function register(): void
{
$this->app->singleton(ReportGenerator::class);
}
public function provides(): array
{
return [ReportGenerator::class];
}
}
Laravel builds a manifest mapping each provided key to its provider. The provider stays dormant until code resolves ReportGenerator, at which point it registers and boots on demand. For an expensive binding used on one route, that is real savings.
The catch is the same ordering trap in a new costume. A deferred provider's boot() does not run at application boot. It runs only when its binding is resolved. So global wiring that must always be present (an event listener, a route, a scheduled task, a Blade directive) cannot live in a deferred provider. If nobody resolves the binding on a given request, that boot() never fires, and your listener silently goes missing. Defer providers whose services are self-contained and pulled by explicit resolution. Keep anything that wires the framework in a normal, always-booted provider.
Keep the wiring in one place
Every one of these bugs traces back to the same habit: wiring scattered across the wrong phase, done eagerly, or split between providers that assume an order they do not control. The discipline that avoids all of it is boring. Declarations go in register(), lazily. Anything that touches another service goes in boot(). Nothing gets resolved before the container is whole.
Do that and provider order stops mattering, which is the entire point. You want the answer to "does provider A run before B" to be "it does not matter, both are fully declared before either wires anything."
If this was useful
The register/boot split is Laravel's version of a boundary you want everywhere: the place where the framework assembles your objects, kept separate from the objects themselves. Your domain code should not know it was wired by a service provider, a factory, or a test. That is the seam this bug lives on, and keeping construction at the edge (out of the code that does the actual work) is exactly what Decoupled PHP is about: the architectural layer your Laravel app reaches for once it outgrows the framework defaults.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)