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 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 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 recorded in a building set. If resolution re-enters make() for an abstract already being built, that’s a cycle — the container throws:

text
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

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().

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:

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.