Laravel's Service Container From First Principles: bind vs. scoped and Their Siblings
Don't memorize four separate functions. Understand one axis they all branch from: the lifetime policy. A first-principles explanation of bind, singleton, scoped, and instance, and why it matters in Octane.
Imagine this scenario: your application runs at high efficiency on Laravel Octane, then users start complaining that they see other users' data. No breach, no query error. The problem is in a single line you wrote months ago without realizing its impact: you registered a service that carries user state with singleton instead of scoped. This small mistake, which never appears in a traditional PHP environment, turns into a data-leak disaster in long-lived environments. And to understand why, we need to understand the Service Container from its first principle.
What Is the Container, Really? A Recipe and a Lifetime Policy
Before we drown in function names, let's establish the principle. The container exists because a class in your application needs another object, and we do not want it to build it by hand via new, tightly coupling to it. So we hand the "building" task to a central party: the container. At its core, the container is only two things no matter how many functions it has: a "build recipe" (how is the object made?) and a "lifetime policy" (how many times is the recipe executed, and when is the result stored?).
This equation is the key to everything. The four functions that seem separate — bind, singleton, scoped, and instance — do exactly the same thing: they register a recipe. The only difference between them is a single axis: the lifetime policy. Is the recipe re-run on every request? Or once forever? Or once per request?

The Single Axis: Lifetime
The "lifetime" question is simple: when I request the object twice, do I get the same instance or a new one? And when is the stored instance forgotten? All the functions are points on this one axis:
bind — no memory (transient): the recipe is re-run in full on every call, so you get a new object each time. No storage at all.
singleton — permanent memory (shared): the recipe runs only once, and the instance lives for the entire lifetime of the container, so you always get the same object.
scoped — memory flushed at request boundaries: exactly like singleton, but the stored instance is automatically cleared at the start of each new "lifecycle" (a request or a job).
instance — you brought your own object: like singleton (a shared instance), but you built the object yourself beforehand and handed it to the container just to store.
The Café Analogy Cements the Logic
Imagine the container is a café, the requested object is a cup of coffee, and the lifetime policy is "how the café serves the coffee." With bind, the barista makes a fresh cup for each customer: no sharing and no storage, suitable for cheap, stateless objects. With singleton, a huge pot is brewed once and everyone drinks from it all day as long as the café is open. With scoped, a fresh pot for each table shared only by that table, and the next table gets a completely new pot. With instance, you walked in with your own thermos and said "use this," so the café makes nothing but serves you exactly what you brought.
The Deeper Question: Why Does scoped Exist at All?
Here is the essence of understanding. If you grasp "why" scoped exists, you will never confuse it with singleton. The reason is a single principle called shared state. In a traditional PHP request (PHP-FPM), the container is built from scratch with each request and dies at its end. That is why singleton is safe: it lives for one request then is erased. Here scoped and singleton behave practically identically, with no tangible difference.
But the problem explodes in long-lived environments like Laravel Octane or queue workers. In Octane, the container stays alive across thousands of requests. Then a stateful singleton becomes a trap: an object loaded with request "A" data (like a user's data) stays alive and leaks it into request "B." The result is data leaking between users and a memory leak. This is precisely the scenario we opened the article with.
How Does scoped Solve the Problem Internally?
The magic of scoped is simpler than you think. At its core, it registers the object as a singleton, but adds its name to a special list of "scoped" instances. At the start of each new lifecycle, Laravel calls the forgetScopedInstances method, which iterates over this list and erases its stored instances, so they are rebuilt clean at their first request in the new cycle. In short: scoped is simply "a singleton that automatically resets itself at the start of each request." It exists because you wanted sharing within a single request, but you do not want it to cross into the next request.
The Recipes in Code: One Example per Policy
These recipes are usually written inside the register method of a Service Provider:
// bind — a new object every time (transient)
$this->app->bind(PaymentGateway::class, function ($app) {
return new StripeGateway($app['config']['services.stripe']);
});
// singleton — built once, same instance forever (shared)
$this->app->singleton(Clock::class, fn() => new SystemClock());
// scoped — one instance per request, auto-reset (safe in Octane)
$this->app->scoped(RequestContext::class, fn() => new RequestContext());
// instance — you brought the object ready, the container just stores it
$client = new ApiClient($token);
$this->app->instance(ApiClient::class, $client);A Decision Tree: Which Do I Choose?
Instead of memorizing, follow the logic and stop at the first "yes." Is the object cheap to build and stateless (pure logic, data transformation, a transient command)? Use bind. Must it be shared everywhere and is it safe to keep (config, a stateless service)? Use singleton. Is it shared but must be reset with each request (it carries user-specific state and you run Octane or Queue)? Use scoped. Did you build the object yourself beforehand (an API client prepared with a ready token)? Use instance.
The Other Side: How Do You Resolve the Object?
After registration, you pull the object from the container. And here is a comforting principle: all resolution methods do the same thing under different masks. The calls app(Service::class), app()->make(Service::class), and resolve(Service::class) are all identical at the core: "build me a Service according to its recipe and lifetime policy." Most importantly, you rarely call make yourself, because automatic injection via type-hint in a class constructor is a make call the framework performs on your behalf:
// You didn't call make manually — Laravel did it via type-hint
public function __construct(private Service $service) {}The Rest of the Functions: All Derivatives, Not Origins
Everything else in the container is not new concepts, but small variations on what came before. The functions bindIf, singletonIf, and scopedIf are the same base with a condition "register only if not already registered," used in packages to let the application override. The extend method is a post-build hook: it catches the object after it is built and wraps or modifies it (the Decorator pattern). Contextual binding via when()->needs()->give() resolves the same recipe into a different implementation depending on "who requests it." Tagging via tag and tagged groups multiple bindings to request them all at once. As for forgetScopedInstances and flush, they are the manual lever behind the automatic scoped reset.
Two quick examples of the most confusing ones. Contextual binding: the same interface, a different implementation depending on the requester:
$this->app->when(PhotoController::class)
->needs(Filesystem::class)
->give(fn() => Storage::disk('s3'));And tagging: group multiple bindings and request them as a set:
$this->app->tag([CpuReport::class, MemReport::class], 'reports');
$this->app->bind(Dashboard::class, fn($app) =>
new Dashboard($app->tagged('reports')));The Whole Container Collapses Into Five Ideas
If you memorize these five, you have memorized the entire container. First: register a recipe, and the whole bind family registers "how the object is built." Second: choose a lifetime, the single axis that distinguishes them (transient, shared, or scoped). Third: resolve the object via make or its equivalent, or let the type-hint do it. Fourth: modify the recipes via extend, contextual binding, or tagging. Fifth: manage the memory via flush and forget, which is what scoped does automatically.
Notice the beauty here: we did not memorize "the difference between bind and scoped" as a standalone rule, we derived it. Both "register a recipe," and the difference is a single point on the lifetime axis: bind does not store the result, and scoped stores it but resets it at request boundaries. This way, what was scattered memorization of four functions becomes one connected logic — which you derive when you need it, instead of memorizing it.
Was this article helpful?