Skip to content
Muhammet Şafak
tr
Framework & Library 3 min read

Using Service Providers and the Container Correctly in Laravel

A practical guide to truly understanding Laravel's dependency resolution mechanism: how to use service providers and the IoC container the right way.


As someone who has worked with Laravel for a long time, I have to admit: I used the IoC container for years at the level of “bind something here, resolve it there.” I knew what it did, but I never fully internalized why it works the way it does, where its limits lie, or how service providers are really meant to be used. This post is a summary of what I learned while trying to fill that gap.

What the container is — and what it isn’t

Laravel’s container is a structure that automatically resolves a class’s dependencies. When you call app()->make(FooService::class), Laravel inspects that class’s constructor and tries to resolve every type-hinted parameter from its own container.

<?php

// PaymentService depends on a Gateway interface
class PaymentService
{
    public function __construct(private GatewayInterface $gateway)
    {
    }
}

For the container to resolve this dependency automatically, it needs to know what GatewayInterface maps to. That declaration is made in the service provider.

The service provider has exactly one job

A service provider’s job is to register bindings in the container. Nothing else. Once I realized this, a lot of things became clear. The register() method is reserved for bindings only, while boot() is for initialization code that needs to run after all providers have been loaded and registered.

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Contracts\GatewayInterface;
use App\Services\IyzicoGateway;

class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(GatewayInterface::class, function ($app) {
            return new IyzicoGateway(
                config('payment.api_key'),
                config('payment.secret_key')
            );
        });
    }

    public function boot(): void
    {
        // Here you can load routes, register view components, etc.
        // Anything that depends on another provider's register() method goes here
    }
}

You should not rely on facades like config() or auth() inside register() — at that point, not all providers may have been loaded yet. This constraint felt strange at first, but once I understood the reasoning, I could see how clean the separation between “registering” and “booting” really is.

Singleton or bind?

When registering a binding, you have two primary options:

  • bind(): a new instance is created on every make() call.
  • singleton(): the instance is created on the first call and the same object is returned on subsequent calls.
<?php

// If we want a fresh logger instance per request:
$this->app->bind(LoggerInterface::class, FileLogger::class);

// If a single cache object is sufficient:
$this->app->singleton(CacheInterface::class, function ($app) {
    return new RedisCache($app['config']['cache.prefix']);
});

Missing this distinction can lead to subtle bugs. In particular, when you register a stateful class as a singleton, state from one request can leak into the next. Conversely, if you register a class that should start clean on every request as a singleton, you will see inconsistent behavior.

When should you actually write a custom provider?

A common mistake in Laravel applications is creating a separate service provider for every small thing. If AppServiceProvider is kept clean enough, a single provider is sufficient for many projects.

A custom provider makes sense when:

  • You are integrating a package or external service that carries its own configuration, routes, and views.
  • The number of bindings has grown large enough to make AppServiceProvider hard to read.
  • You want to keep all the services, configuration, and context for a module together in one place.

Otherwise, the “one provider per service” approach just adds an unnecessary layer of abstraction.

Being explicit about trade-offs

The flexibility the container provides comes at a cost: it becomes harder for your IDE to answer the question “where does this go?” A class implementing an interface cannot be found without looking at the container registration. Static analysis tools like PHPStan and Psalm understand these bindings to some degree, but it is not fully automatic.

My approach: use interface-to-implementation bindings for significant dependencies, and bypass the container entirely for simple classes. Routing everything through the container can mean trading readability for flexibility.

Understanding Laravel’s container marks the transition from seeing the framework as a “magic box” to using it as a real tool. That transition took me time, at least.

Tags: #Laravel
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind