Design Patterns, Explained Simply · Patterns Inside Frameworks

The Service Container and Service Providers

Let one object build and hand out your application's services (the container), and give each feature one place to register its services when the app starts (service providers).

Create a free account to save this page, take notes and track your progress. Sign in

The problem

The Dependency Injection lesson passed every dependency in by hand: Notifier(SmtpMailer(config)). That is easy with five classes. With two hundred, the code that creates and connects them all becomes a huge file that every feature edits.

The wiring also has rules of its own:

  • Local development should log emails instead of sending them, so the choice of mailer depends on the environment.
  • Some objects must be shared: one database connection pool, one cache client. Others must be new each time: a request-specific report builder.
  • A payments package needs to add its own services and routes without you editing its code.

Real-world analogy

A staffing agency. You don't hire an electrician yourself; you ask the agency for "an electrician" and it decides who to send. Some roles are always the same person (the site manager, a shared service), and some get a fresh worker for every job.

Each trade has an onboarding desk that, when the agency opens in the morning, tells the agency which workers it has and how to book them. Only after every desk has finished does the day's work start, so any trade can rely on any other being on the books.

The agency is the service container; the onboarding desks are service providers.

Without the pattern

// public/index.php grows with every feature, and every environment check is copied into it
$config = require 'config.php';
$mailer = $config['env'] === 'local' ? new LogMailer() : new SmtpMailer($config['smtp']);
$db = new Connection($config['db']);
$orders = new OrderRepository($db);
$notifier = new Notifier($mailer);
$checkout = new CheckoutService($orders, $notifier, new StripeGateway($config['stripe']));
// ... 200 more lines

The pattern

Service container (also called a dependency injection container or IoC container):

  • Keeps bindings: a name or interface mapped to a factory that knows how to build it.
  • bind(name, factory): build a new object every time it is asked for.
  • singleton(name, factory): build it once, then share that instance. This is a Singleton scoped to the application, not a global static.
  • make(name): build the object and, through the factories, everything it depends on.
  • Most containers also auto-wire: they read a class's constructor type hints and build those dependencies without any binding.

Service providers organise the bindings by feature. Each provider has two steps, and the framework runs them in two passes:

  1. register: only bind things into the container. Don't use other services yet; they may not be registered.
  2. boot: runs after every provider has registered, so it can safely use any service: register routes, event listeners, view helpers.

Starting the application ("bootstrapping") is then: create the container, call register on every provider, then call boot on every provider.

The code

A container with bind, singleton and make, two providers and the two-pass bootstrap. The notification provider is listed first and still works, because services are only built when first asked for.

A tiny service container with providersTry it yourself
class Container:
    def __init__(self):
        self.bindings = {}
        self.instances = {}

    def bind(self, name, factory):
        self.bindings[name] = (factory, False)

    def singleton(self, name, factory):
        self.bindings[name] = (factory, True)

    def make(self, name):
        if name in self.instances:
            return self.instances[name]
        factory, shared = self.bindings[name]
        obj = factory(self)
        if shared:
            self.instances[name] = obj
        return obj


class LogMailer:
    def send(self, to, text):
        return f"[log] to {to}: {text}"


class SmtpMailer:
    def send(self, to, text):
        return f"[smtp] to {to}: {text}"


class Notifier:
    def __init__(self, mailer):
        self.mailer = mailer

    def welcome(self, email):
        return self.mailer.send(email, "Welcome!")


class MailServiceProvider:
    def register(self, app):
        # Only bind here. The factory reads the config when the mailer is first needed.
        app.singleton("mailer", lambda c: LogMailer() if c.make("config")["env"] == "local" else SmtpMailer())

    def boot(self, app):
        pass


class NotificationServiceProvider:
    def register(self, app):
        app.bind("notifier", lambda c: Notifier(c.make("mailer")))

    def boot(self, app):
        # Every provider has registered by now, so using other services is safe.
        print("boot: notifier uses", type(app.make("notifier").mailer).__name__)


def bootstrap(env):
    app = Container()
    app.singleton("config", lambda c: {"env": env})
    providers = [NotificationServiceProvider(), MailServiceProvider()]
    for provider in providers:
        provider.register(app)
    for provider in providers:
        provider.boot(app)
    return app


app = bootstrap("local")
print(app.make("notifier").welcome("[email protected]"))
print("same mailer:", "yes" if app.make("mailer") is app.make("mailer") else "no")
print("same notifier:", "yes" if app.make("notifier") is app.make("notifier") else "no")
print(bootstrap("production").make("notifier").welcome("[email protected]"))

Output

boot: notifier uses LogMailer
[log] to [email protected]: Welcome!
same mailer: yes
same notifier: no
boot: notifier uses SmtpMailer
[smtp] to [email protected]: Welcome!

bind gave a new notifier each time, singleton shared one mailer, and switching the environment swapped the mailer without touching Notifier.

In Laravel the same ideas look like this. The container builds Notifier by reading its constructor, so only the interface needs a binding:

// app/Providers/MailServiceProvider.php (listed in bootstrap/providers.php)
class MailServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Mailer::class, fn ($app) =>
            $app->environment('local') ? new LogMailer() : new SmtpMailer(config('mail.smtp')));
    }

    public function boot(): void
    {
        // Safe to use other services here: routes, event listeners, view composers.
    }
}

// Anywhere: constructor injection, resolved by the container
class SignupController
{
    public function __construct(private Notifier $notifier) {}
}

When to use it

  • An application with many services whose wiring changes by environment (local, test, production) or by configuration.
  • Objects that must be shared for the life of the app or request (connection pools, clients) next to ones that must be fresh.
  • Packages and modules that need to add their own services, routes and listeners without editing the application: each ships a provider.
  • Tests that swap a real service for a fake in one place ($this->app->instance(Mailer::class, new FakeMailer)).

When not to use it

  • Small scripts and libraries. Passing dependencies by hand is clearer than a container.
  • Service locator use: calling app()->make(...) deep inside business code hides what a class depends on. Ask for dependencies in the constructor and let the container supply them.
  • Doing real work in register (querying the database, resolving other services). It runs before other providers have registered; put that in boot.
  • Binding everything by hand when auto-wiring already resolves concrete classes. Bind interfaces and things that need configuration.

You've already used it

  • Laravel: the service container (bind, singleton, make, auto-wiring, contextual binding), and service providers with register() and boot(), listed in bootstrap/providers.php (in config/app.php before Laravel 11). Providers can be deferred until one of their services is first needed.
  • Symfony: the DependencyInjection component with autowiring; bundles play the role of providers.
  • Spring: the ApplicationContext with @Configuration and @Bean methods and @Component scanning.
  • ASP.NET Core: builder.Services.AddSingleton / AddScoped / AddTransient in Program.cs.
  • NestJS and Angular: modules declare providers; injectors resolve constructor dependencies.

Key points

  • A service container maps names or interfaces to factories and builds objects with their dependencies: bind makes new ones, singleton shares one.
  • Service providers group a feature's bindings; the framework calls every register, then every boot, so boot can rely on anything.
  • Prefer constructor injection; avoid using the container as a service locator.
  • Further reading: Laravel: Service Container and Laravel: Service Providers.

Practice questions

What is the difference between bind and singleton in a service container?

Answer

bind registers a factory that runs every time the service is resolved, so each caller gets a new object. singleton runs the factory once and returns that same instance afterwards, which suits shared resources like connection pools and API clients.

Why should a service provider's register method only bind things into the container?

Answer

Providers are registered one after another. During register, services from providers that haven't registered yet don't exist, so resolving them can fail or pick up the wrong binding. Work that uses other services (routes, listeners, view composers) goes in boot, which runs after every provider has registered.

What is auto-wiring, and when do you still need an explicit binding?

Answer

Auto-wiring is the container reading a class's constructor type hints (with reflection) and building those dependencies itself, so concrete classes need no configuration. You still bind interfaces to the implementation to use, services that need configuration (API keys, environment-specific choices) and services that must be shared as singletons.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?