After moving a PHP API to worker mode, some responses show another user's data; what state is leaking between requests, and how do you find and fix it?
answer
- the previous request's user
- static property or memoised singleton
- static variable inside a function
- reset per request, or never store it
- two requests, two users, one worker
basics
~20 sRequest data, typically the authenticated user, was stored in something that outlives the request: a static property, a static function variable or a memoised singleton. The next request on that worker reuses it. Make such state request-scoped or reset it after each request.
solid answer
~40 sIn worker mode one PHP process serves many requests, so any request-specific value kept in long-lived memory leaks. The classic case: a `CurrentUser` service or helper resolves the user once and memoises it in a static property, a `static` variable inside a function, or a field of a shared singleton; the next request on that worker, from a different user, gets the cached user. Finding it: reproduce with one worker and two sequential requests from different users, then audit statics, `??=` memoisation, singletons holding per-request data and closures that captured the request. Fixing it: pass request data explicitly or keep it in request-scoped services, and give every stateful service a reset method that the worker loop calls in a `finally` after each request. Frameworks with worker-mode support offer reset hooks for exactly this.
code
php · 13 lines<?php
declare(strict_types=1);
// Leaks in worker mode: set by the first request, reused by all later ones.
final class CurrentUser
{
private static ?User $user = null;
public static function get(Request $request, UserRepository $users): User
{
return self::$user ??= $users->byToken($request->bearerToken());
}
}go deeper
Recall that in worker mode a value stored in a static property or singleton survives into the next request, which may belong to another user.
Explain where request data hides (statics, static variables, singleton fields, captured closures) and why resetting in finally matters.
Reproduce with one worker and two users, turn it into a regression test, and make services stateless or resettable across the code base.
Set the rule that governs every future service: explicit lifetimes, a reset contract and an analysis rule, so worker safety does not rest on reviewers' memory.
## Why the bug appears only after the move Under PHP-FPM each request starts with empty memory, so code can cache anything for "the rest of the request" without thinking about the next one. In **worker mode** (FrankenPHP, RoadRunner, Swoole) one PHP worker handles request after request and the engine does not wipe its memory in between. Code that was correct for years suddenly shares data across users. The reported symptom, users occasionally seeing someone else's name, cart or permissions, has a specific shape: it only happens when two users hit **the same worker** one after the other, which is why it looks random and disappears when you debug with a single request. ## Where the logged-in user hides - **Static properties**: `self::$user ??= $this->loadUser()` memoises on the first request and returns the same user forever. - **`static` variables in functions or methods**: `static $user = null;` survives across calls, and across requests in a worker. - **Shared singleton services**: a container service built once at boot that stores `$this->user` during a request. - **Closures that captured the request**: an event listener or callback registered during a request, holding `$request` or the user, and kept in a long-lived registry. - **Caches keyed without the user**: an in-memory array cache of "my permissions" or "my dashboard" keyed by route only. ## Finding it 1. **Reproduce deterministically**: run the runtime with a single worker, send a request as user A, then as user B, and compare. With one worker the leak becomes reliable. 2. **Write that as a test**: boot the application once, handle two requests with different credentials through the same kernel instance, and assert that the second response belongs to B. 3. **Audit the code** for `static` properties and variables, `??=` memoisation on long-lived objects, and services that store request data in fields. 4. **Let static analysis help**: a rule forbidding writes to static properties outside boot code catches many cases. ## Fixing it | Pattern | Fix | |---|---| | User memoised in a static property | inject a request-scoped value or pass the user explicitly | | Singleton service storing request data | make it stateless, or give it a `reset()` called after each request | | Listener registered per request | register once at boot, or remove it after the request | | In-memory cache of per-user data | key by user, or keep it only for the request | The safest design keeps long-lived services **stateless**: they receive the request or the user as arguments and store nothing about them. Where state is unavoidable, the worker loop must reset it **in a `finally` block**, so an exception thrown by one request cannot skip the cleanup and leak state into the next. Frameworks that support worker mode ship reset mechanisms for their own services; your own services must opt in. ## Why session settings are not the fix The session cookie and the session data are usually correct for every request; the leak happens **after** authentication, when the resolved user object is cached in process memory. Changing session storage, lifetimes or cookie flags therefore changes nothing. The fix lives in how the application stores the user once it has been resolved, which is why the audit targets statics and long-lived services, not the session layer. ## Beyond the user object The same audit catches other leaks with the same cause: an open database transaction left behind by a failed request, an ORM's identity map still holding entities loaded for the previous user, a logger's buffered context carrying the last request ID, or a feature-flag service cached for one tenant. Each is request data living in process memory. ## The principle to state in the interview In worker mode **lifetime is explicit**: anything you want to live for one request must either be created per request or reset after it. Everything else lives as long as the worker, and the worker serves many users.
- Why must the reset run in a finally block rather than after handle() returns?If a request throws, code placed after `handle()` never runs, so the failed request's user or open transaction stays in memory and the next request on that worker inherits it. A `finally` block runs on both the normal and the exception path, so every request is followed by a reset.
- Is a static property always a leak in worker mode?No. A static holding data that is the same for every request, such as compiled configuration or a lookup table built at boot, is exactly the kind of state worker mode is meant to reuse. It becomes a leak when it holds something that depends on the request: the user, the tenant, the locale or request-derived permissions.
- How would you stop this class of bug from coming back?Add a regression test that handles two requests as different users through one booted kernel, forbid writes to static properties outside boot code with a static-analysis rule, and require every stateful service to implement a reset contract that the worker loop calls.
saying these in an interview costs you the question
- Worker-mode runtimes isolate statics per request automatically
- Only global variables can leak, not static properties
- Restarting workers every hour fixes the cross-user leak
- Resetting services after handle() returns is enough
- The bug is a session problem, so session settings fix it