Package · di

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:

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

Two 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 __di bucket 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 request scope — never in a singleton under Swoole.
  • Keep stateless services (repositories, connection pools, config readers) as singleton — sharing them across coroutines is safe and cheap.
  • Use transient for 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].