← All posts
Internals 6 May 2026 · 14 min read

The Laravel service container, demystified

Most of us could explain what the container does in a sentence. Fewer of us could explain how Route::get hands a controller a Request and a route-bound model in one call. Let's open the file. It is roughly 1,500 lines, and once you can name its four jobs, the magic stops being magic.

d
Daan Kloetstra
laravel laravel-13 internals container
Cover for The Laravel service container, demystified

Quick test. You write Route::get('/users/{user}', [UserController::class, 'show']). Your show() method's signature is `how(Request $request, User $user). Who calls find() on User to load it from the database? Who builds the Request? Who knows the order to pass them in? The honest answer is one class. Roughly 1,500 lines long. Called Illuminate\Container\Container. Last post we mentioned it fourteen times without opening it. Today we open it.

The container is just an array of recipes

Most Laravel developers carry around a vague mental model of the container. Something like: it does dependency injection, you can ->bind() things into it, and somehow your services come out fully wired up. That isn't wrong. It is also the kind of mental model that breaks the moment something unexpected happens, because there is no detail underneath. Let me give you the detail.

Open Illuminate\Container\Container in your editor of choice. Past the constructor, the first thing you see is properties. Four of them matter. $bindings is an array of recipes, each entry a closure plus a shared flag. $instances is an array of already-built objects, keyed by their abstract name. $aliases is a lookup table of nicknames. $resolved is a flag-set tracking which abstracts have been asked for at least once.

When you call app(DatabaseManager::class), here is what happens. The container checks $aliases first, in case you used a nickname like 'db'. It checks $instances next, in case it has already built one and was told to share. If yes, hand it back. If not, look in $bindings for the closure, run it, and (if the binding is shared) cache the result in $instances. If there is no binding at all, fall back to autowiring, which we will get to in the next section.

That is the whole machine. Two arrays and a lookup table. A binding is, literally, ['concrete' => Closure, 'shared' => bool]. The aliases trick is why facades can route Cache::get() through to the real CacheManager without you ever calling ::class on anything. It is also why app('db') works at all.

One more thing worth knowing. The container implements PSR-11's Psr\Container\ContainerInterface. That is why packages built to be framework-agnostic can call $container->get('foo') and $container->has('foo') without knowing they are inside Laravel. Inside Laravel-land, we tend to use the richer API: make(), bound(), bind(), singleton(), and friends. They do more, and they read better.

The mental model worth keeping is small. The container is a key-value store of how to build things, plus a memo of things it has already built. Every fancy feature in the next sections is a convenience on top of that.

1 final class Container
2 {
3 /** @var array<string, array{concrete: Closure, shared: bool}> */
4 protected array $bindings = [];
5
6 /** @var array<string, object> */
7 protected array $instances = [];
8
9 /** @var array<string, string> */
10 protected array $aliases = [];
11
12 public function make(string $abstract): mixed
13 {
14 $abstract = $this->aliases[$abstract] ?? $abstract;
15
16 return $this->instances[$abstract]
17 ?? $this->resolve($abstract);
18 }
19 }

bind, singleton, scoped, instance

There are four ways to register something with the container. They look similar enough that most developers learn one or two and reach for them indiscriminately. They are not interchangeable.

bind($abstract, $concrete) registers a recipe. Every call to make() runs the closure again and gives you a fresh instance. Useful for stateful things you want isolated per consumer: a Mailer with per-request config, a Builder you mutate, anything where sharing would be a bug.

singleton($abstract, $concrete) registers a recipe and a cache slot. The closure runs once. Every subsequent make() returns the same object. The default for stateless services: API clients, configuration repositories, factories, the bulk of what you wire up in a provider.

scoped($abstract, $concrete) is singleton's younger sibling, introduced for Octane. It behaves like singleton inside a single request, but the cache slot is flushed between requests. If you do not run Octane, scoped looks identical to singleton in practice. If you do run Octane, it is the only way to safely hold per-request state on a shared service.

instance($abstract, $object) is the lazy option. You skip the recipe entirely and hand the container a pre-built object: "next time someone asks for this abstract, give them this exact thing". Used internally for things the framework builds once during boot, like the Request object itself.

Now the part where developers get bitten. Under Octane, the Application instance lives for the entire life of the worker, potentially thousands of requests. A singleton is bound to that lifetime, not to your request. If you bind something as singleton that holds Request-derived state (the current user, a tenant, a locale), every later request hitting that worker will see the first request's data. Not a bug in your code. A misuse of singleton.

scoped is the fix. The container resets scoped bindings between requests, even on persistent workers. If you are not sure which to reach for, ask: does this object hold a reference to anything from the current request? If yes, scoped. If not, singleton.

1 // fresh instance every make()
2 $this->app->bind(MailgunClient::class, fn () => new MailgunClient(
3 config('services.mailgun.key'),
4 ));
5
6 // built once per worker
7 $this->app->singleton(MailgunClient::class, fn () => new MailgunClient(
8 config('services.mailgun.key'),
9 ));
10
11 // built once per request, even under Octane
12 $this->app->scoped(MailgunClient::class, fn () => new MailgunClient(
13 config('services.mailgun.key'),
14 ));
15
16 // pre-built, no closure
17 $this->app->instance(MailgunClient::class, new MailgunClient(
18 config('services.mailgun.key'),
19 ));

Reflection is how the magic works

Now we get to the bit that feels like sorcery. You write a service that takes three constructor dependencies, never bind it to the container, never write a factory, and somehow app(UserService::class) hands you a fully-wired instance. Three dependencies, all resolved, none of them mentioned anywhere in your providers. How?

When make() does not find a binding, it falls through to a method called build(). Build does this: instantiate a ReflectionClass for the requested name. Look at the constructor's parameters. For each parameter, read the type hint. If the type hint is a class, recursively make() it. If it is a primitive without a default, throw a BindingResolutionException. If it has a default, use the default. Construct the class with the resolved arguments. Return.

That is it. That is autowiring. About thirty lines of code, and most of those are error messages. You can write the same thing yourself in an afternoon, which is what we will do at the end of this post.

The recursive call is what makes deeply nested dependency graphs work. When you ask for UserService, the container reflects on its constructor, sees DatabaseManager, asks itself for one of those, reflects on its constructor, sees Connection, asks itself for one of those, and so on down the tree. It builds the leaves first and walks back up. You never see any of this happen.

Two practical consequences are worth keeping. First, the container has no opinions about what "good" dependencies look like. If you type-hint a final concrete class, it resolves a final concrete class. If you type-hint an interface that is not bound, you get a runtime error. The container is dumb in the best way.

Second, this only works when you go through the container. If you new UserService() yourself, you bypass everything. No autowiring. No singleton check. No nothing. Which is also why circular dependencies blow up the container: if A's constructor takes B, and B's constructor takes A, build() gets stuck in infinite recursion. The right answer is not to fix the container. It is to break the cycle in your code.

1 public function build(string $concrete): object
2 {
3 $reflector = new ReflectionClass($concrete);
4 $constructor = $reflector->getConstructor();
5
6 if ($constructor === null) {
7 return new $concrete;
8 }
9
10 $dependencies = array_map(
11 fn (ReflectionParameter $p) => $this->resolveParameter($p),
12 $constructor->getParameters(),
13 );
14
15 return $reflector->newInstanceArgs($dependencies);
16 }

Method injection is route binding by another name

The clever bit is not constructor injection. It is method injection. You write a controller method like update(UpdateUserRequest $request, User $user, UserUpdater $updater), and Laravel passes you a validated form request, a User loaded from the route parameter, and an autowired updater. All three resolved, all without a single line of glue in your controller.

When the router decides which controller method to call, it does not call it directly. It hands the call to ControllerDispatcher, which hands it to BoundMethod::call(). BoundMethod reflects on the method signature and resolves each parameter against the container. Same algorithm as build(), applied to a method instead of a constructor.

There is a subtle priority worth knowing. Before the container resolves anything, BoundMethod checks the route's $parameters array. If you declared {user} in the route URI and it has been resolved by route model binding, the User is already sitting there. The container does not go fetch it. The router fetched it earlier and handed it over.

This is the bit that confuses people. We say "the container resolves controller arguments" as shorthand. The truth is sharper. The SubstituteBindings middleware ran earlier in the pipeline, used the container to resolve the model from the URI, and stored the result on the Route. By the time BoundMethod looks, the answer is already there. The container does not find your User. The Router finds it first and hands the container a pre-built answer.

The same mechanism powers Artisan::call(SomeCommand::class), the handle() method on queued jobs, and event listener handle() methods. Anywhere you can type-hint a parameter and have it resolved, BoundMethod is the engine.

Method injection is also why Form Requests work. When you type-hint StoreUserRequest, the container resolves it and calls validateResolved() on the way out. By the time your controller runs, validation has already happened and the failure response (if any) has already been thrown. Not magic. A type hint, a resolver, and a hook called at the right moment.

1 // Route::patch('/users/{user}', [UserController::class, 'update']);
2
3 public function update(
4 UpdateUserRequest $request, // form request, validated on resolve
5 User $user, // pre-resolved by SubstituteBindings
6 UserUpdater $updater, // autowired by reflection
7 LoggerInterface $logger, // looked up by binding
8 ): JsonResponse
9 {
10 $updater->update($user, $request->validated());
11 $logger->info('user.updated', ['id' => $user->id]);
12
13 return response()->json($user->fresh());
14 }

Contextual binding is the escape hatch you will actually need

Here is the situation. You have two controllers that both depend on Laravel's Filesystem contract. PhotoController writes user uploads to S3. ReportController writes generated PDFs to local disk for an internal admin panel. Same interface, two different implementations, and you need each controller to get the right one.

The naive option is to inject Storage and call Storage::disk('s3') or Storage::disk('local') inside each controller. Workable. It also spreads storage configuration across two controllers. Forget what you did, change the disk name in config six months later, and you have broken the photo upload flow with no compile-time warning.

The cleaner option is contextual binding. You tell the container: when PhotoController asks for a Filesystem, give it the S3 disk. When ReportController asks for one, give it the local disk. Both controllers now type-hint Filesystem and stay completely ignorant of which one they got. The wiring lives in one place. The controllers stay honest about what they need.

Real-world cases where this pattern shines: per-tenant database connections (resolved off the current tenant), per-channel notifiers (Slack for staging, email for production), and any same-interface-different-implementation-per-consumer situation. You stop reaching for if statements inside services and let the container hand you the right thing.

Laravel 13 has a slicker alternative for the simpler cases. Contextual attributes like #[FromConfig('services.s3.key')] on a constructor parameter resolve to a config value at build time. For "give me this string from config" or "give me this resolved target", an attribute often replaces a five-line AppServiceProvider block with one line on the consumer. The provider style is still right when the resolution logic is non-trivial. The attribute style is the right default for the trivial cases.

One thing contextual binding quietly removes: a class of bugs. You stop reaching for if ($tenant->premium) inside services because the container hands the right service in already. Behaviour follows wiring, not branches.

1 // AppServiceProvider::register()
2
3 $this->app->when(PhotoController::class)
4 ->needs(Filesystem::class)
5 ->give(fn () => Storage::disk('s3'));
6
7 $this->app->when(ReportController::class)
8 ->needs(Filesystem::class)
9 ->give(fn () => Storage::disk('local'));

Tagged services let you fan out cleanly

Some problems are naturally one-to-many. Generate every report. Run every health check. Notify every channel. The naive way is to keep a config array somewhere, list the classes by hand, and wire them up in a runner. That works. It also rots. Adding a new report means editing config, registering a binding, and hoping no one else got there first.

The container has a feature most developers never reach for: tagging. You register services under a string tag, and later ask the container for everything tagged with it. The lookup is one method call and it is lazy.

Tagging shines when you want a pluggable architecture inside a monolith. New report? Add the class, register it in your provider, tag it. The runner picks it up automatically. No central registry. No risk of forgetting to wire something. Each module registers its own pieces.

A real example. You have a RunAllReports console command that resolves every tagged generator and runs them. The billing module ships an InvoiceReport, the CRM module ships a SalesReport, the warehouse module ships an InventoryReport. Each lives in its own provider. The runner does not care which.

A small caveat. tagged() returns an iterator, not an array. Each yielded value is resolved fresh from the container at iteration time. That is usually what you want, since it prevents resolving services you do not end up using. It can surprise you if you expected an array of pre-built objects, so keep it in mind when you reach for count() or array_map().

1 // In each module provider:
2 public function register(): void
3 {
4 $this->app->singleton(SalesReport::class);
5 $this->app->tag([SalesReport::class], 'reports');
6 }
7
8 // Console command:
9 final class RunAllReports extends Command
10 {
11 public function handle(): int
12 {
13 foreach (app()->tagged('reports') as $report) {
14 $report->run();
15 }
16
17 return self::SUCCESS;
18 }
19 }

Bind interfaces, but not because someone told you to

There is a piece of advice that became axiomatic in PHP circles: always type-hint interfaces, not concrete classes. Bind the interface, swap the implementation in tests, feel architecturally pure. I followed it. I taught it. I also wasted a lot of time on it. By 2026, the community has mostly walked it back.

Here is the honest version. Bind interfaces when there is a real second implementation in play. A PaymentGateway with a Stripe implementation and a fake for tests. A SearchClient that swaps between Meilisearch in production and Postgres full-text in dev. A NotificationChannel with Slack, email, and SMS variants. These are cases where the interface earns its keep, because something actually swaps.

But UserRepositoryInterface with one implementation, ever, called UserRepository, is just ceremony. You doubled the file count and gained nothing. The interface did not make your tests easier, because Mockery can mock a concrete class without flinching. It did not help you swap implementations, because there is no second implementation. It just gave the next developer two places to look instead of one.

A heuristic I trust: if you would not write a second implementation today, do not write the interface today. You can extract the interface the moment you need one, with two clicks in any IDE built since 2018. The cost of waiting is zero. The cost of over-extracting is a sprawling app/Contracts folder full of UserRepositoryInterface, OrderRepositoryInterface, ProductRepositoryInterface. None of which has ever had a second implementation.

This is the same trap as the Repository pattern, the Service Layer debate, and DDD-by-numbers in general. The pattern is fine. The cargo-cult version, applied without the conditions that make the pattern useful, is just noise. If your codebase has a hundred interfaces, ask how many of them have more than one implementation. The honest answer is usually fewer than ten.

When in doubt, type-hint the concrete class. You can always extract later. You almost never need to.

1 interface UserRepositoryInterface
2 {
3 public function findByEmail(string $email): ?User;
4 }
5
6 final class UserRepository implements UserRepositoryInterface
7 {
8 public function findByEmail(string $email): ?User
9 {
10 return User::where('email', $email)->first();
11 }
12 }
13
14 // AppServiceProvider:
15 $this->app->bind(UserRepositoryInterface::class, UserRepository::class);

Build your own mini-container in 50 lines

Here is the test of whether the model in your head is right: can you build it? Below is a small, single-class container that does bind, singleton, instance, and constructor autowiring. About 35 lines of real code. Paste it into a fresh PHP file and run it.

The interesting thing is not the code itself. It is how much of the real container's behaviour fits in this much space. Recipes? An array. Singletons? Another array. Resolution? ReflectionClass plus recursion. Almost everything else in Laravel's container is convenience: contextual binding, attributes, scoped resets, rebinding callbacks, extending. All built on top of the same two arrays and the same reflection step.

Run it against a small object graph. Bind two services. Resolve one. Watch ReflectionClass walk the constructor and recursively make() each dependency. Singleton-bind the same thing and resolve it twice, and confirm you get the same object both times. Then go open Illuminate\Container\Container and read the real thing. It will look familiar, because the bones are identical.

The real container has roughly 1,500 lines of edge cases. Rebinding listeners. Contextual attributes. Scoped versus shared rules under Octane. Method injection. PSR-11 conformance. But the 35 lines you just wrote handle 80% of what most apps actually do. The other 20% is ergonomics, layered on top with care.

That is the thing worth taking away. The container is not a black box. It is a small, comprehensible engine. You can read every method in an afternoon. You can write a working clone in less than that. After that, magic stops being a useful word for any of this.

1 final class MiniContainer
2 {
3 private array $bindings = [];
4 private array $instances = [];
5
6 public function bind(string $abstract, ?Closure $concrete = null, bool $shared = false): void
7 {
8 $this->bindings[$abstract] = [
9 'concrete' => $concrete ?? fn ($c) => $c->build($abstract),
10 'shared' => $shared,
11 ];
12 }
13
14 public function singleton(string $abstract, ?Closure $concrete = null): void
15 {
16 $this->bind($abstract, $concrete, shared: true);
17 }
18
19 public function make(string $abstract): object
20 {
21 if (isset($this->instances[$abstract])) return $this->instances[$abstract];
22
23 $binding = $this->bindings[$abstract] ?? null;
24 $object = $binding ? ($binding['concrete'])($this) : $this->build($abstract);
25
26 if ($binding['shared'] ?? false) {
27 $this->instances[$abstract] = $object;
28 }
29
30 return $object;
31 }
32
33 private function build(string $concrete): object
34 {
35 $constructor = (new ReflectionClass($concrete))->getConstructor();
36 if (! $constructor) return new $concrete;
37
38 $args = array_map(fn (ReflectionParameter $p) =>
39 $p->getType() instanceof ReflectionNamedType && ! $p->getType()->isBuiltin()
40 ? $this->make($p->getType()->getName())
41 : ($p->isDefaultValueAvailable() ? $p->getDefaultValue() : throw new RuntimeException()),
42 $constructor->getParameters(),
43 );
44
45 return new $concrete(...$args);
46 }
47 }

The four jobs of the container

Four jobs. Worth keeping somewhere: a sticky note, the back of a notebook, the same place you stashed the fourteen request stages from last time.

1 Stores recipes ($bindings)
2 Remembers built instances ($instances)
3 Autowires unknown classes via reflection
4 Adds conveniences: aliases, contextual binding, tagging, scoped lifetimes

So what does this get you?

If you can name the four jobs, you can debug almost any "where did this come from?" moment in a Laravel app. You can drop a debugger in Container::resolve and watch each step run. You stop guessing about magic, because each step has a name, and each name has about thirty lines of code behind it.

Next time, we look at who fills the container. Service Providers are the framework's stage managers. They register bindings, wire up listeners, schedule jobs, publish config, and quietly disappear before the curtain rises. The container is the warehouse. Providers are the people who stock it before the doors open.

Two arrays, some reflection, a few clever tricks. That is the whole machine.

Found this useful? Pass it on.

Keep reading.

All posts →
Databases · 3 May 2026

Why Postgres is my default for relational data

I have shipped ten-plus production projects on Postgres in the last five years. One legacy MySQL client aside, every new database decision h…

8 min read Read post →
Tools · 8 April 2026

What Inertia v3 actually fixed

useHttp lands the form-ergonomics-for-API-calls hook every Inertia + React app eventually grows. Axios is gone. The Vite plugin replaces thr…

9 min read Read post →