Laravel Foundations

Lesson 1 of 5

The Service Container

"The whole is greater than the sum of its parts" is a bad description; the whole is different from the sum of its parts. — Anatol Rapoport, General System Theory seminars (1968)

Most of what people call Laravel's magic is one object. Facades resolve through it. Route parameters are typed and appear as arguments because of it. Jobs, events, middleware, form requests, console commands — all of them are constructed by it, and almost none of them are constructed by you.

That object is a container, and it is roughly two hundred lines of idea. This chapter builds a working one, measures what it costs, and then asks the question that matters more than the mechanism: what does handing construction to a container actually buy?

Everything a container needs to know is in the constructor

PHP — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

That table is the whole algorithm. For each constructor parameter: if the type is a class or interface, resolve it recursively; if it is a builtin with a default, take the default; if it is nullable, pass null; otherwise fail, because a container cannot invent a database password.

The class_exists detail is worth fixing in your memory now, because it produces the container error that wastes the most time. class_exists(SomeInterface::class) is false. A resolver that checks only class_exists will tell you the interface does not exist — when in fact it exists perfectly well and simply has no binding. The message points at the wrong problem entirely.

An analogy

Think about how a restaurant kitchen differs from cooking at home. At home, when a recipe calls for stock, you make stock: you go and get bones, you simmer them, and the recipe's first step is three hours long. In a professional kitchen the recipe says "300ml stock" and stock arrives — someone else's job, made to a standard, available from a station. The chef cooking the dish does not know whether today's stock was made this morning or defrosted, and that ignorance is the point: it means the same dish can be cooked with a vegetarian stock for one service and a veal stock for another without a single line of the recipe changing. Notice what has actually been separated. It is not the work — someone still makes stock. It is the decision about which stock, which has moved out of the recipe and into the kitchen's setup, where it can be made once and changed centrally. A service container is that move. Your class declares what it needs by type; something else decides which implementation satisfies it; and the class becomes cookable in more than one kitchen — production, test, a console command — without being rewritten for each.

The container, in about forty lines

PHP — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Three binding styles cover essentially everything Laravel exposes. bind registers a recipe and runs it every time. singleton runs it once and remembers the result. instance skips the recipe and registers an object you already have — which is how a container ends up holding the request, the config, and anything else created before it.

Note the shape of make: check for a shared instance, guard against re-entry, run the binding or autowire, then remember the result if this abstract was registered as shared. The finally is doing real work — without it, a failed resolution leaves a stale resolving marker and every later attempt reports a circular dependency that is not there.

How it fails, which is most of what you will see

PHP — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Four of these five errors are configuration mistakes, and their messages are the container's entire user interface. One that says "Class App\Services\Maler does not exist" for all of them is a container you will spend hours with.

The unbound-interface case earns its special message: it is by far the most common, and the honest diagnosis — "this exists, you never told me what to build" — points straight at the provider needing a line.

The last block shows why the finally matters. If a resolution throws and the resolving marker is not cleared, the next attempt reports a circular dependency that does not exist, and you go looking for a cycle instead of reading the original error.

A little history

The pattern predates PHP's use of it by a decade and arrived from Java, where it was a response to a specific pain: enterprise applications whose object graphs were so large that wiring them by hand became a full-time maintenance burden. Martin Fowler's 2004 article "Inversion of Control Containers and the Dependency Injection Pattern" is the piece that named it — he coined "dependency injection" there precisely because "inversion of control" was too vague to distinguish this from every other callback-shaped design. The PHP ecosystem took a while. Symfony's DependencyInjection component landed in 2010 and took the compiled approach, generating a PHP class that constructs everything with no reflection at runtime — fast, explicit, and configured in XML or YAML, which is to say verbose. Laravel, arriving in 2011, went the other way: reflection-driven autowiring with almost no configuration, on the bet that a container which needs to be told nothing would be used by people who would never have written the XML. That bet was correct and is most of why Laravel felt so much lighter than its contemporaries. PSR-11 in 2016 then standardised the reading half of the interface — get() and has(), deliberately not the binding half, because the committee could not agree on configuration and correctly judged that interoperability only required consumers to be able to fetch. The two philosophies have since converged: Symfony added autowiring, and Laravel added container compilation, which is what optimize does.

What it costs

PHP — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

The numbers say what the argument usually will not. Reflection really is slower — four to five times slower than hand-written new here — and per resolution it is ~0.0025ms. Fifty services cost about 0.12ms, somewhere between a hundredth and a thousandth of one database round trip.

So the performance objection is real and irrelevant, which is more useful than either "containers are slow" or "reflection is free". And the third row shows why compiled containers exist: replaying a cached plan is over three times faster than re-reflecting, so at framework scale the caching earns its place even though each resolution is cheap.

The big question

If a container is just automated new, why not write the new calls? The answer is not effort, and framing it as effort is what makes the pattern look like ceremony. It is that a hand-written new hard-codes a decision at the point of use, and the decision is which concrete implementation satisfies a need. Written as new SmtpMailer(...) inside a class, that decision is now in a hundred places, it is invisible in the class's signature, and it cannot vary by environment — so the test suite sends real email, the local environment needs SMTP credentials, and swapping providers is a search-and-replace across the codebase. Declared as __construct(private Mailer $m), the decision has moved to one place that can differ per environment, and the class has become honest about what it needs, because its dependencies are now in its type signature rather than scattered through its method bodies. That second effect is the one that compounds. A constructor is a contract you can read in isolation: this class needs a mailer and a clock, and nothing else, which tells you what to mock, what will break if the mailer changes, and whether the class is doing too much — a constructor with nine parameters is a design review that writes itself. None of that information exists when the dependencies are created inline. So the container is not saving you typing; it is relocating a decision out of the code that shouldn't be making it, and the visibility of the resulting graph is worth more than the wiring it automates.

What it is actually for

PHP — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

The Clock example is the smallest complete argument for the pattern. Subscription has a real behaviour that depends on the current time, and with time() written inside it there is no way to test the expiry boundary except by changing the system clock or waiting. With the dependency declared, both assertions are two lines and run in microseconds — and Subscription never learns that FrozenClock exists.

The injection-versus-location contrast is where Laravel asks something of you. app(), resolve() and facades are service location, and they work, and they are genuinely convenient in a route closure or a quick command. Inside a class they cost you the property that makes the container worth having: that a constructor tells you what the class needs. A class with nine hidden app() calls looks exactly like a class with no dependencies.

From my notebook

Writing the container was the part that changed how I read framework errors. I had used Laravel's container for years with a mental model roughly like "it figures out what you need", which is not wrong but is unhelpfully shaped — it does not predict anything. Implementing autowire() replaced it with something much smaller and much more useful: for each constructor parameter, is the type a class or a builtin, and if a builtin, is there a default. That is it. And having that model, the errors stopped being mysterious and started being obvious. "Unresolvable dependency resolving [Parameter #0 [ <required> string $key ]]" is no longer a puzzle; it is the fourth row of my own table, the one that fails because a container cannot invent a string. The class_exists detail landed the same way — I had genuinely assumed it returned true for interfaces, and finding it does not explained a class of confusing message I had previously filed under "framework being weird". I would generalise it like this: for any framework component you use constantly and do not understand, the cheapest possible investment is to build the worst version of it yourself for an afternoon. You do not get a better container; you get the ability to predict what the real one will do, and that is what turns error messages from noise into information.

In the real world

The container bug that reaches production most often is a singleton holding request state. The shape is always the same and always reasonable at the time: a service is expensive to construct — it reads config, opens a connection, builds a lookup table — so someone registers it as a singleton, correctly. Later, that service grows a property: the current user, the current tenant, a locale, a request id, something set once and reused. Under PHP-FPM this is invisible, because the process dies at the end of the request and the singleton dies with it, so "one per request" and "one per process" are the same thing. Then the application moves to a long-lived worker — Octane, Swoole, RoadRunner, or a queue worker processing jobs in a loop — and the container is no longer torn down between requests. Now user A's tenant id is still sitting in the singleton when user B's request arrives, and the failure is intermittent, data-dependent, and catastrophic in exactly the way that is hardest to reproduce. The rule that prevents it is worth stating as a question to ask at registration time rather than as a warning: would it be correct for two different requests to share this object? If the answer is no, it is not a singleton, however expensive it is to build. And the reason the bug is so common is that PHP-FPM's shared-nothing model — the thing this volume opened by praising — hides the mistake perfectly until the day you stop relying on it.

Common Mistakes

  1. Checking only class_exists when resolving. It returns false for interfaces, so the container reports "no such class" when the real problem is a missing binding — the most misleading error it can give.

  2. Not clearing the resolution guard on failure. Without a finally, a failed resolution leaves a marker behind and every later attempt reports a circular dependency that does not exist.

  3. Registering request state as a singleton. Harmless under PHP-FPM because the process dies each request; catastrophic under Octane or a queue worker, where the container survives and user A's data reaches user B.

  4. Expecting the container to supply scalars. It cannot invent an API key. Give the parameter a default, bind it contextually, or pass it explicitly.

  5. Using app() or a facade inside a class. That is service location: the dependency vanishes from the signature, the class cannot be constructed without a configured container, and a class with nine hidden dependencies looks like one with none.

  6. Type-hinting a concrete class when you meant the interface. Autowiring will happily build the concrete class, and you lose the substitution that was the point of the container.

  7. Rejecting containers on performance grounds. Reflection is about four times slower than new, which is 0.13ms across fifty services — a fraction of one database round trip.

  8. Binding in a controller or a route file. Bindings belong in service providers, which run once at boot; scattering them means the wiring depends on which code path ran first.

  9. Building a deep graph to satisfy one method. If a class needs nine collaborators, the container is reporting a design problem accurately rather than causing one.

  10. Treating singleton as "faster". It is a statement about identity and shared state, not about speed. Choose it because sharing is correct, not because construction is expensive.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
What is the difference between bind and singleton?
Model answer — bind registers a recipe that runs every time the abstract is resolved, so each caller gets a fresh object. singleton runs the recipe once and returns that same object for the rest of the container's life. The choice is about identity and shared state rather than performance: pick singleton when it is correct for every caller to see the same object, and note that "the container's life" means one request under PHP-FPM but potentially thousands of requests under a long-lived worker like Octane.
Intermediate
How does autowiring actually work?
Model answer — Reflection on the constructor. For each parameter the container asks whether the type is a class or interface — if so it resolves that recursively — or a builtin, in which case it needs a default value, a nullable type, or an explicit binding, and otherwise it fails, because it cannot invent a string. That is the whole algorithm, and it explains the errors: "unresolvable dependency … string $key" is the builtin case with no default. One detail worth knowing is that class_exists() returns false for an interface, so a container that checks only that reports "class not found" when the real problem is an unbound interface.
Advanced
A service works under PHP-FPM and leaks data between users under Octane. What happened?
Model answer — A singleton holding request state. Under FPM the process is destroyed after each request, so a container-scoped singleton is effectively request-scoped and storing the current user or tenant on it is invisibly safe. Under a long-lived worker the container survives between requests, so that property persists and the next request sees the previous user's data — intermittently, and dependent on which worker handles the request, which makes it very hard to reproduce. The fix is to scope it correctly: register the service as non-shared, or keep the mutable state out of it and pass it per call. The check worth applying at registration is whether it would be correct for two different requests to share this object; if not, it is not a singleton no matter how expensive it is to build.
micro1Practical: bind vs singleton, and injecting a dependency.GoogleDeepest — the autowiring algorithm and its failure modes.MetaInjection versus service location as a design question.AmazonLifetime bugs that only appear under long-lived workers.

Hands-on

Hands-on

Model the container rules this chapter measured. Implement resolutionFor, the per-parameter decision; lookupExists, the class_exists trap; bindingKind, choosing between bind, singleton and instance; cycleIn, detecting a resolution cycle; and safeAsSingleton, the lifetime rule. A stub scores zero, so a passing run means the model is real.

PHP — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Summary

  • A container is one decision table applied recursively: class type, resolve it; builtin with a default, take it; nullable, pass null; otherwise fail.
  • class_exists() returns false for an interface, so a resolver that checks only it reports "no such class" for what is really a missing binding.
  • bind runs the recipe every time, singleton runs it once, instance registers an object you already hold. The choice is about identity, not speed.
  • The resolution guard must be cleared in a finally, or a failed resolution makes every later attempt report a phantom cycle.
  • A container cannot invent a scalar. An unresolvable string $key needs a default, a contextual binding, or an explicit argument.
  • Reflection costs 4–5× a hand-written new — ~0.0025ms per build, roughly 0.12ms across fifty services, against 1–10ms for one database round trip.
  • Caching the resolution plan is over 3× faster than re-reflecting, which is what a compiled container does and what optimize produces.
  • The point is relocating a decision, not saving typing: which implementation satisfies a need moves out of the class and into one place that can vary by environment.
  • Constructor injection puts dependencies in the signature; app() and facades hide them. A class with nine hidden dependencies looks identical to one with none.
  • A singleton holding request state is safe under PHP-FPM and catastrophic under a long-lived worker, because FPM's shared-nothing model hides the mistake rather than preventing it.

Next: routing and controllers — how a URL becomes a method call, what a route actually compiles to, and where the container quietly does the work.

Editorial reflection

There is a particular kind of relief in discovering that a thing you found impressive is simple, and I think it is worth being slightly suspicious of it. Laravel's container really is about two hundred lines of idea, and building a working one in forty was genuinely clarifying — the errors became predictable, the lifetime rules became obvious, and the framework stopped feeling like a system with opinions and started feeling like a decision table I could have written. But the gap between my forty lines and Laravel's implementation is not padding. It is contextual bindings, method injection, tagged services, extenders, rebinding callbacks, compiled resolution, and a decade of edge cases discovered by people running it on code I will never see. The simple model is the right one to hold, and it is not the same thing as the implementation being simple. What I take from that is a caution about the phrase "it's just" — as in "a container is just automated new", which is the framing I very nearly used as this chapter's opening line. It is true about the idea and false about the software, and the distinction matters because the first kind of truth lets you predict behaviour while the second kind of confidence leads people to reimplement, badly, something that had good reasons for its size. Understanding the core is what lets you use a tool well. It is not the same as being able to replace it, and the difference between those two is most of what experience is.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.