Home › PHP › PHP Allowed Memory Exhausted: Raise It and Fix It
Intermediate 5 min · September 23, 2026

PHP Allowed Memory Exhausted: Raise It and Fix It

Raise memory_limit via php.ini or -d, then fix the real cause: chunk big queries, disable xdebug in prod, free results.

N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.

Follow
✓ Production
production tested
September 23, 2026
last updated
1,942
articles · all by Naren
Before you start⏱ 13 min
  • ✓PHP and Laravel basics
  • ✓Running artisan commands
  • ✓Reading php -i output
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • Allowed memory size exhausted means the script hit memory_limit — raising it buys room but rarely fixes the cause
  • Check the real limit first with php -i | grep memory_limit — CLI and FPM often use different php.ini files
  • Chunk unbounded queries with chunk() or cursors instead of loading a million rows into one collection
  • Circular references and buffered query results hold memory; call $result->free() and unset big variables explicitly
  • Turn xdebug off in production and use COMPOSER_MEMORY_LIMIT=-1 only for composer itself, never as an app fix
✦ Definition~90s read
What is PHP Memory Exhausted Fix?

PHP manages memory per request with a simple contract: every allocation counts against memory_limit, refcounting frees acyclic garbage instantly, and a cyclic collector periodically reclaims reference cycles. There is no generational compactor or background sweeper tuning your heap while you sleep — what you hold stays held until you release it or the request ends.

★
Think of a backpack with a strict weight limit at the airport.

This makes PHP memory behavior unusually legible: growth graphs map directly to code holding data, not collector moods.

The weight multipliers surprise newcomers. Framework hydration turns lean rows into rich objects at 4-5x bytes. Database drivers buffer full results before loops start. Debuggers triple footprints. Queue workers stretch the per-request contract across hundreds of jobs, so anything appended per job accumulates indefinitely.

Each multiplier is harmless at 50 rows and fatal at 2M — which is why volume testing, not unit testing, catches memory bugs.

The discipline is correspondingly direct: window unbounded inputs, stream outputs, free cursors promptly, collect cycles on schedule, strip debuggers from production, and recycle long-lived workers. Memory limits stay finite as regression alarms rather than ceilings to raise.

PHP rewards this explicitness with flat, predictable peaks — the same export costs 90MB at 2M rows and 5M rows alike, and the fatal error becomes something you read about instead of something that pages you.

Plain-English First

Think of a backpack with a strict weight limit at the airport. Your bag weighs too much, so you could pay for a bigger allowance — or you could stop packing bricks. PHP's memory_limit is that allowance: when your script exceeds it, PHP kills the run with Allowed memory size exhausted. Raising the limit is paying for more kilos. The smarter move is usually unpacking: process data in small batches instead of loading everything at once.

Allowed memory size of X bytes exhausted is PHP telling you a single request or script crossed its memory_limit. Every Laravel developer meets it during a big import, a report over millions of rows, or a queue worker that slowly balloons. The number in the message (usually 134217728 for 128M) tells you the cap, and the allocation size tells you the final straw — not the culprit. The culprit is whatever accumulated before it.

The trap is treating the limit as the bug. Bumping memory_limit from 128M to 2G silences the error while the import still loads every row into memory — it just dies later, slower, and with more collateral. Real fixes shrink peak usage: chunked queries, generators, unbuffered cursors, and freeing results you no longer need. The limit then becomes a safety net that catches genuine runaways instead of a ceiling you keep raising.

This guide covers both sides honestly: how to inspect and raise the limit correctly (php.ini versus -d versus ini_set, CLI versus FPM), then how to make the limit irrelevant with chunked Eloquent, cursor pagination, and xdebug-free production. You'll also learn the composer memory trick for what it is — a tooling workaround, not an application fix.

What the Error Actually Tells You

PHP kills the script the moment total allocation crosses memory_limit, and the message reports two numbers: the cap (Allowed memory size of 536870912 bytes) and the last request (tried to allocate 20480 bytes). Beginners chase the second number — 20KB looks innocent — but it's only the straw that broke the camel's back. The 512MB before it is the actual story: something accumulated relentlessly until any tiny allocation became fatal.

The cap comes from layered configuration. php.ini sets the default, the -d flag overrides per invocation, and ini_set changes it at runtime (unless the host disables it). CLI and FPM read different ini files on most distros — /etc/php/8.3/cli versus /etc/php/8.3/fpm — so artisan commands and web requests can run under different limits on the same box. Always verify with the runtime that fails, not the one on your laptop.

Treat the limit as a fire alarm, not a thermostat. A healthy request uses a few dozen MB; a healthy import streams under a few hundred. When the alarm rings, measure peak usage per code block with memory_get_peak_usage(true) and find what grows with input size. Raising the limit before measuring is like disabling the alarm while the kitchen smokes — quieter, briefly, and much worse later.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
# Which limit applies, and which ini sets it?
php -i | grep -iE 'memory_limit|Loaded Configuration File'
# memory_limit => 128M => 128M
# Loaded Configuration File => /etc/php/8.3/cli/php.ini

# Override per run (no ini edit needed)
php -d memory_limit=512M artisan export:customers

# Peak usage probe around suspect code (add temporarily)
# $m0 = memory_get_usage(true);
# ... suspect block ...
# error_log('delta: '.((memory_get_usage(true) - $m0) / 1048576).' MB');
📊 Production Insight
Night 3's bump from 512M to 2G moved the crash from 78% to 91% — the same O(n) accumulation, just 20 minutes later and with a bigger blast radius.
🎯 Key Takeaway
Read the cap not the straw, verify the limit in the failing runtime, and measure peak per block before touching any setting.

Raising memory_limit the Right Way

Sometimes the limit genuinely is wrong — a 128M default from 2015 serving a 2026 image pipeline. Raise it deliberately: set memory_limit = 512M in the correct php.ini (FPM's for web, CLI's for artisan), restart FPM, and confirm with a phpinfo page or php -i. Prefer scoped changes over global ones — a .user.ini or pool-specific php_admin_value for the heavy endpoint beats doubling every request's allowance.

Know the hierarchy so changes stick. php_admin_value in the FPM pool config overrides php.ini and blocks ini_set — great for enforcing production ceilings. The -d flag beats the ini file for one-off artisan runs. ini_set('memory_limit', '512M') works only when the host allows it and only for the current process; shared hosts often forbid it, and relying on it hides requirements from deploy docs.

Set -1 (unlimited) almost never. It converts a catchable fatal into a box-level OOM kill that takes down neighbors — the kernel picks the biggest process, which might be your database. Keep a finite cap everywhere so runaways die loudly and alone. The composer exception proves the rule: COMPOSER_MEMORY_LIMIT=-1 applies to the dependency resolver on a build box, never to customer-facing code.

php.iniINI
1
2
3
4
5
6
7
8
; /etc/php/8.3/fpm/php.ini — scoped, finite, documented
memory_limit = 512M
; Pool override for the heavy endpoint only:
; php_admin_value[memory_limit] = 512M
; One-off artisan run without editing files:
; php -d memory_limit=512M artisan export:customers
; Runtime (only where hosts permit):
; ini_set('memory_limit', '512M');
⚠ Never set unlimited in production
memory_limit = -1 turns a contained PHP fatal into a kernel OOM kill that can murder neighboring processes. Keep finite caps so runaways die loudly and alone instead of taking the database down with them.
📊 Production Insight
The export's limit stayed at 512M after the fix — as a runaway guard, not a target. Peak now sits under 90MB, so the alarm has 400MB of headroom to catch the next regression.
🎯 Key Takeaway
Raise deliberately in the right ini with finite values, scope heavy endpoints separately, and keep -1 strictly for composer on build boxes.

Chunk Unbounded Queries: ::all() Is the Enemy

Eloquent hydration multiplies row weight 4-5x — casts, dates, relations, event state — so 2M modest rows become 3.6GB of models. ::all() holds every one until the loop ends, which is why memory graphs climb linearly to the cap and die near completion. The fix keeps only one window in memory: chunkById(2000) fetches 2,000 rows, processes them, releases them, and repeats. Peak flattens to one chunk regardless of table size.

Prefer chunkById over chunk for tables with concurrent writes — id-ordered paging can't skip or repeat rows when inserts land mid-export, while offset paging drifts. For read-and-stream shapes, lazy() cursors yield models one at a time with minimal buffering, ideal when each row writes straight to output. Either way, flush per window: write the CSV lines, unset the chunk, and let the collector reclaim before the next fetch.

Watch the per-chunk traps. Eager-load only relations you actually use — with('items') on 2,000 rows you don't need is a chunk-sized leak of its own. Avoid collecting chunks into a result array for later; stream to disk, queue, or response as you go. The pattern generalizes beyond Eloquent: any unbounded source (API pagination, file lines, queue depths) gets windowed processing with explicit release per window.

ExportCustomers.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
use App\Models\Customer;
$out = fopen($path, 'w');
Customer::chunkById(2000, function ($customers) use ($out) {
    foreach ($customers as $c) {
        fputcsv($out, [$c->id, $c->email, $c->total]);
    }
    unset($customers);
    if (function_exists('gc_collect_cycles')) { gc_collect_cycles(); }
});
fclose($out);
// Streaming shape alternative:
// foreach (Customer::lazy(2000) as $c) { fputcsv($out, [$c->id, $c->email]); }
📊 Production Insight
One-line change in spirit — ::all() to ::chunkById(2000) with streaming writes — cut peak from 3.6GB to 90MB and turned 7 nightly failures into 31-minute green runs.
🎯 Key Takeaway
Window every unbounded source with chunkById or lazy cursors, flush per window, and stream output instead of accumulating it.

Circular References and Buffered Results

PHP's refcounting frees most garbage instantly, but cycles (A points to B points to A) need the cyclic collector, which runs only periodically. Long loops building parent-child graphs — category trees, ORM graphs with back-references, DOM walks — accumulate cycles faster than the collector sweeps. Memory climbs in a sawtooth that trends upward until the cap. A periodic gc_collect_cycles call per chunk flattens it by forcing the sweep on your schedule.

Database buffering is the quieter hog. PDO's default buffered queries load the entire result into the driver before PHP sees row one — a 1M-row select allocates fully before your loop starts. Unbuffered queries (PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false) or cursors stream rows, trading the ability to count rows up front for flat memory. MySQLi users get the same choice with use_result versus store_result.

Explicit release closes the loop. mysqli_result->free() and PDOStatement->closeCursor() return driver buffers the moment you're done instead of at script end. Unset big temporaries ($rows = null) before the next phase rather than after the script. Static properties and long-lived singletons deserve audits — anything appended per request in a worker process is a slow leak wearing a trench coat.

BufferedFix.phpPHP
1
2
3
4
5
6
7
8
9
10
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$stmt = $pdo->query('SELECT id, email FROM customers');
$out = fopen($path, 'w');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    fputcsv($out, $row);
}
$stmt->closeCursor(); // release driver buffers now
fclose($out);
// MySQLi equivalent: $res = $db->query($sql, MYSQLI_USE_RESULT);
// ... loop ...; $res->free();
📊 Production Insight
Buffered PDO held the full 1M-row result before row one processed; unbuffered mode plus closeCursor flattened driver memory to a single-row window.
🎯 Key Takeaway
Force cycle collection per chunk, stream unbuffered results, and explicitly free cursors and big temporaries the moment they retire.

Xdebug Overhead and Production Hygiene

Xdebug multiplies memory usage 2-3x through execution tracing, stack capture, and code coverage bookkeeping — wonderful for debugging, lethal for capacity. The classic incident: xdebug left enabled in production after a debugging session, and every request carries triple weight until the fattest ones cross the limit. Development stays green (high local limits) while production burns (tight FPM limits plus real data volumes).

Verify with php -m | grep -i xdebug on the production box and in the FPM pool — CLI and FPM load different extension sets, so checking one proves nothing about the other. The fix is environmental: install xdebug only in dev images, or toggle with phpdismod per SAPI. Measure the delta once (ab benchmark with and without) so the team respects the gap instead of rediscovering it yearly.

Apply the same hygiene to opcache and workers. Opcache preloading trims per-request compile memory; realpath_cache_size avoids stat churn. Queue workers need --max-jobs and --max-time so they recycle before static state accumulates, plus --memory caps that restart runaways. Production PHP should be lean by construction: no debugger, warm caches, recycled workers, and finite limits everywhere.

BASH
1
2
3
4
5
6
7
8
9
10
11
# Is the debugger taxing production?
php -m | grep -i xdebug
php -i | grep -iE 'memory_limit|xdebug.mode|opcache.enable'

# Per-SAPI disable (Debian/Ubuntu layout)
# phpdismod -s fpm xdebug
# systemctl restart php8.3-fpm

# Recycle queue workers before state accumulates
# php artisan queue:work --max-jobs=500 --max-time=3600 --memory=128 --tries=1
# php artisan queue:restart   # rolling restart after deploys
📊 Production Insight
Staging carried xdebug with a 1G CLI limit while FPM ran 512M without it — same code, different profiles, and the comparison misled tuning for 3 nights.
🎯 Key Takeaway
Keep xdebug out of production images, verify per SAPI, recycle workers with caps, and warm opcache instead of raising limits.

Composer Memory Trick and a Repeatable Workflow

COMPOSER_MEMORY_LIMIT=-1 exists because dependency resolution genuinely needs ~1.5GB on large trees — SAT solving over hundreds of packages isn't your app leaking, it's combinatorial math. Scope it to the tool call (COMPOSER_MEMORY_LIMIT=-1 composer install --no-dev -o) or set it in CI env for the build step only. It never belongs in php.ini, FPM pools, or application code — those serve requests, not solvers.

The durable workflow runs the same five steps every time. First, read both numbers in the fatal and confirm the active limit in the failing SAPI. Second, profile peak per block with memory_get_peak_usage(true) to name the hog — linear-with-rows means buffering, sawtooth-up means cycles, flat-then-spike means one giant allocation. Third, apply the matching fix: chunk, unbuffer, collect cycles, free cursors, or disable xdebug.

Fourth, verify on realistic volume — a 200K-row staging export asserting peak bytes in CI, not a 50-row unit test. Fifth, keep the limit finite as a regression alarm with an alert on fatal frequency. Memory bugs grow with data, so today's comfortable headroom is next year's incident. The team that gates peak bytes in CI never meets this error at 3 AM again.

BASH
1
2
3
4
5
6
7
8
9
10
# Build-box only: let the resolver breathe (never in php.ini)
COMPOSER_MEMORY_LIMIT=-1 composer install --no-dev --optimize-autoloader

# Ship vendor from the build box instead of resolving in prod
# composer install --no-dev -o --no-scripts   # on CI
# rsync -a vendor/ prod:/app/vendor/

# Regression gate: fail CI when peak exceeds budget
# php -d memory_limit=512M artisan export:customers --env=testing
# test asserts: memory_get_peak_usage(true) < 150 * 1024 * 1024
💡Scope the composer trick to the tool call
Prefix the single composer command with COMPOSER_MEMORY_LIMIT=-1 instead of editing any ini file. Better yet, resolve dependencies on the build box and ship vendor/ — production then never runs the solver at all.
📊 Production Insight
Composer on the 512M prod box died during deploys until the team resolved on CI and shipped vendor/ — the app limit stayed 512M while deploys stopped touching it.
🎯 Key Takeaway
Unlimited memory belongs to the build-box solver only; gate app peak bytes in CI and keep production limits finite and alerted.
● Production incidentPOST-MORTEMseverity: high

A 2M-Row Export OOM-Killed Every Night for a Week

Symptom
The nightly customer export died with Allowed memory size of 536870912 bytes exhausted at roughly 78% progress for 7 straight nights. The queue retried each failure once, so the database served the same 1.5M rows twice nightly — slow queries doubled and the replica lagged 40 seconds. Morning CSVs were missing, support tickets averaged 12 per day, and the on-call engineer restarted the worker each morning which fixed nothing.
Assumption
The team assumed the 512M limit was simply too small and raised it to 2G on night 3. The export then died at 91% instead of 78% — 20 minutes later, same error. Next they blamed a memory leak in Laravel and restarted workers between jobs, which cost 4 minutes per restart and still crashed. The actual cause was one line: Customer::all() loading 2M hydrated models (about 1.8KB each, 3.6GB total) into a single collection.
Root cause
Customer::all() materialized all 2M rows as Eloquent models with relations, casts, and event state — roughly 3.6GB for a 400MB CSV. PHP's 512M limit (later 2G) killed the worker mid-loop every time. Hydration overhead made each row 4-5x heavier than its raw bytes, and the collection held every model until the loop ended, so memory grew linearly to the cap. The retry then re-ran the same doomed query, doubling database load for zero output.
Fix
The export was rewritten with Customer::chunkById(2000) writing each chunk to the CSV stream and calling unset plus gc_collect_cycles per chunk — peak memory dropped from 3.6GB to under 90MB. The limit stayed at 512M as a runaway guard instead of rising to 2G. The 2M-row export now finishes in 31 minutes, replica lag never exceeds 3 seconds, and a 200K-row staging export asserts peak bytes in CI.
Key lesson
  • Never load unbounded datasets with ::all() — chunkById or cursors keep peak memory flat no matter how rows grow.
  • Raising memory_limit without shrinking usage just moves the crash later; keep the limit as a runaway guard.
  • Gate memory in CI with a realistic-volume export test — row counts only grow, and the next 2M becomes 5M quietly.
Production debug guideFive measurements that find the hog before you raise the limit.5 entries
Symptom · 01
Fatal error names an allocation of a few KB against a 128M/512M cap
→
Fix
Read both numbers: the cap is your limit, the small request is just the final straw. Run php -i | grep -i memory to confirm the active limit and which php.ini applies — CLI and FPM differ. Fix: profile peak with memory_get_peak_usage(true) around suspect blocks instead of raising the cap blindly.
Symptom · 02
Memory grows linearly with row count on imports or exports
→
Fix
Confirm with a 10K-row versus 100K-row run while logging memory_get_usage(true) per 1,000 rows — linear growth means buffered accumulation. Fix: replace ::all() with ::chunkById(2000) or lazy() cursors, flush output per chunk, and unset the chunk variable each pass.
Symptom · 03
Queue worker balloons over hours but web requests stay flat
→
Fix
Compare php -d memory_limit=512M artisan queue:work --memory=128 --tries=1 against the long-lived worker; if restarts fix it, state accumulates between jobs. Fix: set --max-jobs=500 --max-time=3600 so workers recycle, and clear static caches in an event listener between jobs.
Symptom · 04
Local runs pass but production exhausts on the same data
→
Fix
Diff php -i | grep -iE 'memory|xdebug|opcache' between environments — production often has xdebug loaded or a lower FPM limit. Fix: disable xdebug in prod (it adds 2-3x overhead), align FPM memory_limit with tested values, and never develop with a higher limit than production allows.
Symptom · 05
Composer itself exhausts during deploy dependency installs
→
Fix
Run free -m during composer install — the resolver needs ~1.5GB on big trees while the box may have 512M. Fix: set COMPOSER_MEMORY_LIMIT=-1 for the composer command only, or run composer install --no-dev -o on a build box and ship the vendor directory.
PHP memory causes compared
Root CauseHow to ConfirmFixPrevention
Unbounded ::all() hydrationMemory linear with row count; dies near completionchunkById(2000) + streaming writes + unset per chunkVolume regression test asserting peak bytes
Buffered driver resultsFull allocation before first row processesUnbuffered queries; closeCursor/free() when doneDefault unbuffered for exports; review fetch modes
Cycle accumulation in loopsSawtooth trending up; gc_collect_cycles flattens itCollect cycles per chunk; break parent-child cyclesUnset graph roots per window; profile long loops
Xdebug loaded in productionphp -m shows xdebug; 2-3x overhead vs devRemove from prod images; phpdismod per SAPIImage audit in CI; per-SAPI extension checks
Worker state across jobsRestarts fix it; grows with job count not rowsRecycle with --max-jobs/--max-time/--memoryClear static caches between jobs; monitor worker RSS
Composer resolver on small boxFails during install, not requests; needs ~1.5GBCOMPOSER_MEMORY_LIMIT=-1 for that command onlyResolve on build box; ship vendor/ to production
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
php -i | grep -iE 'memory_limit|Loaded Configuration File'What the Error Actually Tells You
php.ini; /etc/php/8.3/fpm/php.ini — scoped, finite, documentedRaising memory_limit the Right Way
ExportCustomers.phpuse App\Models\Customer;Chunk Unbounded Queries
BufferedFix.php$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);Circular References and Buffered Results
php -m | grep -i xdebugXdebug Overhead and Production Hygiene
COMPOSER_MEMORY_LIMIT=-1 composer install --no-dev --optimize-autoloaderComposer Memory Trick and a Repeatable Workflow

Key takeaways

1
The fatal names the cap and the straw
investigate the megabytes before the kilobytes.
2
Verify the limit in the failing SAPI; CLI and FPM read different ini files.
3
Window unbounded sources with chunkById or cursors and stream output per window.
4
Free driver cursors, collect cycles per chunk, and unset phase data promptly.
5
Keep xdebug out of production and recycle queue workers with memory caps.
6
Gate peak bytes in CI on realistic volume; keep production limits finite and alerted.

Common mistakes to avoid

5 patterns
×

Raising memory_limit instead of shrinking usage

Symptom
Crashes move later with bigger blast radius; 512M to 2G bought only 20 minutes
Fix
Chunk and stream first, keep the limit as a runaway guard, alert on fatal frequency
×

Loading tables with ::all() or get() unbounded

Symptom
Linear growth to the cap; biggest customer always crashes first
Fix
chunkById for writes-safe paging, lazy() for pure streaming, flush output per window
×

Leaving xdebug enabled in production

Symptom
2-3x memory on every request; fattest endpoints cross limits under real data
Fix
Strip from prod images; verify with php -m per SAPI after every debug session
×

Using COMPOSER_MEMORY_LIMIT as an app fix

Symptom
Unlimited web processes turn fatals into kernel OOM kills of neighbors
Fix
Scope -1 to the composer command on build boxes; ship vendor/ to production
×

Never freeing driver results or big temporaries

Symptom
Buffers held to script end; sequential phases stack peak on peak
Fix
closeCursor()/free() immediately, unset phase data before the next phase, recycle workers
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What do the two numbers in the fatal error mean?
Q02JUNIOR
Why is ::all() dangerous on big tables?
Q03SENIOR
When is raising memory_limit correct?
Q04SENIOR
How do chunk, chunkById, and lazy differ?
Q05SENIOR
How would you stop a worker that balloons nightly?
Q01 of 05JUNIOR

What do the two numbers in the fatal error mean?

ANSWER
The cap (Allowed memory size of...) is your limit; the small tried-to-allocate size is just the final straw. Investigate the megabytes accumulated before it, not the kilobytes that tripped it.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Why does production exhaust when local passes?
02
Does unset() actually free memory?
03
Buffered or unbuffered queries for exports?
04
How much memory does Eloquent hydration add?
05
Should queue workers run unlimited?
06
Where should COMPOSER_MEMORY_LIMIT go?
N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.

Follow
✓ Verified
production tested
September 23, 2026
last updated
1,942
articles · all by Naren
🔥

That's Advanced PHP. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
PHP Memory Management
14 / 14 · Advanced PHP