Memory leak — memory allocated but never freed; how it kills long-running PHP
Concept
Memory leak — a situation where a program allocates memory but never releases it, causing memory consumption to grow indefinitely until the process runs out of memory and crashes.
In traditional PHP (per-request model): Each request is a separate PHP process. When the request ends, all memory is freed automatically. Memory leaks in normal PHP don't accumulate across requests — they cause one request to use too much memory.
In long-running PHP processes (queue workers, WebSocket servers, Laravel Octane, scheduled tasks): The process runs for hours or days. Memory that isn't freed accumulates over time. This is where PHP memory leaks become critical.
Common causes of memory leaks in PHP:
- Circular references: Object A has reference to B, B has reference to A. PHP's reference counter can't free them. PHP's garbage collector handles most cases, but can be slow.
- Static variables: Static properties accumulate data without being cleared.
- Event listeners holding closures: Closures captured variables keep them in memory.
- Eloquent model events: Multiple
creating,savinglisteners registered in loops without cleanup. - Result sets not freed: Large Eloquent collections kept in memory.
Detecting memory leaks:
memory_get_usage()— current memory usage.memory_get_peak_usage()— peak memory usage.- Monitor worker memory with
php artisan queue:work --max-memory=256(restart if exceeds 256MB). - New Relic, Datadog, or Valgrind for production profiling.
Laravel queue workers: --max-memory flag restarts the worker if it exceeds the limit. --max-jobs=1000 restarts after 1000 jobs. Both are safeguards against slow memory leaks.
Code Example
<?php
// DETECTING memory growth in a long-running process
$memBefore = memory_get_usage();
// Run 1000 iterations of a job
for ($i = 0; $i < 1000; $i++) {
processOrder(Order::find($i));
if ($i % 100 === 0) {
$memNow = memory_get_usage();
$delta = ($memNow - $memBefore) / 1024 / 1024;
echo "Iteration {$i}: {$delta}MB growth\n";
// If delta keeps growing → memory leak
}
}
// COMMON LEAK: large collection not freed
// BAD — keeps all users in memory
User::all()->each(function ($user) {
processUser($user); // but $users collection never freed while loop runs
});
// GOOD — cursor yields one at a time, frees previous
User::cursor()->each(function ($user) {
processUser($user);
// each model freed after iteration
});
// Also: chunk() for DB-based batching
User::chunk(100, function ($users) {
$users->each(fn($user) => processUser($user));
// $users collection freed after each chunk
});
// QUEUE WORKERS — safeguards
// php artisan queue:work \
// --max-memory=256 \ restart if > 256MB
// --max-jobs=500 \ restart after 500 jobs
// --sleep=3 \ sleep 3s if queue empty
// --tries=3 retry failed jobs 3 times
// In Supervisor config:
// [program:queue-worker]
// command=php artisan queue:work --max-memory=256 --max-jobs=500
// autorestart=true ← restart automatically when process exits (including memory limit exit)
// CIRCULAR REFERENCE (PHP garbage collector handles, but can cause pauses)
class Node {
public ?Node $parent = null;
public array $children = [];
}
$parent = new Node();
$child = new Node();
$parent->children[] = $child;
$child->parent = $parent;
unset($parent, $child);
// refcount of each is still 1 (pointing to each other)
// gc_collect_cycles() — force GC to collect these
// Or: null the back-reference before unset: $child->parent = null; unset($child, $parent);