Service providers explained in PHP & Laravel Development
If you've worked with Laravel at scale, you've likely felt the weight of a bloated AppServiceProvider trying to do everything at once. Service providers are Laravel's configuration and bootstrapping mechanism, and mastering them is the difference between a maintainable codebase and one that becomes a headache as your app grows. Day 36 dives into how service providers work, when to create them, and why keeping them focused matters more than you might think.
Understanding the provider lifecycle
Every Laravel application goes through a specific sequence when it boots. Frameworks don't just load your code randomly—they follow a choreography. Service providers are Laravel's way of orchestrating that dance.
When your app starts, Laravel registers all providers listed in config/app.php. This is a two-phase process: first all register() methods run across every provider, then all boot() methods run. This separation exists for a reason. Think of register() as the "declaration" phase and boot() as the "execution" phase.
In the register phase, you're binding things into the service container. You're saying, "When someone asks for this, give them that." It's pure binding—no side effects, no assumptions about what else has already been registered. In boot, other services are guaranteed to exist. You can safely depend on them.
The register() method binds services
The register() method is where you tell Laravel's container how to resolve types. This is your binding layer.
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Services\PaymentGateway;
use App\Contracts\PaymentInterface;
class PaymentServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(PaymentInterface::class, PaymentGateway::class);
$this->app->singleton('payment.config', function ($app) {
return [
'provider' => env('PAYMENT_PROVIDER', 'stripe'),
'timeout' => env('PAYMENT_TIMEOUT', 30),
];
});
}
}Here we're doing two things. First, binding an interface to its implementation. When something requests PaymentInterface, the container resolves it to PaymentGateway. This follows dependency inversion—depend on abstractions, not concrete classes.
Second, we're registering configuration as a singleton. It's computed once and reused. This is common when you need environment-based configuration that should exist before the boot phase runs.
The key principle: register() should never depend on other services being fully set up. If service A's register() depends on something service B provides, and B hasn't registered yet, you'll get errors that are hard to debug. Keep it simple. Bind contracts, register singletons, but don't reach for other services.
The boot() method runs after all providers register
Once every provider has called register(), Laravel runs the boot() method on every provider. Now you can safely assume the entire container is populated. You can inject dependencies, call other services, and perform configuration that depends on the full ecosystem.
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Services\PaymentGateway;
use App\Services\NotificationService;
class PaymentServiceProvider extends ServiceProvider
{
public function boot(): void
{
$paymentGateway = $this->app->make(PaymentGateway::class);
$notificationService = $this->app->make(NotificationService::class);
// Only set up listeners after both services exist
$paymentGateway->onSuccess(function ($transaction) use ($notificationService) {
$notificationService->notify('Payment processed: ' . $transaction->id);
});
// Publish configuration files
$this->publishes([
__DIR__ . '/../config/payment.php' => config_path('payment.php'),
]);
}
}This is a real scenario. A payment gateway needs to listen for events and notify users. But it can't set up those listeners until the NotificationService exists. That's why this lives in boot(), not register().
The boot method also handles publishing assets, configuration, and migrations for packages. If you're building a reusable package, boot() is where you tell Laravel where to find your resources.
Create providers per feature
One massive AppServiceProvider isn't Laravel's fault—it's a choice we make. As soon as your app has more than one cohesive feature, you should split providers.
Imagine you're building a ride-hailing platform. You have payment, geolocation, notifications, and driver matching as separate domains. Each deserves its own provider:
PaymentServiceProvider– binds payment gateways, webhooks, reconciliation logicLocationServiceProvider– binds geolocation services, cache strategies, distance calculationsNotificationServiceProvider– binds SMS, email, push notification servicesMatchingServiceProvider– binds matching algorithms, preference engines, surge pricing
This structure makes testing easier. When you test the payment feature, you load only PaymentServiceProvider. You don't pull in geolocation, notifications, and matching logic. Your test is faster and more focused.
It also makes onboarding new engineers easier. If someone works on payments, they look at PaymentServiceProvider. They don't have to scroll through a 500-line file trying to find their domain's bindings.
Register these in config/app.php:
'providers' => [
// Framework providers...
// Feature providers
App\Providers\PaymentServiceProvider::class,
App\Providers\LocationServiceProvider::class,
App\Providers\NotificationServiceProvider::class,
App\Providers\MatchingServiceProvider::class,
],Keep them small
A service provider should be a filing cabinet for one feature, not a junk drawer for the entire app. Small providers are easier to understand, test, and maintain.
A good rule of thumb: if your provider's register() or boot() method exceeds 50 lines, something is probably too broad. Break it down.
Here's what we often see in large codebases:
// Too broad
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(PaymentInterface::class, Stripe::class);
$this->app->bind(StorageInterface::class, S3Storage::class);
$this->app->bind(CacheInterface::class, RedisCache::class);
$this->app->bind(MailInterface::class, SendgridMail::class);
$this->app->bind(SmsInterface::class, Twilio::class);
// ... 30 more bindings
}
}Instead, extract each responsibility:
// PaymentServiceProvider
public function register(): void
{
$this->app->bind(PaymentInterface::class, Stripe::class);
}
// StorageServiceProvider
public function register(): void
{
$this->app->bind(StorageInterface::class, S3Storage::class);
}
// And so on...Smaller providers are also easier to disable. If you're running tests and don't need the payment feature initialized, you skip PaymentServiceProvider. That's a huge performance win for test suites.
Common mistakes
Mistake 1: Resolving dependencies in register(). You reach into the container and ask for a service that hasn't registered yet. This breaks the contract. If you need to configure one service based on another, that's boot() work.
Mistake 2: Putting business logic in providers. Providers configure the app. They don't build models, calculate prices, or send emails. That's what services and commands are for. Providers should be 80% configuration and 20% orchestration.
Mistake 3: Forgetting the boot phase requires injection. You can inject dependencies directly into boot():
public function boot(PaymentGateway $gateway, NotificationService $notifications): void
{
$gateway->onSuccess(fn($t) => $notifications->notify($t->id));
}This is cleaner than calling $this->app->make() repeatedly.
Mistake 4: Registering too early in the boot cycle. If you're extending Eloquent models or modifying query builders, do it in boot(), not register(). The models might not be fully loaded yet.
Key takeaways
- register() declares bindings; boot() executes setup. Respect the separation. Don't pull from the container in register().
- One provider per feature. Split your configuration by domain. PaymentServiceProvider owns payment, LocationServiceProvider owns location.
- Keep providers focused. They configure the app. Business logic lives elsewhere—in services, commands, and jobs.
- Use constructor injection in boot(). It's cleaner than repeated
$this->app->make()calls and documents your dependencies clearly. - Test with only the providers you need. Small, focused providers let you bootstrap only what matters for each test suite, making tests faster.






Comments
Loading comments…