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.
20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.
- ✓PHP and Laravel basics
- ✓Running artisan commands
- ✓Reading php -i output
- 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
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.
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.
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.
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.
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.
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.
A 2M-Row Export OOM-Killed Every Night for a Week
- 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.
lazy() cursors, flush output per chunk, and unset the chunk variable each pass.| File | Command / Code | Purpose |
|---|---|---|
| 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, documented | Raising memory_limit the Right Way |
| ExportCustomers.php | use 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 xdebug | Xdebug Overhead and Production Hygiene | |
| COMPOSER_MEMORY_LIMIT=-1 composer install --no-dev --optimize-autoloader | Composer Memory Trick and a Repeatable Workflow |
Key takeaways
Common mistakes to avoid
5 patternsRaising memory_limit instead of shrinking usage
Loading tables with ::all() or get() unbounded
lazy() for pure streaming, flush output per windowLeaving xdebug enabled in production
Using COMPOSER_MEMORY_LIMIT as an app fix
Never freeing driver results or big temporaries
Interview Questions on This Topic
What do the two numbers in the fatal error mean?
Frequently Asked Questions
20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.
That's Advanced PHP. Mark it forged?
5 min read · try the examples if you haven't