Understanding Laravel Service Providers: Register vs Boot
Service providers are the central place where a Laravel application is configured and bootstrapped. Almost everything the framework does, from binding the database to registering routes, happens through a provider. Yet many developers copy code into boot() or register() by pattern matching without knowing why one method exists rather than the other. Understanding the distinction is not academic: it explains a recurring category of "why is this null?" bugs.
The Two Phases of Bootstrapping
When Laravel handles a request, it bootstraps the application in two distinct passes over every registered service provider.
First comes the register phase. Laravel calls the register() method on every provider, one after another. The single job of register() is to bind things into the service container. You should not try to use any service from the container here, because other providers may not have registered their bindings yet.
Second comes the boot phase. Only after every provider has finished registering does Laravel loop through them again and call boot(). By the time boot() runs, the entire container is populated. This is where you do work that depends on other services being available: registering event listeners, defining routes, publishing configuration, sharing data with views, and so on.
The rule follows directly from the ordering: bind in register(), use in boot().
A Concrete Example
Suppose PHP Architect ships a package that talks to a billing API. The client needs an API key from configuration, and we also want to log every request it makes. Here is the provider done correctly.
namespace PhpArchitect\Billing;
use Illuminate\Support\ServiceProvider;
class BillingServiceProvider extends ServiceProvider
{
public function register(): void
{
// Only bind. Do not resolve other services here.
$this->app->singleton(BillingClient::class, function ($app) {
return new BillingClient(
config('billing.api_key'),
config('billing.base_url'),
);
});
}
public function boot(): void
{
// The whole container is ready now, so it is safe to use it.
$this->publishes([
__DIR__ . '/../config/billing.php' => config_path('billing.php'),
], 'billing-config');
$this->app->make(BillingClient::class)
->onRequest(fn ($request) => logger()->info('Billing call', [
'endpoint' => $request->endpoint(),
]));
}
}
Notice what would go wrong if you moved that $this->app->make(BillingClient::class) call into register(). If any provider registered later in the boot order overrides or decorates the binding, you would have grabbed the wrong instance too early. Resolving in boot() guarantees you get the final, fully-configured version.
Why register() Must Stay Lightweight
There is a second, subtler reason to keep register() free of heavy work. Laravel supports deferred providers, which are only loaded when one of the services they provide is actually requested. A provider becomes deferrable by implementing DeferrableProvider and listing what it offers:
use Illuminate\Contracts\Support\DeferrableProvider;
class BillingServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
$this->app->singleton(BillingClient::class, fn ($app) =>
new BillingClient(config('billing.api_key'))
);
}
public function provides(): array
{
return [BillingClient::class];
}
}
Deferred providers are a performance feature: their register() is skipped entirely on requests that never touch the billing client. But this only works if register() does nothing except bind. If you performed side effects there, deferring the provider would silently skip them. That is why the framework is strict about the division of labor.
The Mental Model
Think of the container as a workshop being set up before the workday. During register(), every provider is quietly placing its tools on the bench. Nobody is allowed to pick up a tool yet, because someone might still be putting theirs down. During boot(), the bench is fully stocked and everyone can start using each other's tools freely.
Once this clicks, a lot of framework behavior stops being mysterious. You understand why the docs tell you to register routes and view composers in boot(), why binding in register() is safe even when providers depend on each other, and why a well-behaved provider keeps its register() method boring. Service providers are the seam where the whole framework is assembled, and knowing which phase you are in is what lets you extend Laravel with confidence.
Further Reading
- Laravel documentation, "Service Providers" (laravel.com/docs/13.x/providers)
- Laravel documentation, "Service Container" (laravel.com/docs/13.x/container)