Skip to content

Fix: PHP Fatal error: Allowed memory size of X bytes exhausted

FixDevs · (Updated: )

Part of:  PHP, Ruby & Other Languages

Quick Answer

How to fix PHP Fatal error Allowed memory size exhausted caused by memory limits, large datasets, memory leaks, recursive functions, and inefficient queries.

The Error

Your PHP application crashes with:

Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 65536 bytes)
in /var/www/html/app/process.php on line 42

Or variations:

PHP Fatal error: Out of memory (allocated 268435456) (tried to allocate 4096 bytes)
Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes)
in /vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php on line 588
Composer: proc_open(): fork failed - Cannot allocate memory

PHP tried to use more memory than the configured limit allows. The default limit is typically 128MB (134217728 bytes). Your script needs more memory than is available.

Why This Happens

PHP has a per-script memory limit (memory_limit in php.ini) that prevents a single script from consuming all server memory. When a script exceeds this limit, PHP kills it with a fatal error. The limit applies per-request in FPM and per-invocation in CLI, so a leak in one request does not directly impact another — but the FPM worker that handled it may exit and need replacement, which has its own cost.

The default memory_limit is 128 MB in PHP 7.x and 8.x. That value made sense in the WordPress-era web, when a typical page rendered a few KB of content and made a handful of database queries. Modern PHP applications — Laravel admin panels with large Eloquent collections, Symfony API responses serialized from rich entity graphs, image pipelines, CSV importers — routinely exceed it. The default is a safety net for shared hosting, not a target for production workloads.

A useful mental model: PHP scripts allocate memory in three places. First, the PHP runtime itself (opcode caches, autoloader state, framework bootstrapping). Second, your application objects (Eloquent models, Doctrine entities, parsed JSON). Third, extension memory (GD image buffers, intl collation tables, OPcache strings). The memory_limit covers the second and third categories per request. When the limit is hit, the allocation that pushed you over the edge is named in the error — but the actual culprit is usually whatever loaded too much data before that point.

Common causes:

  • Loading too much data at once. Fetching millions of database rows into memory.
  • Large file processing. Reading an entire large file into a string.
  • Memory leaks. Objects accumulate in loops and are never freed.
  • Recursive functions. Deep recursion with large data at each level.
  • Image processing. GD or Imagick operations on large images.
  • Composer installation. Composer itself needs memory for dependency resolution.
  • Low memory limit. The default 128MB is too low for some operations.
  • Eager-loaded relations. with() on an Eloquent query that pulls in millions of related rows.

In Production: Incident Lens

In production, this error is almost always traffic-driven. A code path that runs fine on a small payload (single user, one CSV row, a 200 KB image) starves the worker when a real user uploads a 50 MB CSV or a 12-megapixel photo. The error is local to one request, but the secondary effects on PHP-FPM are what turn it into an incident.

How it surfaces: the first signal is an error spike in your monitoring — Sentry or your log aggregator shows multiple Allowed memory size of X bytes exhausted events within a minute. The next signal is FPM saturation: when many requests simultaneously hit a memory-heavy path, every active worker is occupied, and the rest of your traffic queues. Users see slow responses or 502/504 errors from the reverse proxy because Nginx ran out of available FPM upstream workers. The PHP error log fills with fatal-error lines, but the user-visible symptom is “the whole site is slow” — not “one endpoint failed.”

Blast radius: scoped per FPM pool. If you have a single pool serving everything, a memory-heavy request path can degrade the entire application. If you separated pools by concern (one pool for the web UI, one for the upload endpoint, one for cron), the blast is contained to the affected pool. The math matters: with pm.max_children = 16 and one heavy request taking 200 MB and 30 seconds, you have only 15 slots left for everything else. Five concurrent heavy uploads and the rest of the site stops responding.

Monitoring signal: the layered alerts are pool-level active workers, FPM listen queue depth, and per-request peak memory. Expose php-fpm status via pm.status_path = /status and scrape it with Prometheus or your APM. Alert when active workers exceed 80% of pm.max_children for more than 60 seconds — that is your saturation alarm and it triggers before users notice. Also alert on a sustained slow_log rate above baseline, since memory-heavy requests are often slow before they fail. Sentry’s memory_peak_usage() instrumentation lets you alert on the actual culprit endpoint rather than just the symptom.

Recovery sequence: the immediate move is to restart the affected FPM pool — systemctl restart php8.3-fpm releases stuck workers and clears the queue. If you can identify the offending request from the access log, block its source at the WAF or rate-limit the endpoint while you patch. Bump memory_limit for the specific pool (not globally) as a holding action, then deploy the real fix: streaming, chunking, or moving the heavy work to a queue. Never set memory_limit = -1 in production; you trade a fatal error for an OOM kill that takes the whole machine down.

Postmortem preventive: the durable fixes are operational, not just code. Split FPM pools by workload — a separate pool for uploads, exports, image processing, with its own memory_limit and pm.max_children. Move every “heavy” code path to a job queue (Laravel Horizon, Symfony Messenger) so the web tier returns quickly and the worker pool can be sized for memory rather than concurrency. Add pm.max_requests = 500 so workers are recycled before slow leaks add up. Stream files (fopen + fgets, generators, SplFileObject) rather than reading them whole; chunk database iteration (chunk(), cursor()); avoid with() on unbounded relations. Set up CI tests that import a representative-large fixture file and assert peak memory stays below a threshold.

Fix 1: Increase the Memory Limit

The quickest fix. Increase memory_limit in PHP configuration:

In php.ini:

; Find your php.ini:
; php -i | grep php.ini
; Common locations: /etc/php/8.3/fpm/php.ini, /etc/php/8.3/cli/php.ini

memory_limit = 256M
; or
memory_limit = 512M
; or for unlimited (not recommended in production):
memory_limit = -1

Restart PHP-FPM after changing:

sudo systemctl restart php8.3-fpm
# or
sudo systemctl restart php-fpm

In a specific script (runtime override):

ini_set('memory_limit', '512M');

In .htaccess (Apache):

php_value memory_limit 256M

In docker-compose.yml:

services:
  php:
    image: php:8.3-fpm
    environment:
      PHP_MEMORY_LIMIT: 256M

For Composer specifically:

COMPOSER_MEMORY_LIMIT=-1 composer install
# or
php -d memory_limit=-1 /usr/local/bin/composer install

Pro Tip: Increase the limit enough to solve the immediate problem, but not to -1 (unlimited) in production. Unlimited memory allows a single buggy script to crash the entire server. Set it to the maximum your scripts actually need (check with memory_get_peak_usage()).

Fix 2: Process Data in Chunks

Loading all data at once is the most common cause. Process in batches:

Broken — loading all rows:

// Loads ALL users into memory at once
$users = User::all();  // 1 million rows = crash!

foreach ($users as $user) {
    processUser($user);
}

Fixed — use chunking:

// Laravel: Process 1000 rows at a time
User::chunk(1000, function ($users) {
    foreach ($users as $user) {
        processUser($user);
    }
});

// Laravel: Lazy collection (even more memory-efficient)
User::lazy()->each(function ($user) {
    processUser($user);
});

Raw PDO with cursor:

$stmt = $pdo->prepare("SELECT * FROM users");
$stmt->execute();

while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    processUser($row);
    // Only one row in memory at a time
}

MySQLi unbuffered query:

$mysqli->real_query("SELECT * FROM large_table");
$result = $mysqli->use_result();  // Unbuffered — rows fetched one at a time

while ($row = $result->fetch_assoc()) {
    processRow($row);
}
$result->free();

Common Mistake: Using ->get() or ::all() on large tables. These methods load the entire result set into memory. Use chunk(), lazy(), or cursor() for large datasets.

Fix 3: Fix File Processing

Reading large files entirely into memory:

Broken:

// Loads the entire file into memory
$content = file_get_contents('/path/to/huge-file.csv');  // 2GB file = crash!
$lines = explode("\n", $content);

Fixed — read line by line:

$handle = fopen('/path/to/huge-file.csv', 'r');
if ($handle) {
    while (($line = fgets($handle)) !== false) {
        processLine($line);
    }
    fclose($handle);
}

Fixed — use SplFileObject:

$file = new SplFileObject('/path/to/huge-file.csv');
$file->setFlags(SplFileObject::READ_CSV);

foreach ($file as $row) {
    if ($row[0] !== null) {  // Skip empty lines
        processRow($row);
    }
}

Fixed — use generators:

function readCsv(string $path): Generator {
    $handle = fopen($path, 'r');
    while (($row = fgetcsv($handle)) !== false) {
        yield $row;
    }
    fclose($handle);
}

foreach (readCsv('/path/to/huge-file.csv') as $row) {
    processRow($row);
    // Only one row in memory at a time
}

For JSON files:

// Wrong — loads entire JSON into memory
$data = json_decode(file_get_contents('huge.json'), true);

// Fixed — use a streaming JSON parser
// composer require halaxa/json-machine
use JsonMachine\Items;

$items = Items::fromFile('huge.json');
foreach ($items as $item) {
    processItem($item);
}

Fix 4: Fix Memory Leaks in Loops

Variables accumulating in loops:

Broken:

$results = [];
foreach ($largeDataSet as $item) {
    $processed = heavyProcessing($item);
    $results[] = $processed;  // Array grows until memory runs out
    log($processed);  // If you only need to log, don't store
}

Fixed — process and discard:

foreach ($largeDataSet as $item) {
    $processed = heavyProcessing($item);
    log($processed);
    // Don't accumulate results if you don't need them all
}

Fixed — write to file or database instead of storing in memory:

$outputFile = fopen('results.csv', 'w');
foreach ($largeDataSet as $item) {
    $processed = heavyProcessing($item);
    fputcsv($outputFile, $processed);
}
fclose($outputFile);

Unset large variables when done:

$bigData = loadData();
processData($bigData);
unset($bigData);  // Free the memory immediately
gc_collect_cycles();  // Force garbage collection

Laravel Eloquent in loops — disable event listeners and relations:

// Prevent Eloquent from accumulating query log
DB::disableQueryLog();

// Process in chunks
User::chunk(500, function ($users) {
    foreach ($users as $user) {
        $user->process();
    }
});

Fix 5: Fix Image Processing

Image operations can use massive amounts of memory:

// A 5000x5000 pixel image at 32-bit color uses ~100MB of raw memory
$image = imagecreatefromjpeg('large-photo.jpg');

Estimate memory needed:

function estimateImageMemory(string $path): int {
    [$width, $height] = getimagesize($path);
    // 4 bytes per pixel (RGBA) + overhead
    return $width * $height * 4 * 1.5;  // 1.5x safety factor
}

$needed = estimateImageMemory('photo.jpg');
$available = intval(ini_get('memory_limit')) * 1024 * 1024;

if ($needed > $available * 0.8) {
    ini_set('memory_limit', ceil($needed / 1024 / 1024 * 2) . 'M');
}

Use ImageMagick CLI instead of GD for large images:

// Process with ImageMagick command line — doesn't use PHP memory
exec('convert large-photo.jpg -resize 800x600 thumbnail.jpg');

Resize before processing:

// Process in tiles or reduce resolution first
$image = imagecreatefromjpeg('large-photo.jpg');
$thumb = imagescale($image, 800, 600);
imagedestroy($image);  // Free the original immediately
// Work with the smaller $thumb

Fix 6: Monitor Memory Usage

Track memory usage to find the problem:

echo "Memory: " . memory_get_usage(true) / 1024 / 1024 . " MB\n";
echo "Peak: " . memory_get_peak_usage(true) / 1024 / 1024 . " MB\n";

Profile memory usage in sections:

function memoryCheckpoint(string $label): void {
    static $last = 0;
    $current = memory_get_usage(true);
    $diff = $current - $last;
    echo sprintf("[%s] Memory: %.2f MB (Δ %.2f MB)\n",
        $label,
        $current / 1024 / 1024,
        $diff / 1024 / 1024
    );
    $last = $current;
}

memoryCheckpoint("Start");
$data = loadData();
memoryCheckpoint("After loadData");
processData($data);
memoryCheckpoint("After processData");

Use Xdebug profiler for detailed analysis:

; php.ini
xdebug.mode=profile
xdebug.output_dir=/tmp/xdebug

Fix 7: Fix Recursive Functions

Deep recursion with large data structures:

Broken:

function processTree(array $node): array {
    $result = processNode($node);
    foreach ($node['children'] ?? [] as $child) {
        $result['children'][] = processTree($child);  // Recursive, accumulates memory
    }
    return $result;
}

Fixed — use iterative approach:

function processTree(array $root): array {
    $stack = [&$root];
    while (!empty($stack)) {
        $node = &$stack[count($stack) - 1];
        array_pop($stack);
        processNode($node);
        foreach ($node['children'] ?? [] as &$child) {
            $stack[] = &$child;
        }
    }
    return $root;
}

Fix 8: Optimize PHP-FPM Settings

PHP-FPM worker processes each consume memory:

; /etc/php/8.3/fpm/pool.d/www.conf

; Calculate: available_memory / memory_per_process = max_children
; Example: 4GB server, 256MB per process → max 16 children
pm = dynamic
pm.max_children = 16
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8

; Restart workers after N requests to prevent memory leaks
pm.max_requests = 500

Check per-worker memory usage:

ps aux --sort -rss | grep php-fpm | head -10

Still Not Working?

Check for OPcache settings. OPcache uses shared memory separately from memory_limit:

opcache.memory_consumption=128
opcache.interned_strings_buffer=16

Check for session storage issues. Large session data stored in memory can accumulate.

Check for third-party library leaks. Some libraries accumulate internal state. Check their documentation for cleanup methods.

Consider using a queue for heavy processing:

// Instead of processing in a web request:
dispatch(new ProcessLargeDataJob($dataId));

// The job runs in a separate process with its own memory limit
// php artisan queue:work --memory=512

Check for realpath_cache_size exhaustion. When you load thousands of files (a large Composer autoloader, many includes), the realpath cache fills and PHP starts re-resolving paths repeatedly. This is not part of memory_limit but it correlates with memory pressure. Bump realpath_cache_size = 4096K and realpath_cache_ttl = 600 in php.ini.

Check for serialized session data growth. If you store large objects in $_SESSION and the session handler is file-based, each request reads the entire serialized blob into memory. Move large session payloads to a dedicated store (Redis, database) and keep only a session ID in the cookie.

Check for Twig or Blade view caching when iterating. Rendering a template per item in a loop instantiates the template engine per call. Cache the compiled template outside the loop and render with fresh variables only.

Check for Doctrine entity manager retention. Doctrine’s identity map holds every entity loaded in the current request. In a long-running batch, call EntityManager::clear() periodically (every 100-500 entities) to release them.

For Composer memory issues specifically, see Fix: PHP Composer memory limit. For similar memory patterns in JavaScript, see Fix: JavaScript heap out of memory. For container-level OOM events that look like memory errors, see Fix: Docker container exited 137 (OOMKilled). For queue worker memory tuning, see Fix: Laravel queue job not processing.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles