Mental model
Two ideas explain almost everything in this container. First: a type hint is a wiring instruction — the container reads it and builds the whole object graph. Second: a scope decides whether each object in that graph is shared or freshly built. Hold those two, and the rest follows.
Idea 1 — a type hint is the wiring
When you ask for a class, the container looks at its constructor. Every typed parameter is a dependency it must supply, so it resolves each one the same way — recursively — until it reaches classes with no dependencies. Then it builds back up.
make(UserService::class)
└─ needs UserRepository
└─ needs DatabaseConnection
└─ (no dependencies) → new DatabaseConnection
← new UserRepository(db)
← new UserService(repo)You wrote no wiring code for this. The declared types are the configuration:
class UserService
{
public function __construct(private UserRepository $repo) {}
}The practical consequence: you stop calling new for services. You describe what a class
needs, and the container assembles it. Interfaces are the one case that needs help — the
container can’t guess which implementation you mean, so you tell it once with a binding (see
Service providers).
Idea 2 — a scope decides sharing
Building the graph is only half the story. The other half is identity: when two classes both
need a DatabaseConnection, do they get the same one or their own? That’s the scope.
| Scope | Identity | Built |
|---|---|---|
singleton |
one shared instance per process | once, then cached |
request |
one per HTTP request / coroutine | once per request |
transient |
a new instance every time | on every resolution |
A class picks its scope with an attribute (#[Singleton], #[Request], #[Transient]), or
you set it with a binding. With no attribute and no binding the scope is transient — the
safe default, because a fresh object can never leak state.
Why the default is transient
A shared object that holds per-request state (the current user, a request id) leaks that state
across requests — a real hazard in long-running Swoole workers. Defaulting to transient means
nothing is shared until you deliberately say so. Full guidance in
Scopes.
Putting them together
Resolution walks the graph (Idea 1); at each node the scope decides whether to reuse a cached
instance or build a new one (Idea 2). So a single #[Singleton] DatabaseConnection shared by
many repositories is built once, while a #[Transient] QueryBuilder is fresh for each holder —
all in the same resolution.
What follows from this
- Attributes on properties — sometimes you can’t inject through the constructor (a base
class owns it).
#[Autowired]/#[Inject]on a property inject after construction, still by type. See Injecting into properties. - A real cycle is an error — if A needs B and B needs A, recursion can’t terminate. The
container detects it and throws. When the cycle is legitimate,
#[Lazy]injects a proxy so one side resolves later — see Breaking circular dependencies. - The consumer can shape a dependency — because the container knows which class it is
injecting into, a
contextual()factory can tailor the instance per consumer (a logger named after its user). See Per-consumer logger.
When to reach for it
- ✅ Assembling stateless services and their dependencies without boilerplate.
- ✅ Giving classes clear lifetimes (shared vs per-request vs fresh).
- ❌ Threading request-specific values through constructors — pass those as method
arguments or via
make()overrides, not as autowired dependencies.
Next
- Do it hands-on: Quickstart
- The lifetimes in depth: Scopes
- How resolution actually runs: Resolution lifecycle
- The authoritative surface: API reference