Package · di

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:

text
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 $abstract already on THIS unit of work's stack
│                      → throw ContainerException naming the chain
5. wait for singleton? if scope 'singleton' AND another coroutine builds it
│                      → wait, then return its instance
6. mark as building    push $abstract onto this unit of work's stack

7. doResolve()         build the instance (see below)
8. injectProperties()  fill #[Autowired] / #[Inject] / #[Lazy] properties
9. cache()             if $overrides empty: store per scope
│                      finally: pop $abstract off the stack
└─ return instance

1 · 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 pushed onto the resolution stack of the current unit of work — the coroutine context under Swoole, a plain container property off that path. If resolution re-enters make() for an abstract already on that stack, that’s a cycle, and the container throws, naming the whole chain:

text
ContainerException: Circular dependency detected while resolving
[App\SmsSendService] → [App\FakeSendService] → [App\SmsSendService].

The stack is per unit of work, not per worker: a neighbouring request building the same class at the same moment is not a cycle. Why it works that way, and what would happen otherwise, is on Concurrent resolution.

The entry is popped in a finally, so a failed resolution never leaves a stale guard. #[Lazy] sidesteps a cycle by injecting a proxy instead of recursing — see Breaking circular dependencies.

5 · Waiting for a singleton

If a singleton is requested that is not cached yet and another coroutine is building it right now, make() waits for that result and returns the same instance instead of building a second one. A singleton factory therefore runs once per worker even under simultaneous demand. Waiting can only happen on the first resolution; after that step 1 answers.

6 · Marking as building

The abstract goes onto the current unit of work’s stack — this is the entry step 4 sees if resolving a nested dependency comes back around to the same class.

7 · doResolve — construction

text
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 NotFoundException

resolve() reads the constructor’s parameters (via ReflectionCache, memoised) and builds each argument. A constructor with no parameters is just new $class().

8 · 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.

9 · 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 9 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:

text
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 ContainerException

Two 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, not make(). That’s what lets a contextual() factory see the consumer during injection while a direct make() doesn’t.