Resolution lifecycle
This is what actually happens when you call make() — read from the source, not
from memory. Understanding the order explains the caching rules, the circular-dependency
error, and why overrides behave the way they do.
The make() pipeline
Every make($abstract, $overrides) runs the same sequence:
make($abstract, $overrides)
│
1. resolved cache? if $overrides empty AND $abstract in resolved → return it
│ (singletons and set() values live here)
2. scopeOf($abstract) binding scope → class attribute → 'transient'
│
3. request cache? if scope 'request' AND $overrides empty
│ AND coroutine context has it → return it
4. cycle guard if building[$abstract] → throw ContainerException
│ else building[$abstract] = true
│
5. doResolve() build the instance (see below)
6. injectProperties() fill #[Autowired] / #[Inject] / #[Lazy] properties
7. cache() if $overrides empty: store per scope
│ finally: unset building[$abstract]
└─ return instance1 · Resolved cache
If there are no overrides and the abstract is already in the resolved map, it’s returned
immediately — zero reflection. This map holds realised singletons, set() values, and
the self-registered container (Container / ContainerInterface both map to $this).
2 · Determining scope
scopeOf() decides the lifetime, in order: an explicit binding’s scope wins; otherwise the
class’s scope attribute (#[Singleton] / #[Request] / #[Transient]); otherwise
transient.
3 · Request-scope cache
For a request-scoped abstract with no overrides, the container checks the Swoole coroutine
context (Coroutine::getContext()['__di']) before building. Outside a coroutine there is no
such context and it falls through — see Request scope & Swoole.
4 · Circular-dependency guard
Before building, the abstract is recorded in a building set. If resolution re-enters make()
for an abstract already being built, that’s a cycle — the container throws:
ContainerException: Circular dependency detected while resolving [SmsSendService].The entry is cleared in a finally, so a failed resolution never leaves a stale guard.
#[Lazy] sidesteps this by injecting a proxy instead of recursing — see
Breaking circular dependencies.
5 · doResolve — construction
binding exists?
├─ concrete is callable → concrete($container) (factory closure)
└─ concrete is a class → resolver->resolve(concrete) (autowire constructor)
no binding?
├─ class_exists → resolver->resolve($abstract) (autowire directly)
└─ otherwise → throw NotFoundExceptionresolve() reads the constructor’s parameters (via ReflectionCache,
memoised) and builds each argument. A constructor with no parameters is just new $class().
6 · Property injection
After construction, injectProperties() scans the class’s non-promoted properties for
#[Autowired], #[Inject], or #[Lazy] and fills each one. Constructor-promoted properties
are skipped — they were already set during construction.
7 · Caching
Only when $overrides is empty:
| Scope | Cached in |
|---|---|
singleton |
the process-level resolved map |
request |
the coroutine context (Swoole) or resolved map (FPM / CLI) |
transient |
not cached |
Why overrides are never cached
Steps 1, 3, and 7 all guard on $overrides being empty. An instance built with overrides
isn’t the canonical object for that abstract, so caching it would poison every later resolve.
That’s the deliberate rule: overrides always build fresh and are never shared.
How one argument is resolved
Inside resolve() / call(), each parameter is decided by the first matching rule — this is
the precedence you rely on when mixing overrides, attributes, and defaults:
for each parameter:
1. name in $overrides? → use the override value
2. #[Inject] present? → resolve id (or declared type); lazy → proxy
3. typed (non-builtin)? → makeContextual(type)
└─ NotFoundException + has default → use default
└─ #[Lazy] → inject a proxy instead
4. has a default value? → use it
5. optional? → skip
6. otherwise → throw ContainerExceptionTwo consequences worth internalising:
- A resolvable dependency with a default still gets injected. The default is a fallback used only when the type can’t be resolved — not a reason to skip injection.
makeContextual()is the injection entry point, notmake(). That’s what lets acontextual()factory see the consumer during injection while a directmake()doesn’t.
Related
- Reflection cache — how parameter metadata is memoised
- Request scope & Swoole — the coroutine cache in step 3
- Lazy proxies — the proxy branch in steps 5–6
- API reference —
make()/call()signatures