Laravel Octane memory management is tricky. Learn how to profile and fix circular references in long-running processes to prevent production memory leaks.
Last month, our primary API server hit an OOM (Out of Memory) error at 3 AM, despite having a massive 16GB of overhead. We were running Laravel Octane on Swoole, and the memory consumption of our worker processes was climbing steadily until the kernel finally killed them.
If you're moving your stack to Octane, you're essentially moving from a "share-nothing" architecture to a persistent one. In standard PHP-FPM, the process dies after every request, effectively resetting the state. In Octane, the application stays in memory. If you aren't careful, you'll accumulate objects that the garbage collector can't touch.
In a long-running process, PHP's garbage collector (GC) works differently than you might expect. When you hold onto references in static variables or singletons, the memory isn't reclaimed when the request finishes.
The biggest culprit I've encountered is the circular reference. When Object A points to Object B, and Object B points back to Object A, the reference count never hits zero. Even if you nullify the variables, the internal PHP cycle collector has to work significantly harder to clean these up. In an Octane environment, if these cycles are created during every request, your memory usage will grow in a "staircase" pattern until your server crashes.
Before you start refactoring, you need to see exactly what’s happening. Don't guess—measure. I use memory_get_usage() wrapped in a simple middleware to monitor the footprint during the request lifecycle.
If you suspect a leak, try this snippet in a dedicated testing route:
PHP#6A9955">// In a controller or route closure $before = memory_get_usage(); #6A9955">// Trigger the suspected logic $service = app(HeavyService::class); $service->execute(); $after = memory_get_usage(); Log::info("Memory delta: " . ($after - $before) / 1024 . " KB");
For deeper analysis, I reach for PHP-Meminfo. It’s a bit of a pain to set up, but it gives you a snapshot of all objects currently in memory. You’ll be looking for object counts that increase linearly with every request. If you see a User or Job object count that never returns to baseline, you've found your leak.
We first tried solving our leak by manually calling gc_collect_cycles() at the end of every request. It worked for about two days, but it added roughly 45ms of latency to our requests. It was a band-aid, not a fix.
The real issue was our reliance on singletons that were inadvertently holding onto request-specific data. If you resolve a service in the container that acts as a singleton, and that service stores the current Request object, you have created a permanent reference to every request that passes through your system.
If you must use singletons in Octane, you need to implement a "reset" mechanism. Octane provides a warm and reset lifecycle hook.
Octane::prepareApplicationForNextOperation() hook.register phase.WeakReference. It allows you to hold a reference to an object without preventing it from being garbage collected.Here is how I refactored a problematic logger that was holding onto request context:
PHPuse WeakReference; class RequestContextLogger { private ?WeakReference $request = null; public function setRequest($request) { $this->request = WeakReference::create($request); } public function log($message) { $request = $this->request?->get(); #6A9955">// Even if the request object is destroyed, #6A9955">// this logger won't force it to stay in memory. } }
When you're dealing with high-concurrency, you should also look at Optimizing Laravel Service Container Performance: Beyond Reflection. Reducing the overhead of resolving dependencies helps the GC stay ahead of the game. If you're building complex pipelines, consider Laravel Performance Optimization: Building Content-Aware Batching Pipelines to keep the memory footprint predictable.
One thing we are still experimenting with is the max_requests configuration in Octane. Even with perfect code, PHP has a tendency to fragment memory over time. Setting a max_requests limit (e.g., 500-1000) acts as a safety valve. It forces the worker to restart, effectively clearing any "hidden" memory fragmentation that the garbage collector couldn't resolve.
Does Octane automatically handle memory leaks? No. Octane keeps the application in memory. If your code creates objects that aren't properly destroyed, they stay there. Octane manages the application state, not your business logic memory usage.
Is gc_collect_cycles() a good fix?
Only as a last resort. It's expensive. It’s better to design your code to avoid circular references than to force PHP to clean them up manually.
How do I know if a singleton is leaking memory?
Check if the memory usage of your worker process increases after each request in your load testing environment. If it does, log the count of your primary objects (like User or Order) using gc_status().
Memory management in long-running processes is a trade-off. We gained significant speed by switching to Octane, but we traded away the "lazy" safety of PHP-FPM. My advice? Don't optimize until you have a baseline. Monitor your memory growth, isolate the objects, and use WeakReference where you need to keep a handle on objects without owning their lifecycle.
Master Laravel middleware request collapsing to solve high-concurrency bottlenecks. Learn to implement deterministic memoization and batching for faster APIs.