Fix Common Errors

Debug PHP "Memory Size Exhausted" in Production

Debug 'allowed memory size exhausted' PHP production errors with live profiling, leak detection, and streaming strategies for large data.

The Allowed memory size exhausted error hits production with no warning. Your PHP application suddenly fails to process requests, leaving you staring at a fatal error with no context about which operation ate all available memory. Unlike test environments where you can add print statements and restart, debugging an allowed memory size exhausted error in production requires a different approach: live profiling, traffic reconstruction, and systematic elimination of memory leaks. This post walks through production-specific tools and patterns to find the leak and fix it before it returns.

Memory exhaustion usually surfaces during peak traffic or when a background job processes larger-than-expected data. A single request might leak a few kilobytes — harmless in isolation — but when multiplied across thousands of daily requests, those leaks cascade into fatal errors. The challenge is that PHP's memory limit exists for a reason: it prevents runaway processes from consuming server resources. Your job is to find the code responsible before the entire application becomes unreliable.

Understanding PHP memory exhaustion

PHP sets a memory_limit (default 128 MB, often raised to 256 MB or 512 MB in production) to cap the heap size of each script execution. When a request allocates objects, strings, or arrays beyond that limit, PHP throws a fatal error and terminates. Unlike segmentation faults in compiled languages, this is PHP's safety mechanism — but it tells you almost nothing about what consumed the memory.

Common culprits:

  • Unbounded loops: Processing large result sets without pagination or chunking.
  • Recursive data structures: Building trees or graphs that hold too many nodes in memory simultaneously.
  • String concatenation in loops: Each concatenation creates a new string object; 10,000 concatenations balloon quickly.
  • Circular references: Objects that reference each other, blocking garbage collection in older PHP versions.
  • Caching without expiry: Storing computed results indefinitely in memory.

The memory_get_peak_usage() function tells you the peak memory used in a request, but by the time it crashes, that peak already exceeded the limit. Production profiling requires different tactics.

Profiling memory in production with Blackfire

Blackfire is the gold standard for PHP profiling in production. Unlike Xdebug (which has significant overhead and is typically disabled in production), Blackfire uses a lightweight agent that samples your code without noticeably degrading performance. You can profile a single request against production traffic.

Before profiling, ensure you have Blackfire's probe installed on your server. Most managed hosts (Platform.sh, Heroku, AWS) offer one-click enablement. Self-managed servers require installing the agent via package manager.

To profile a specific request:

curl --header "X-Blackfire-Query: profile" \
  https://your-app.com/api/endpoint

This triggers Blackfire to capture a profile of that request. You then view it in the Blackfire web UI, which shows:

  • Call graph: Which functions consumed how much time and memory.
  • Memory allocations: Lines of code responsible for allocating the most memory.
  • Call count: Which functions were called thousands of times unexpectedly.

A memory-hungry request often reveals itself immediately: a loop calling a query-builder method 50,000 times, each adding a few kilobytes to the query object. Blackfire shows you the exact line.

If Blackfire isn't available, Xdebug can profile in production if you enable it selectively:

; php.ini (production)
xdebug.mode = profile
xdebug.output_dir = /tmp/xdebug
xdebug.trigger_value = your-secret-token

Then trigger profiling with a query string:

curl "https://your-app.com/api/endpoint?XDEBUG_PROFILE=your-secret-token"

Xdebug writes a cachegrind file to /tmp/xdebug/, which you can analyze with KCacheGrind (Linux/Mac) or WinCacheGrind (Windows). The UI shows memory allocations per function, though with higher overhead than Blackfire.

Identifying traffic-driven memory leaks

Some requests are harmless in isolation but become toxic under load. A background job that processes 1,000 records in development might face 100,000 records in production, triggering exhaustion. Here's how to identify them:

1. Log peak memory per request

Add this early in your application bootstrap:

<?php
register_shutdown_function(function () {
    $peak = memory_get_peak_usage(true) / 1024 / 1024;
    error_log("Peak memory: {$peak}MB");
});

This logs the peak memory for every request. Parse your logs to find outliers — requests using 200+ MB are red flags.

2. Instrument Doctrine/PDO queries

If you're using an ORM, queries that load hundreds of thousands of entities into memory are common culprits:

<?php
// Bad: loads ALL users into memory at once
$users = $userRepository->findAll();
foreach ($users as $user) {
    // process user
}

// Good: iterate using a batch size
$batch = 500;
$offset = 0;
while (true) {
    $users = $userRepository->findBy([], null, $batch, $offset);
    if (empty($users)) break;
    foreach ($users as $user) {
        // process user
    }
    $offset += $batch;
}

Most ORMs offer iterators designed for large datasets. Doctrine's iterate() method fetches rows on-demand rather than buffering them:

<?php
foreach ($userRepository->createQueryBuilder('u')
    ->getQuery()
    ->iterate() as [$user]) {
    // process user; $user is unloaded after each iteration
}

3. Check for unserialize of untrusted data

Unserializing large objects from user input is a vector for memory bombs:

<?php
// Dangerous: attacker sends a deeply nested array
$data = unserialize($_POST['data']);

Replace unserialize() with json_decode() and validate the structure, or use JSON schema validation:

<?php
$data = json_decode($_POST['data'], true);
if (!isset($data['key'])) {
    throw new InvalidArgumentException('Missing key');
}

Streaming and chunking large data

When you must process large datasets, stream them rather than buffering:

Streaming CSV or JSON lines

Instead of loading a multi-GB CSV into memory:

<?php
$file = fopen('large-file.csv', 'r');
$headers = fgetcsv($file);
while (($row = fgetcsv($file)) !== false) {
    $record = array_combine($headers, $row);
    processRecord($record);
    unset($record); // explicitly release
}
fclose($file);

This keeps only one row in memory at a time.

Streaming API responses

When fetching data from an upstream service, use streaming if it's available:

<?php
$client = new \GuzzleHttp\Client();
$response = $client->get('https://api.example.com/data');
foreach ($response->getBody() as $chunk) {
    processChunk($chunk);
}

Chunked database exports

For exporting large tables to CSV, chunk the query:

<?php
$chunkSize = 10000;
$fp = fopen('output.csv', 'w');
fputcsv($fp, ['id', 'name', 'email']);

$query = $connection->createQueryBuilder()
    ->select('id', 'name', 'email')
    ->from('users');

for ($i = 0; ; $i++) {
    $rows = (clone $query)
        ->setMaxResults($chunkSize)
        ->setFirstResult($i * $chunkSize)
        ->fetchAllAssociative();
    
    if (empty($rows)) break;
    
    foreach ($rows as $row) {
        fputcsv($fp, $row);
    }
}
fclose($fp);

Each iteration holds at most $chunkSize rows in memory.

Monitoring with error tracking

Memory exhaustion errors should never surprise you. Use error tracking to capture memory errors as they happen, so you see which endpoint, which traffic pattern, or which user behavior triggered them:

<?php
Sentry\init([
    'dsn' => 'https://<key>@light-trace.robomiri.com/1',
    'error_types' => E_ALL,
]);

register_shutdown_function(function () {
    $lastError = error_get_last();
    if ($lastError && str_contains($lastError['message'], 'Allowed memory size')) {
        // Capture context before shutdown
        Sentry\captureMessage(
            "Memory exhausted: " . $lastError['message'],
            'fatal'
        );
    }
});

Each memory error report includes the request URL, query parameters, and headers. Patterns emerge: a specific search endpoint fails under load, or a background job fails after processing 50k records. Once you see the pattern in your error tracker, read the stack trace to pinpoint the function.

Memory errors often show incomplete stack traces because PHP terminates before flushing all error details. If your error tracker shows a shallow trace, cross-reference it with Blackfire profiles from the same request pattern to see the full call graph.

Fixing common patterns

String building: Use implode() instead of concatenation in loops:

<?php
// Bad: ~10 string copies per iteration
$output = '';
foreach ($data as $row) {
    $output .= formatRow($row);
}

// Good: single allocation
$output = implode('', array_map('formatRow', $data));

Array mutations: Be cautious with array_merge in loops:

<?php
// Bad: creates new array on each iteration
$merged = [];
foreach ($batches as $batch) {
    $merged = array_merge($merged, $batch);
}

// Good: collect then merge once
$merged = array_merge(...$batches);

Circular references (PHP 7.4+): PHP's garbage collector handles most cases, but explicit unset helps:

<?php
$node = new Node();
$node->parent = $parent;
$parent->children[] = $node;
// Later, when done with structure:
$node->parent = null;
unset($node);

Cache expiry: If caching computed results in memory, set a TTL:

<?php
class MemoryCache {
    private $cache = [];
    private $expires = [];
    
    public function set($key, $value, $ttl = 3600) {
        $this->cache[$key] = $value;
        $this->expires[$key] = time() + $ttl;
    }
    
    public function get($key) {
        if (!isset($this->cache[$key])) return null;
        if (time() > $this->expires[$key]) {
            unset($this->cache[$key]);
            return null;
        }
        return $this->cache[$key];
    }
}

Testing under realistic load

Memory leaks hide in production because test datasets are small. Reproduce production-like load locally:

  • Use production database backups (anonymized) in staging.
  • Generate realistic CSV/JSON files at production sizes.
  • Load-test with a tool like Apache Bench or Locust, monitoring memory growth across requests.

If memory grows request-to-request without dropping back to a baseline, you have a leak:

# Monitor PHP process memory in real-time
watch -n 1 'ps aux | grep php | grep -v grep'

Healthy requests should show stable RSS (resident set size) across runs. Growing RSS indicates leaks.

LightTrace captures memory spikes and fatal errors as they occur, letting you see the exact request pattern that triggered exhaustion. Correlating that with Blackfire profiles of the same endpoint gives you the full picture: what the code did, and when it failed under real traffic.

Memory exhaustion is solvable. Profile production traffic with Blackfire or Xdebug, log peak memory per request, stream large datasets instead of buffering, and monitor errors as they happen. Once you've fixed the leak, you'll see memory stabilize and requests process reliably.

Start tracking errors in minutes

Start free with LightTrace to track memory errors in production and correlate them with request patterns that trigger them. Set up alert rules so the next memory exhaustion error reaches you immediately, not your users.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.