A static array with the comment “cache, cleared per request”. It went to production on RoadRunner last month, and the memory graph turned into a staircase.
PHP had one superpower nobody advertised: the request died. Every leak, every forgotten static, every open transaction was erased when the process shut down. Shared-nothing was not architecture. It was amnesia, and amnesia forgave us everything.
RoadRunner and Swoole take it away. The worker lives for thousands of requests, and the class of bugs changes on the first day. That “per request” cache became a cache per worker lifetime, and the supervisor killed the process when it ran out of memory. A logger kept the request id in a property, so entries from user B carried the id of user A. And the best one: an exception in the middle of a Doctrine transaction left the connection with the transaction open, and the next request on that worker silently joined it.
None of this code was wrong. The runtime it ran on changed.
My checklist now, before a service goes on persistent workers. Everything request-scoped gets reset between requests, including things you never think of, like a locale set on some intl formatter. Every transaction gets a rollback in finally. Every in-process cache gets a size bound. Database and Redis connections must survive a server-side disconnect: read the reconnect code yourself, the readme lies.
And one measurement instead of faith. Run ten thousand requests through one worker and watch memory_get_usage(true) between them. Flat line, good. Staircase, you have a leak that FPM would have hidden from you forever.
We cut p50 roughly in half by not rebuilding the container every request. The win is real. It is paid for with discipline FPM never asked from us, and I am not sure the whole team signed up for it.