php-fpm RSS that Laravel will not show you
memory_get_usage() went back down at the end of the request. The host still grew. Copy-on-write turned shared OPcache pages into private RSS after Eloquent hydrate.
nginx returned 502 for a minute after the OOM killer took a php-fpm worker. The Docker host has one memory budget for php-fpm, Redis, MySQL, Memcached, and Node. php-fpm looked fine in the Laravel log. memory_get_usage(true) at the end of a notes list request sat near 12 MB. Eight workers still filled a 2 GB box.
There was no leak in the PHP allocator. The host grew because copy-on-write copies a page the moment a worker writes it. OPcache is shared after fork. Hydrating an Eloquent collection with three locales in JSON writes a lot of pages. Those pages become private RSS and they stay private until the worker dies. Users saw 502. The host saw RSS that Laravel never printed.
Telescope and the log both agreed the request was small. smem on the worker did not.
What the workers held
The list endpoint used to cache Eloquent models in Redis. serialize() stores attributes, relations, class names, and extra state. On get, PHP rebuilt that graph. Each row carried en, ru, and de even when the request asked for one language.
The cache value became a list of arrays with four keys: id, title, image, size. Title is already the string for the request locale. Redis stores that JSON. The worker heap stopped cloning the whole model graph.
Accessors that touch a relation still dirty pages after the DTO change. One notes list loaded category for a badge. That hydrate copied more OPcache-adjacent pages into private RSS. I moved the badge field onto the cached array at write time. The request no longer woke Eloquent for the grid.
Limits that actually bound the host
pm.max_requests = 200 so a worker dies before private RSS becomes the machine. memory_limit stays at 128M. That limit never saw the host RSS, because RSS is not the PHP allocator. The Docker memory limit on the php service has to fit all workers plus a margin. If Redis and MySQL share the same VM, count them too.
I measure RSS after fork, after the first list, after two hundred lists. If the first two numbers match and the third has climbed by 100 MB, the next feature added an accessor that touches a relation. The N+1 test would not have caught that. The RSS log did.
pm.max_children on a shared 2 GB box is not the number that looks good on a dedicated PHP host. Eight workers times a private RSS that climbed after hydrate is how the OOM picked a victim. I dropped children until Redis and MySQL still had room after two hundred lists.
What I took from this
I measure worker RSS after fork and after N lists, not memory_get_usage() at request end. A 12 MB allocator and a growing host can both be true.
I do not trust Eloquent in the Redis list cache or memory_limit as a host bound. Cache a four-key array per tile. Recycle workers with pm.max_requests.
The rule I keep: count Docker memory for all workers plus MySQL and Redis. If RSS climbs 100 MB by request 200, an accessor woke a relation. Kill the worker before the box.
