Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

PHP Memory Limits and Common Leak Patterns

By Kokil Thapa | Last reviewed: September 2026

PHP memory limits and common leak patterns cause more production outages than most teams expect. A CSV export, a report job, or a single eager-loaded query can push a request past memory_limit and return HTTP 500 with no useful stack trace. On real client projects — from Laravel eCommerce platforms to legal-tech portals — I've traced the same failures back to misconfigured limits, unbounded collections, and code that looks fine on small datasets. This guide covers how PHP allocates memory, where leaks actually hide, and what to change in production without guessing.

What is PHP memory_limit and how does allocation work?

Every PHP process gets a ceiling set by memory_limit. When usage crosses that ceiling, PHP throws a fatal error: Allowed memory size exhausted. The limit applies per script execution — each HTTP request through PHP-FPM gets its own budget. CLI commands like artisan queue:work get a separate budget per worker process.

PHP tracks allocated bytes internally. You can inspect usage at any point with memory_get_usage(true) for system allocation and memory_get_peak_usage(true) for the high-water mark. The official PHP memory functions documentation describes both flags.

How PHP interprets memory_limit values

memory_limit accepts suffixes: 128M, 512M, 1G. A value of -1 means unlimited — avoid that in production. Setting 0 also means unlimited on most builds. The default in php.ini is often 128M, which is too low for modern Laravel 13 or WordPress 7.1 admin screens with several plugins active.

; php.ini or pool override
memory_limit = 256M

; Per-directory .user.ini (CGI/FPM with user_ini.filename enabled)
memory_limit = 512M
PHP Memory Limits Per RequestPHP-FPMWorker poolBootstrapAutoload + iniYour CodeArrays, ORMGC CycleFree unusedUnder LimitNormal responseOver LimitFatal error 500Worker ResetMemory cleared
PHP memory limits apply per request: each PHP-FPM worker runs one script until completion, garbage collection, or fatal exhaustion.

PHP 8.5 improves allocator behaviour compared with PHP 7.x, but the mental model stays the same. Strings, arrays, and objects live on the heap. Copy-on-write reduces duplication until you modify data. Large string concatenation in loops still allocates fresh buffers each iteration — a pattern I still see in legacy export scripts.

How do you configure PHP memory limits in production?

Configuration layers stack. Know which layer wins before you change anything. On Ubuntu servers I maintain for Linux system administration clients, the wrong layer is the usual reason a php.ini edit appears to do nothing.

Configuration precedence from lowest to highest

  1. Built-in PHP default (often 128M)
  2. Global /etc/php/8.5/fpm/php.ini or Apache equivalent
  3. Pool-specific overrides in /etc/php/8.5/fpm/pool.d/www.conf
  4. php_admin_value[memory_limit] in the pool — cannot be overridden by ini_set()
  5. ini_set('memory_limit', '512M') at runtime — if not blocked by admin values
; /etc/php/8.5/fpm/pool.d/www.conf
php_admin_value[memory_limit] = 256M

; Long-running queue workers — separate pool
[queue]
user = www-data
group = www-data
listen = /run/php/php8.5-fpm-queue.sock
pm = static
pm.max_children = 4
php_admin_value[memory_limit] = 512M
php_admin_value[max_execution_time] = 0

After any change, reload PHP-FPM: sudo systemctl reload php8.5-fpm. Verify with a one-liner:

php -i | grep memory_limit
php-fpm8.5 -i 2>/dev/null | grep memory_limit

Choosing sensible limits by workload type

WorkloadSuggested limitNotes
Public web pages (Laravel, WordPress)128M–256MFix the code if you need more for normal pages
Admin dashboards, bulk edits256M–512MIsolate to admin routes or separate pool
CSV/Excel exports512M–1GPrefer streaming or queued jobs instead
Queue workers (Maatwebsite Excel, PDF)512M–1GMatch pm.max_children to server RAM
One-off CLI migrations1G–2GTemporary; revert after migration completes

A server with 8 GB RAM and 20 PHP-FPM workers at 512M each can theoretically demand 10 GB. That mismatch causes OOM kills at the OS level — worse than a clean PHP fatal. Capacity planning belongs in testing and optimization, not only in application code.

PHP Memory Config Layersphp.ini global default — 128MFPM pool php_admin_value — 256M.user.ini per directory — optionalini_set() at runtime — if allowedHighest wins — check with phpinfo()
PHP memory limit configuration stacks: pool admin values beat global php.ini and block runtime ini_set unless explicitly permitted.

What are the most common PHP memory leak patterns?

Strictly speaking, PHP rarely leaks memory the way C programs do. When usage climbs across a single request, the cause is usually retained references — data you loaded and never released. Between requests, PHP-FPM workers reset. Long-running queue workers are the exception; they need deliberate cleanup.

Pattern 1: Loading entire tables into memory

This is the number-one offender on Laravel apps. User::all() or Order::with('items')->get() on a table with 200,000 rows will exhaust 512M quickly. Eloquent hydrates full model objects — far heavier than raw arrays.

/* Bad — loads everything */
$orders = Order::with(['items', 'customer'])->get();

/* Better — stream in chunks */
Order::with(['items', 'customer'])
    ->orderBy('id')
    ->chunkById(500, function ($orders) {
        foreach ($orders as $order) {
            /* process one batch */
        }
    });

/* Best for exports — lazy cursor */
foreach (Order::cursor() as $order) {
    /* one model at a time */
}

On a production Laravel application handling trek bookings, switching a report from get() to chunkById() dropped peak memory from 780M to under 90M. Same output, different architecture. See handling large JSON payloads in PHP for related patterns with API responses.

Pattern 2: Unbounded in-memory aggregation

Building a full index inside a loop is another classic mistake. Reading a 500 MB log into one string, then splitting on newlines, doubles peak usage before GC runs.

/* Bad */
$lines = file('/var/log/app.log');
$counts = [];
foreach ($lines as $line) {
    $counts[$line] = ($counts[$line] ?? 0) + 1;
}

/* Better — stream line by line */
$handle = fopen('/var/log/app.log', 'r');
$counts = [];
while (($line = fgets($handle)) !== false) {
    $counts[$line] = ($counts[$line] ?? 0) + 1;
}
fclose($handle);

Pattern 3: Static properties and singleton caches

Static arrays on service classes survive for the entire worker lifetime. In queue workers processing thousands of jobs, a static cache grows without bound.

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

    public static function resolve(string $code): array
    {
        if (!isset(self::$cache[$code])) {
            self::$cache[$code] = /* expensive lookup */;
        }
        return self::$cache[$code];
    }
}

Use Redis or application-level Redis caching with TTL instead. Static caches belong only in short web requests where the worker resets after each hit.

Pattern 4: Circular references and closures

PHP 7.4+ handles most circular reference graphs through cycle collection. Older code holding parent-child object graphs in long CLI scripts can still balloon. Closures that capture large objects by reference keep those objects alive until the closure is destroyed.

$parent = new stdClass();
$child = new stdClass();
$parent->child = $child;
$child->parent = $parent;
unset($parent, $child);
/* GC runs at cycle boundaries — force if needed */
gc_collect_cycles();

Pattern 5: Image and document processing

GD and Imagick decode full raster images into memory. A 6000×4000 PNG can consume hundreds of megabytes per intermediate step. Spatie Media Library conversions on large uploads are a common trigger. Process on a queue worker with a higher limit, or delegate to a dedicated service.

Common PHP Memory Leak PatternsUnbounded QueryModel::all() on big tablesStatic CacheGrows in queue workersFull File Loadfile_get_contents on logsImage DecodeGD/Imagick full rasterFix: chunk, cursor, stream, Redis TTLRaise limit only for isolated heavy jobs
The most common PHP memory leak patterns in production: unbounded ORM queries, static caches, whole-file reads, and image decoding — all fixable without raising limits globally.

Pattern 6: Serialisation and deep copy side effects

Serialising large object graphs for cache or queue payloads duplicates structure in memory during encode/decode. Passing massive arrays through json_encode for logging can spike usage on every request. Use the JSON formatter tool to inspect payload size during development, and truncate debug output in production.

How do you debug PHP memory exhaustion in production?

Reproducing memory failures locally with production data volume is the fastest path to a fix. When that is not possible, instrument the code and read server metrics.

Step-by-step debugging workflow

  1. Confirm the active limit: add a temporary route or CLI script calling ini_get('memory_limit').
  2. Find peak usage in the failing path with memory_get_peak_usage(true) at strategic checkpoints.
  3. Enable query logging temporarily and count rows returned — N+1 queries multiply memory as well as SQL round trips.
  4. Run the script under php -d memory_limit=512M artisan … with Xdebug disabled for accurate timing.
  5. Profile with PHPStan and Blackfire or Xdebug trace if the hotspot is unclear.
  6. Load-test the fixed path with k6 load testing before deploy.
function logMemory(string $label): void
{
    $current = memory_get_usage(true);
    $peak = memory_get_peak_usage(true);
    logger()->debug($label, [
        'current_mb' => round($current / 1048576, 2),
        'peak_mb' => round($peak / 1048576, 2),
    ]);
}

logMemory('after bootstrap');
/* ... code under test ... */
logMemory('after export loop');

Reading error logs and PHP-FPM signals

PHP fatals land in the FPM pool log or nginx/apache error log depending on setup. Look for Allowed memory size of X bytes exhausted followed by the file and line. If the line is inside vendor code, your caller probably passed too much data — trace upward.

Watch for Linux OOM killer entries in dmesg. Those mean total process memory exceeded physical RAM plus swap. Lower pm.max_children or per-worker limits. PHP-FPM tuning for high traffic covers the arithmetic.

Laravel-specific debugging tools

Laravel Telescope and Debugbar show query counts in development. Never enable Debugbar in production — it retains request data in memory and skews measurements. For queued jobs, wrap the handle method:

public function handle(): void
{
    ini_set('memory_limit', '512M');

    $before = memory_get_usage(true);
    /* job logic */
    $after = memory_get_usage(true);

    Log::info('Job memory', [
        'delta_mb' => round(($after - $before) / 1048576, 2),
        'peak_mb' => round(memory_get_peak_usage(true) / 1048576, 2),
    ]);
}

The Laravel documentation on chunking query results is the reference for ORM-level fixes in Laravel 12 and 13.

Debugging PHP Memory ExhaustionFatal LogFind file + lineInstrumentPeak at checkpointsIdentifyQuery or cacheFix CodeChunk / streamVerify: peak under 50% of limitLoad test before production deployOpcache OKSee opcache guideMonitor RAMWorkers x limit
Debug PHP memory exhaustion by tracing the fatal log, instrumenting peak usage, fixing the data-loading pattern, then verifying under load.

How do WordPress, WooCommerce, and CLI workers differ?

WordPress 7.1 sets WP_MEMORY_LIMIT and WP_MAX_MEMORY_LIMIT in wp-config.php. WooCommerce 11.1 admin screens and product imports often need 256M or more. That does not excuse loading every product meta row into one array — the same chunking rules apply.

define('WP_MEMORY_LIMIT', '256M');
define('WP_MAX_MEMORY_LIMIT', '512M');

CLI workers on Laravel 13 with PHP 8.3+ run until killed. Memory from job 1 can affect job 500 if static state leaks. Restart workers periodically with --max-jobs=500 or supervisor stopwaitsecs. On shared hosting common in Nepal (Rs 3,000–8,000/year, ~USD 22–60), hard caps at 128M or 256M are non-negotiable — design exports as emailed links, not synchronous downloads.

For enterprise batch systems, enterprise application development should treat memory budgets as architecture requirements from day one. I've applied the same discipline on booking systems with large itinerary datasets where report generation runs off-queue.

OPcache configuration reduces bootstrap memory but does not shrink per-request data. Pair opcache tuning with query discipline. When a site keeps hitting limits after code fixes, support and maintenance usually reveals a plugin, module, or cron job added without load testing.

Key Takeaways

  • Set memory_limit at the FPM pool level with php_admin_value — verify with php -i, not assumptions.
  • Never load full tables with get() or all(); use chunkById(), cursor(), or SQL aggregation instead.
  • Static caches and unbounded in-memory arrays are the top leak patterns in long-running queue workers.
  • Raise limits only for isolated pools (exports, image jobs) — global 1G masks bugs and risks OS OOM kills.
  • Instrument with memory_get_peak_usage(true) at checkpoints before blindly doubling memory_limit.
  • Match total worker memory (children × limit) to server RAM; PHP-FPM and MySQL 9.7 compete for the same box.

People Also Ask

What happens when PHP runs out of memory?

PHP terminates the script with a fatal error: Allowed memory size of N bytes exhausted. The web server returns HTTP 500 unless you catch it at a higher layer — which you generally cannot for fatals. PHP-FPM logs the error; the worker process may respawn clean for the next request.

Can ini_set increase memory_limit in Laravel?

Only if the pool does not set php_admin_value[memory_limit]. Admin values block runtime overrides. Many production pools use admin values intentionally so application code cannot raise limits without deployment review. Check ini_get('memory_limit') after bootstrap to see the effective value.

Is raising memory_limit to 2G a valid fix?

It is a temporary workaround, not a fix. If a web page needs 2G, the architecture is wrong — paginate, queue, or stream. Reserve high limits for CLI migrations or dedicated export workers with monitored concurrency. On a 4 GB VPS, one 2G request plus MySQL and Redis leaves little headroom.

Do PHP memory leaks persist between requests?

Not in standard PHP-FPM web workers — each request starts fresh after the worker handles the script. Leaks persist in long-running CLI processes: queue workers, ReactPHP/RoadRunner workers, and custom daemons. Restart policies and avoiding static accumulation are essential there.

Fix the pattern first, then tune the limit

PHP memory limits and common leak patterns are predictable once you know where to look. Misconfigured pools, unbounded Eloquent queries, and static caches cause most of the fatals I see in production — not mysterious engine bugs. Fix the data-loading pattern, isolate heavy jobs into separate FPM pools, and size worker counts against real RAM. If your application keeps hitting ceilings after code review, speed and optimization work or a focused audit from someone who ships PHP in production will save more than repeatedly editing php.ini. Need hands-on help with a stuck export, queue worker, or hosting migration? Contact us — or browse web development services and client reviews to see how similar issues were resolved on live projects.

Frequently Asked Questions

memory_limit caps heap usage per script run. Each HTTP request and CLI process gets its own budget. Crossing it throws a fatal Allowed memory size exhausted error.

PHP terminates with Allowed memory size exhausted, returns HTTP 500, and logs the fatal in PHP-FPM. The worker may respawn clean for the next request.

No. It is a temporary workaround, not an architecture fix. Paginate, queue, or stream. On a 4 GB VPS, one 2G request leaves little headroom for MySQL and Redis.

Configuration stacks from lowest to highest: built-in default, global php.ini at /etc/php/8.5/fpm/php.ini, then pool overrides in /etc/php/8.5/fpm/pool.d/www.conf using php_admin_value[memory_limit]. Admin values beat ini_set and cannot be overridden at runtime. After changes, run sudo systemctl reload php8.5-fpm and verify with php -i | grep memory_limit or php-fpm8.5 -i. Per-directory .user.ini works on some CGI/FPM setups. Always confirm which layer wins before assuming a php.ini edit took effect.

Only if the PHP-FPM pool does not set php_admin_value[memory_limit]. Many production pools use admin values intentionally so application code cannot raise limits without deployment review. If admin values are set, ini_set is blocked. Check ini_get('memory_limit') after Laravel bootstrap to see the effective value. On real client projects, I have seen developers add ini_set in a controller while the pool still enforced 256M, making the change silently useless.

Public web pages on Laravel 12 or 13 typically need 128M to 256M; if normal pages require more, fix the code. Admin dashboards and bulk edits often need 256M to 512M, ideally isolated to admin routes or a separate pool. Queue workers handling Maatwebsite Excel or PDF jobs should run at 512M to 1G in a dedicated pool with pm.max_children matched to server RAM. One-off CLI migrations can temporarily use 1G to 2G, then revert after completion.

Not in standard PHP-FPM web workers. Each request starts fresh after the worker finishes the script. Memory that climbs within a single request is usually retained references, not a kernel-level leak. The exception is long-running CLI processes: Laravel queue workers, custom daemons, and similar workers reuse the same process. Static caches and unbounded arrays there grow across hundreds of jobs. Restart workers periodically with --max-jobs=500 or supervisor stopwaitsecs, and avoid static accumulation.

Strictly speaking, PHP rarely leaks like C programs. Usage spikes come from retained data: loading entire tables with User::all() or eager get(), unbounded in-memory aggregation such as reading a whole log file into one string, static property caches on service classes, circular references in older long CLI scripts, full raster image decoding via GD or Imagick, and serialising large object graphs for cache or queue payloads. Between requests PHP-FPM resets; long queue workers are where static and unbounded patterns hurt most.

Replace get() or all() on large tables with chunked or cursor iteration. Order::with(['items', 'customer'])->get() on 200,000 rows can exhaust 512M because Eloquent hydrates full model objects. Use chunkById(500, callback) for batch processing or cursor() for one model at a time during exports. On a production booking application, switching a report from get() to chunkById() dropped peak memory from 780M to under 90M with identical output. Prefer SQL aggregation when you only need counts or sums.

Static arrays on service classes survive for the entire worker lifetime, not just one job. A GeoLookup-style static cache grows with every unique key across thousands of jobs while the same PHP process keeps running. Web requests reset after each hit, so static caches are less risky there. For queue workers on Laravel 13 with PHP 8.3+, use Redis or application caching with TTL instead. Combine that with worker restarts via --max-jobs=500 so memory from job 1 does not affect job 500.

Confirm the active limit with ini_get('memory_limit'). Instrument the failing path using memory_get_peak_usage(true) at strategic checkpoints, logging current and peak megabytes. Enable query logging temporarily and count rows returned, since N+1 queries multiply memory. Reproduce with php -d memory_limit=512M artisan with Xdebug disabled. Read PHP-FPM or web server logs for Allowed memory size exhausted and trace upward if the fatal lands in vendor code. Watch dmesg for Linux OOM killer entries, which mean total process memory exceeded physical RAM plus swap.

WordPress 7.1 defines WP_MEMORY_LIMIT and WP_MAX_MEMORY_LIMIT in wp-config.php for front-end and admin contexts respectively. WooCommerce 11.1 admin screens and product imports often need 256M or more under WP_MAX_MEMORY_LIMIT. Those constants do not replace php.ini or FPM pool limits; the effective ceiling is whichever layer is strictest. The same chunking discipline applies: raising WordPress memory constants does not excuse loading every product meta row into one array. On shared hosting common in Nepal at Rs 3,000 to 8,000 per year, hard caps at 128M or 256M are non-negotiable.

Total demand is pm.max_children multiplied by each worker memory_limit. A server with 8 GB RAM and 20 PHP-FPM workers at 512M each can theoretically need 10 GB, triggering OS-level OOM kills that are worse than a clean PHP fatal. MySQL 9.7 and Redis compete for the same box. Match worker count and per-pool limits to actual RAM during capacity planning. Raise limits only for isolated pools such as exports or image jobs, not globally at 1G, which masks bugs and risks killing the entire server.

Prefer streaming, chunkById(), or cursor() over loading full datasets into RAM. For heavy exports, queue the job in a dedicated FPM pool at 512M to 1G with controlled concurrency rather than raising the global web pool limit. On budget shared hosting capped at 128M to 256M, design exports as emailed download links processed off-queue, not synchronous browser downloads. Maatwebsite Excel and similar packages belong on queue workers with higher isolated limits, not on public web requests that share memory with normal page traffic.

OPcache reduces bootstrap memory by caching compiled bytecode across requests, but it does not shrink per-request data loaded during the script. A CSV export or unbounded Eloquent query will still exhaust memory_limit regardless of OPcache tuning. Pair opcache configuration with query discipline: chunk results, avoid static caches in workers, and stream files line by line instead of file(). When a site keeps hitting limits after code fixes, investigate plugins, modules, or cron jobs added without load testing, since those often reintroduce unbounded loading patterns.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: