Request scope & Swoole
The request scope is the one place the runtime matters. Under a traditional
process-per-request model it’s just a singleton; under Swoole, where one worker process serves
thousands of concurrent requests, it has to isolate each request’s instance — and it does that
through the coroutine context.
The problem Swoole creates
Under PHP-FPM, “one request = one process”: a singleton stored in process memory is
automatically per-request, because the process dies at the end of the request. Nothing leaks.
Under Swoole the assumption breaks. A single long-lived worker handles many requests
concurrently, all sharing the same process memory. A singleton holding the current user
would be visible to every in-flight request at once — a data-leak bug. The request scope
exists to give each concurrent request its own instance without that leak.
How isolation works
Swoole runs each request in its own coroutine, and each coroutine has an isolated context
object (Coroutine::getContext()) that is destroyed when the coroutine ends. Winter DI stores
request-scoped instances there, under a private __di key:
Worker process (shared memory)
├─ Coroutine #1 ── getContext()['__di'] ── AuthContext(user: Ada)
├─ Coroutine #2 ── getContext()['__di'] ── AuthContext(user: Linus)
└─ Coroutine #3 ── getContext()['__di'] ── AuthContext(user: Grace)
each request sees only its own instance; context freed when the coroutine endsTwo moments in the resolution lifecycle touch this:
- Read — before building a
request-scoped abstract (and with no overrides),make()looks in the current coroutine’s__dibucket and returns any instance already there. - Write — after building, the instance is written back into that same bucket, so the rest of the request reuses it.
The detection is deliberately narrow: the coroutine path is taken only when ext-swoole is
loaded and the code is running inside a coroutine (Coroutine::getCid() > 0).
The fallback: FPM and CLI
When there’s no active coroutine — plain FPM, CLI, or Swoole code running outside a coroutine — the container writes the instance into its process-level cache instead, exactly like a singleton. This is correct precisely because one process serves one request in those runtimes.
| Runtime | Where a request-scoped instance lives | Effective lifetime |
|---|---|---|
| Swoole (in coroutine) | Coroutine::getContext()['__di'] |
per coroutine, freed at coroutine end |
| FPM | process-level cache | per process = per request |
| CLI | process-level cache | per process = per command |
No manual cleanup
You never clear the request cache yourself. Under Swoole the coroutine context is discarded when the coroutine finishes; under FPM/CLI the process exits. Both reclaim request-scoped instances automatically.
Practical guidance
- Put mutable per-request state (auth context, current user, unit of work, request-bound
counters) in the
requestscope — never in asingletonunder Swoole. - Keep stateless services (
repositories, connection pools, config readers) assingleton— sharing them across coroutines is safe and cheap. - Use
transientfor throwaway stateful objects (query builders, DTOs) that shouldn’t be shared even within one request.
The full recommendation table is on the Scopes page.
The classic Swoole leak
The most common bug is a #[Singleton] service that stores the current user or request id.
It works in FPM tests and leaks across concurrent requests in production Swoole. When a class
holds anything request-specific, it must be #[Request].
Related
- Scopes — the three scopes and the safety matrix
- Resolution lifecycle — where the coroutine cache is read/written
- Reflection cache — the other Swoole hot-path optimisation