Loading...

PHP’s Hidden Memory Leak: Why Your Cron Jobs Crash at 3 a.m. (And How to Fix It in 20 Minutes)

I spent 47 minutes staring at memory_get_usage() output inside a for loop, watching the number climb—12.4 MB → 13.1 MB → 13.9 MB—then jump to 18.2 MB on iteration 843. No exception. No stack trace. Just silence, then “Allowed memory size of 256M exhausted.” The script was a Laravel command that processed subscription renewals for ~12K customers. It ran fine locally. It passed QA. It worked for three weeks in staging. Then, at 3:17 a.m., it died—every night—on the exact same iteration.

That wasn’t bad code. It wasn’t misconfigured opcache. It wasn’t an N+1 query. It was PHP’s garbage collector doing exactly what the manual says it does—and nobody warning me that exactly how it does it breaks under long-running CLI workloads.

This isn’t theoretical. I’ve seen it kill cron jobs, queue workers, migration scripts, and nightly report generators—not just at one place, but across four different projects: a bootstrapped SaaS with 42K users, a freelance invoicing tool for a regional accounting firm, a side project that sent personalized learning digests, and a small e-commerce platform handling Black Friday traffic. In every case, the crash came without drama: no alert fired, no log line appeared, just a silent exit and stalled business logic.

The fix wasn’t upgrading PHP or rewriting in Go. It was understanding what PHP holds onto, when it decides to let go, and why your assumptions about unset() and gc_collect_cycles() are quietly wrong.

Let’s get concrete.

Why memory_limit Lies to You (and Why That’s Fine)

PHP’s memory limit is a ceiling—not a budget. It doesn’t warn you when you’re creeping up. It doesn’t throttle. It just kills the process the instant allocation fails. That means your script can use 249MB for 1,199 iterations, then fail on #1,200—not because iteration #1,200 is heavier, but because PHP’s internal bookkeeping finally hit a fragmentation wall or a reference cycle it couldn’t resolve in time.

You can see this live:

// Run this in CLI: php -d memory_limit=32M leak-demo.php  
for ($i = 0; $i < 2000; $i++) {
$data = str_repeat('x', 1024 * 32); // 32KB per iteration
echo "Iteration {$i}: " . round(memory_get_usage() / 1024 / 1024, 1) . "MB\n";
if ($i % 100 === 0) {
gc_collect_cycles();
}
}

You’ll see memory rise, plateau, dip slightly after gc_collect_cycles(), then rise again—until it doesn’t. At some point, PHP stops reclaiming. Not because GC is broken—but because nothing is triggering collection, or what’s holding memory isn’t visible to the cycle collector.

That’s the first lesson: Memory leaks in PHP CLI aren’t usually about forgetting to unset()—they’re about holding references where PHP’s GC can’t see them. And those references hide in plain sight: Doctrine identity maps, PDO statement handles, static caches, event listener closures, and even include() calls.

Let’s walk through the four real patterns I’ve debugged, fixed, and now check for before writing any long-running script.

1. The Loop Trap: Reused Objects That Never Let Go

What Happened

A mid-sized SaaS needed to renew subscriptions daily. Each renewal involved:

  • Loading a Customer entity
  • Checking payment method validity
  • Calculating prorated charges
  • Updating status in DB
  • Sending email via Mailgun SDK

The script used Doctrine ORM. It fetched all customer IDs first (SELECT id FROM customers WHERE status = 'active'), then looped:

$em = $this->getEntityManager();  
$ids = $this->getActiveCustomerIds();

foreach ($ids as $id) {
$customer = $em->find(Customer::class, $id);
$this->renewSubscription($customer);
}

It worked fine for ~800 customers. Then memory usage spiked—not linearly, but in steps. By iteration 1,100, it was using 192MB. At 1,199, it crashed.

Why It Failed

Doctrine’s EntityManager maintains an identity map: an internal array mapping object IDs to hydrated instances. This prevents duplicate objects for the same database row—and is essential for change tracking. But it also means every $em->find() call adds an entry to that map. And every entity holds a reference back to its EntityManager (via $this->_em). That creates a reference cycle:

EntityManagerUnitOfWork::identityMapCustomer_emEntityManager

PHP’s cycle collector can break this—but only when all references to the cycle are local to a scope that ends. In a foreach loop with a reused $em, the EntityManager lives for the entire duration. So the cycle persists across iterations. gc_collect_cycles() runs after the loop—not during it. By then, you’ve got 1,200 objects in memory, each holding onto their own copy of metadata, proxies, and connection state.

I messed this up the first time. I added unset($customer) and called gc_collect_cycles() at the end. Didn’t help. Then I dumped spl_object_hash($em) and saw it was identical on every iteration—proof the same instance was reused.

The Fix: Scope Isolation + Explicit Cleanup

The solution isn’t avoiding Doctrine—it’s respecting its lifetime contract. Doctrine expects EntityManager instances to be short-lived in CLI contexts. So we create a fresh one per iteration, and force cleanup inside the loop:

$ids = $this->getActiveCustomerIds();  

foreach ($ids as $id) {
// Fresh EM per iteration
$em = $this->getEntityManager();

// Load and process
$customer = $em->find(Customer::class, $id);
if (!$customer) {
continue;
}
$this->renewSubscription($customer);

// Critical: clear EM's identity map before unsetting
$em->clear();

// Now unset both
unset($customer, $em);
gc_collect_cycles(); // Run here, not after the loop
}

$em->clear() is the key. It empties the identity map and detaches all managed entities—breaking the cycle before you try to unset. Without it, unset($em) leaves dangling references in PHP’s internal structures.

Practical Tip: Verify It Worked

Add this before and after $em->clear() to confirm:

$uow = $em->getUnitOfWork();  
echo "Before clear: " . count($uow->getIdentityMap()) . " objects\n";
$em->clear();
echo "After clear: " . count($uow->getIdentityMap()) . " objects\n";

You should see it drop to zero every iteration. If it doesn’t, something else is holding a reference—like a service injected into your renewal logic that caches the EM statically.

Tradeoff

Creating a new EntityManager per iteration adds ~3–5ms overhead (mostly connection setup and metadata loading). For 12K customers, that’s ~60 seconds extra runtime—acceptable for a nightly job. If you need sub-second latency, use raw PDO or a lightweight data mapper. But don’t reuse Doctrine’s EM across thousands of iterations. It’s not designed for it.

2. PDO Prepared Statements: The Silent Reference Leak

What Happened

A freelance client’s invoice generator processed ~5,000 invoices nightly. Each invoice required inserting line items into three tables. They used one PDO connection and reused a prepared statement:

$pdo = new PDO($dsn, $user, $pass);  
$stmt = $pdo->prepare("INSERT INTO line_items (invoice_id, sku, qty, price) VALUES (?, ?, ?, ?)");

foreach ($invoices as $invoice) {
foreach ($invoice['items'] as $item) {
$stmt->execute([$invoice['id'], $item['sku'], $item['qty'], $item['price']]);
}
}

It ran fine for ~1,400 invoices—then memory spiked and crashed.

Why It Failed

PDO statements hold more than SQL text. Internally, they cache:

  • Bound parameter values
  • Column metadata
  • Internal buffers for large objects (BLOBs, CLOBs)
  • Statement handle references tied to the connection

PHP does not free these resources when you unset($stmt)—unless the statement handle is explicitly closed. The PDOStatement object may be destroyed, but the underlying C-level handle remains allocated until closeCursor() is called or the connection closes.

In a long loop, this means:

  • Each execute() call reuses the same handle but accumulates bound parameter state
  • Internal buffers grow to accommodate worst-case data sizes
  • Over 1,400 iterations, this leaked ~30–40KB per iteration

I confirmed this by running memory_get_usage(true) before and after closeCursor() in a test loop—each call reclaimed ~35KB. That’s 49MB total by iteration 1,400. Enough to blow past 256MB.

The Fix: Prepare → Execute → Close → Unset (Every Time)

Don’t reuse statements across iterations. Prepare fresh, execute, close, then unset:

$pdo = new PDO($dsn, $user, $pass);  
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

foreach ($invoices as $invoice) {
// Prepare fresh for this invoice
$stmt = $pdo->prepare("INSERT INTO line_items (invoice_id, sku, qty, price) VALUES (?, ?, ?, ?)");

foreach ($invoice['items'] as $item) {
$stmt->execute([$invoice['id'], $item['sku'], $item['qty'], $item['price']]);
}
// Critical: release internal resources
$stmt->closeCursor();
unset($stmt); // Now safe
}

If you’re inserting many rows for one invoice, batch them with INSERT ... VALUES (...), (...), (...) instead of looping execute(). But never reuse a PDOStatement across logical units of work (e.g., per-invoice).

Practical Tip: Measure the Gain

Add this to verify cleanup:

$before = memory_get_usage(true);  
$stmt->closeCursor();
$after = memory_get_usage(true);
echo "Freed: " . ($before - $after) . " bytes\n"; // Expect 30–40KB

Run it in isolation—you’ll see consistent reclaiming. Skip closeCursor(), and the delta stays near zero.

Tradeoff

Preparing a statement takes ~0.2ms. For 5,000 invoices with 5 items each, that’s ~5 seconds added. But it’s predictable, bounded, and eliminates a class of leaks that are impossible to debug without strace or Xdebug’s memory profiler. Worth it.

3. Static Caches That Outlive Their Purpose

What Happened

A startup’s API gateway validated JWTs for every request. To avoid repeated crypto ops, they cached results in a static array:

class JwtValidator {  
private static array $cache = [];

public function validate(string $token): array {
$hash = md5($token);
if (isset(self::$cache[$hash])) {
return self::$cache[$hash];
}
$result = $this->doActualValidation($token);
self::$cache[$hash] = $result;
return $result;
}
}

Worked perfectly in FPM—cache cleared on each request. But when they added a console command to pre-warm the cache for high-traffic endpoints, memory usage ballooned over 4 hours. The command processed 18K tokens. The static $cache array held all 18K entries—60MB of strings and arrays—until the process exited.

Worse: stale tokens stayed cached forever. __destruct() doesn’t run on static properties. There was no TTL. No eviction. Just growth.

Why It Failed

Static properties live for the entire process lifetime. In CLI mode, that’s the full runtime of your script. PHP won’t garbage-collect them unless all references to the class are gone—which they aren’t, because the class is autoloaded and used repeatedly.

The JwtValidator instance was recreated each time, but self::$cache was shared across all instances. And since the validator was pulled from a DI container that reused instances, the static cache became a black hole.

I found this by dumping count(JwtValidator::$cache) mid-loop. It grew monotonically. No surprise—the code had no eviction logic.

The Fix: Instance-Scope LRU Cache with Hard Bounds

Replace static storage with bounded, instance-scoped caching:

class JwtValidator {  
private array $cache = [];
private int $maxSize = 200;
private int $ttlSeconds = 300; // 5 minutes

public function validate(string $token): array {
$hash = md5($token);
$now = time();

// Check cache
if (isset($this->cache[$hash]) && ($now - $this->cache[$hash]['ts']) < $this->ttlSeconds) {
// Move to front for LRU
$value = $this->cache[$hash];
unset($this->cache[$hash]);
$this->cache[$hash] = $value;
return $value['data'];
}

// Compute & cache
$result = $this->doActualValidation($token);
$this->cache[$hash] = [
'data' => $result,
'ts' => $now,
];

// Enforce size bound immediately
if (count($this->cache) > $this->maxSize) {
array_shift($this->cache); // Remove oldest
}

return $result;
}
}

Key points:

  • No static. Cache lives on the instance.
  • TTL ensures stale entries expire.
  • Hard cap ($maxSize) prevents runaway growth.
  • array_shift() removes the oldest entry (FIFO), which works fine for LRU-ish behavior in CLI loops.

Practical Tip: Use APCu for Cross-Process Caching (If You Need It)

If you truly need cache sharing across CLI processes (e.g., multiple queue workers reading the same token), use APCu—with TTL:

$key = 'jwt_' . md5($token);  
if ($cached = apcu_fetch($key)) {
return $cached;
}
$result = $this->doActualValidation($token);
apcu_store($key, $result, 300); // 5-minute TTL
return $result;

APCu respects TTLs and doesn’t leak across process boundaries like statics do.

Tradeoff

Instance-scoped cache means no sharing between commands—but that’s usually correct. A cache warmed in a pre-flight command shouldn’t affect a payment retry worker. If you need coordination, use Redis or a database—not process memory.

4. Event Listeners That Stick Around (Even When You Think They’re Gone)

What Happened

A small e-commerce site added Slack alerts for failed orders. They used Symfony’s event dispatcher:

$dispatcher->addListener('order.failed', [$this, 'onOrderFailed']);

The handler looked like:

public function onOrderFailed(OrderFailedEvent $event): void {  
$this->slackClient->send("Order {$event->orderId} failed");
}

It worked. Then, during a load test simulating 2,000 failed orders in a single CLI command, memory spiked. Not gradually—abruptly, at ~1,800 events.

Why It Failed

[$this, 'onOrderFailed'] is a callable that captures $this. And $this was a service pulled from the container—which held references to the database connection, Twig environment, logger, and config. So every time the dispatcher stored that callable, it kept the entire service graph alive.

Even worse: calling $dispatcher->removeListener() after the loop didn’t help. The dispatcher had already copied the callable into its internal listener list—and that list held references to $this.

I confirmed this by dumping $this inside onOrderFailed() and seeing its memory footprint grow with each call. The object wasn’t being cloned—it was the same instance, but now referenced from two places: the DI container and the dispatcher’s listener array.

The Fix: Stateless Handlers + Direct Calls (When Possible)

First choice: skip the event system entirely in CLI loops:

foreach ($orders as $order) {  
if ($order->isFailed()) {
$this->sendSlackAlert($order); // Direct call, no listeners
}
}

No dispatcher. No closures. No hidden references.

If you must use events (e.g., for consistency with web requests), use anonymous functions with no captures:

$dispatcher = new EventDispatcher();  

// State-free closure
$alertListener = function (OrderFailedEvent $event) {
file_put_contents('/tmp/slack.log', "Failed: {$event->orderId}\n", FILE_APPEND);
};

$dispatcher->addListener('order.failed', $alertListener);

// Process orders
foreach ($orders as $order) {
if ($order->isFailed()) {
$dispatcher->dispatch(new OrderFailedEvent($order->id));
}
}

// Clean up before next iteration or exit
$dispatcher->removeListener('order.failed', $alertListener);

No $this. No service dependencies. Just what’s needed.

Practical Tip: Audit Your Listener Signatures

Run this before long loops:

$ref = new ReflectionObject($dispatcher);  
$prop = $ref->getProperty('listeners');
$prop->setAccessible(true);
$listeners = $prop->getValue($dispatcher);
echo "Listeners registered: " . count($listeners['order.failed'] ?? []) . "\n";

If it grows every iteration, you’re leaking.

Tradeoff

Direct calls sacrifice loose coupling—but in CLI scripts, coupling is often desirable. You control the flow. You know the context. Events add indirection and memory cost for no runtime benefit. Reserve them for web requests where decoupling matters for scalability.

Common Pitfalls (and Why They Waste Your Time)

1. Assuming gc_enable() Fixes Everything

A freelancer enabled gc_enable() at the top of their cron script, saw memory stabilize for the first 200 iterations, then watched it climb again. They assumed GC was “working”—but it wasn’t.

gc_enable() only toggles whether PHP attempts cycle collection. It doesn’t guarantee when, how often, or what it collects. The cycle collector ignores:

  • Objects referenced from $GLOBALS
  • Objects referenced from static properties
  • Objects referenced from the main execution scope (i.e., variables declared outside any function)

So this does nothing:

gc_enable();  
$data = [];
for ($i = 0; $i < 1000; $i++) {
$data[] = str_repeat('x', 1024);
// $data is in global scope → GC won’t touch it
}

gc_collect_cycles() will return 0 here—even though $data holds 1MB.

The Real Fix: Control Scope, Not GC

Move data into functions:

function processBatch(array $items): void {  
$data = [];
foreach ($items as $item) {
$data[] = str_repeat('x', 1024);
}
// $data freed when function exits
}

processBatch($items);
gc_collect_cycles(); // Now meaningful

GC works best when you help it by limiting scope. Don’t beg it to clean up global messes.

2. Forgetting That include/require Leaves Traces

A legacy reporting tool loaded config files inside a loop:

foreach ($reports as $report) {  
include __DIR__ . '/config/' . $report->type . '.php';
generateReport($report);
}

Each include parsed and compiled the PHP file anew. Even with opcache disabled, PHP retained internal AST nodes and symbol table entries tied to the including scope. Memory crept up ~50KB per include.

The Fix: Load Once, Reuse

$configs = [];  
foreach (['sales', 'marketing', 'finance'] as $type) {
$configs[$type] = require __DIR__ . '/config/' . $type . '.php';
}

foreach ($reports as $report) {
$config = $configs[$report->type] ?? [];
generateReport($report, $config);
}

require returns the value of the last expression—so config files should return [/ config /];. No parsing overhead. No memory leaks.

3. Using __destruct() to Clean Up Static State

Some devs add destructors to clear static caches:

class BadCache {  
private static array $cache = [];

public function __destruct() {
self::$cache = []; // Won't run in CLI
}
}

__destruct() is not guaranteed to run for static properties in CLI mode. PHP may exit before calling it—or skip it entirely for performance. Relying on it is dangerous.

The Fix: Explicit Reset

Provide a reset() method and call it:

class GoodCache {  
private static array $cache = [];

public static function reset(): void {
self::$cache = [];
}
}

// In your CLI script:
GoodCache::reset();
foreach ($items as $item) {
// ...
}

No magic. No assumptions. Just control.

4. Ignoring memory_get_peak_usage()

Developers check memory_get_usage() mid-loop—but peak usage tells the real story. You might be at 120MB now, but peaked at 240MB earlier and freed some. Or you might be steadily climbing toward 256MB with no dip.

Always log both:

echo "Current: " . round(memory_get_usage() / 1024 / 1024, 1) . "MB\n";  
echo "Peak: " . round(memory_get_peak_usage() / 1024 / 1024, 1) . "MB\n";

If peak grows every iteration, you have a leak. If current resets but peak climbs, you have fragmentation or unreleased handles.

Tools That Actually Help (Not Just Noise)

1. memory_get_usage(true) — Not false

memory_get_usage(false) returns estimated memory used by PHP’s internal heap. true returns real allocated memory (from malloc). For leak hunting, always use true.

$bytes = memory_get_usage(true); // Accurate  
$mb = round($bytes / 1024 / 1024, 1);

2. Xdebug’s xdebug_memory_usage() (If You Can Enable It)

Xdebug 3+ has xdebug_memory_usage() which gives allocation by file and line. Enable it in CLI:

php -dxdebug.mode=develop -dxdebug.cli_color=1 your-script.php  

Then in code:

xdebug_memory_usage(); // Returns usage since last call  

Call it before and after suspect blocks to isolate culprits.

3. strace for PDO-Level Confirmation

When in doubt, trace system calls:

strace -e trace=brk,mmap,munmap -o strace.log php your-script.php  

Look for brk() calls growing without munmap()—proof of unreleased memory.

4. php --ini and php -m — Verify Your Environment

Opcache, Xdebug, and other extensions change memory behavior. Run:

php --ini  # See loaded config  
php -m # See loaded modules

If opcache is enabled, disable it for debugging (opcache.enable_cli=0). Its optimization can mask leaks.

When to Walk Away From PHP (Seriously)

PHP is great for CLI tasks—but not all. Consider switching before you debug for 6 hours if:

  • You’re processing >100K records in one go
  • You need sub-100ms latency per item
  • You’re doing heavy numeric computation (matrix math, FFT, etc.)
  • You’re building a long-running daemon (not just cron)

I switched a real-time fraud scoring service from PHP to Rust. Why? Not because PHP was “slow”—but because managing memory across 50K concurrent checks, with zero tolerance for GC pauses, was harder than rewriting in a language where memory is explicit.

But for 90% of cron jobs? PHP is perfect. You just need to respect its rules.

Checklist Before Running Any Long-Running Script

Print this. Tape it to your monitor. Run it every time.

Scope EntityManager/DB connections: Fresh per iteration, or clear() + unset()

Close PDO statements: closeCursor() before unset()

No static caches in CLI: Use instance-scoped LRU or external cache (APCu/Redis)

No event listeners with $this: Prefer direct calls or stateless closures

No include/require in loops: Load configs once, reuse

Log memory_get_usage(true) and memory_get_peak_usage() every 100 iterations

Verify cleanup: count($em->getUnitOfWork()->getIdentityMap()) should be zero before next iteration

Test with memory_limit=64M locally: Catch leaks early

One Last Story: The Fix That Took 12 Minutes

A side project sent weekly learning digests to ~8K users. It fetched user preferences, rendered Twig templates, and queued emails. Crashed nightly at ~6,200 users.

I added logging:

if ($i % 100 === 0) {  
echo "User {$i}: " . round(memory_get_usage(true) / 1024 / 1024, 1) . "MB (peak: " . round(memory_get_peak_usage(true) / 1024 / 1024, 1) . "MB)\n";
}

Saw peak climb 0.3MB every 100 users. Dug deeper:

  • Twig\Environment was instantiated once, reused. ✅
  • But each template render created a Twig\Template instance that held $this->env → reference cycle. ❌
  • Twig’s loader cached compiled templates in memory. Also static. ❌

Fixed it in 12 minutes:

// Before loop  
$loader = new FilesystemLoader(__DIR__ . '/templates');
$twig = new Environment($loader, ['cache' => false]); // Disable cache

foreach ($users as $i => $user) {
// Fresh template instance per user
$template = $twig->load('digest.twig');
$html = $template->render(['user' => $user]);

// Send email...

// Explicitly unset
unset($template, $html);
if ($i % 100 === 0) {
gc_collect_cycles();
}
}

No framework changes. No composer updates. Just understanding what holds what—and breaking cycles before they accumulate.

That’s the core insight: PHP’s memory model isn’t broken. It’s honest. It holds onto everything you tell it to hold—and nothing more. Your job is to tell it only what you need.

Start there. The rest follows.