How do I debug memory leaks under Laravel Octane (FrankenPHP)?
Question
We run Laravel Octane with the FrankenPHP driver at `max-requests=500`. Under heavy traffic the workers' memory climbs fast, RAM balloons, and the server gets close to locking up. PHP-FPM clears memory after every request, but Octane is stateful — singletons, static variables, and third-party packages cause this leak. How do I debug it, and how do I optimize memory while keeping workers persistent?
Answer
Short answer: don’t guess, measure — in Octane the worker is long-lived, so anything that accumulates between requests leaks; track what’s piling up first, then reset the stateful side in the hooks.
Short answer
The real issue: PHP-FPM assumes “a fresh process per request,” but Octane keeps the worker alive. Everything that breaks that assumption — growing static arrays, singletons holding request state, listeners/bindings registered per request, global stores (Log, Auth, the current request) that never reset — quietly inflates memory. I covered what the persistent-process model changes in the Laravel Octane post.
Why
-
A leak here isn’t a bug, it’s a broken assumption. Under PHP-FPM every request was born and died in its own process, and leaky code died with it. Octane removes that safety net and the same code starts accumulating.
-
The culprit usually isn’t your code. A third-party package that assumes a fresh process per request is invisible under PHP-FPM and explosive under Octane. That’s why this needs measurement, not reading.
-
max_requestshides the root cause. Recycling the worker after a set number of requests caps RAM but doesn’t close the leak — lowering500just moves you from crashing fast to crashing late.
What to do
-
Measure first, talk later. Log
memory_get_usage(true)every N requests; if memory climbs monotonically you have a leak, if it oscillates and drops it’s normal. Drop a counter into Octane’sRequestTerminatedhook and look at the shape of the curve. -
Bisect to find the culprit. Disable service providers and suspect packages one at a time and re-measure; wherever the growth stops, that’s your leak. A snapshot diff (the delta of
memory_get_usagebetween two points) narrows which type is accumulating. -
Reset stateful singletons in the hooks. Clear bindings that hold request state in the
RequestReceived/RequestTerminatedhooks, or via theflushlist inconfig/octane.php. Leaving the singleton in the container but resetting the state inside it closes most leaks in a single line. -
Kill static accumulation, rebind services per request. Ever-growing static
$cache = []arrays, connections that never close, collections that swell per request — all classic. Don’t make a request-scoped service a singleton; rebind it each request so the data it carries dies with the request. -
Keep
max_requestson as a safety valve. But do the actual work by closing the leak; don’t lean on it.
Bottom line: if it were me, I’d order it like this — plot the curve with memory_get_usage first, narrow the culprit by bisecting service providers/packages, then reset stateful singletons in Octane’s hooks and clear static accumulation. Keep max_requests as a net but don’t lean on it. If you want Octane’s speed, you have to accept “the process doesn’t die per request” in every layer of your code.
Related Reading
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.