0

Memory leak — memory allocated but never freed; how it kills long-running PHP

Intermediate5 min read·eng-20-006
interviewperformance

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, saving listeners 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
<?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);