In a normal PHP request, memory is not really your problem. The process starts, allocates, responds, and is torn down. Even genuinely leaky code is bounded by a single request's lifetime.
Then you write a queue worker, a WebSocket server, or anything using Swoole, RoadRunner, or Octane — and every allocation that used to disappear at the end of the request now accumulates across thousands of iterations. Code that ran for a decade without complaint starts dying with Allowed memory size exhausted at 3am.
Measuring
Two functions, and the difference between them matters:
memory_get_usage(true); // memory the OS gave PHP
memory_get_usage(false); // memory PHP's allocator has handed out
memory_get_peak_usage(true); // high-water mark
Passing true reports real memory — what PHP requested from the operating system, in chunks. Passing false reports what your script is actually using. Real usage grows in steps and never shrinks; script usage moves with your data.
For leak hunting, track real usage across iterations. If it climbs monotonically and never plateaus, you have a leak. If it climbs and levels off, you have a large working set, which is a different problem with a different fix.
$baseline = memory_get_usage(true);
foreach (range(1, 1000) as $i) {
$this->processOne();
if ($i % 100 === 0) {
$now = memory_get_usage(true);
printf("iter %4d: %6.2f MB (+%.2f MB)\n",
$i,
$now / 1048576,
($now - $baseline) / 1048576
);
}
}
Run it for a few thousand iterations. A leak shows up as a straight line.
The usual culprits
Query log accumulation
Laravel's query log is an array in memory that grows with every query executed. In a request that is fine. In a worker running for hours it is a slow, guaranteed leak.
It is off by default, but debug tooling turns it on and people forget:
DB::connection()->disableQueryLog();
Check it before hunting anything more exotic. It is the single most common cause of worker memory growth in Laravel applications.
Static properties and singletons
Static state has process lifetime, not request lifetime:
class Cache
{
private static array $items = []; // never freed
public static function put(string $key, mixed $value): void
{
self::$items[$key] = $value; // grows forever
}
}
Perfectly safe in FPM, an unbounded leak in a worker. If you need in-process caching, bound it:
private static array $items = [];
private const MAX = 1000;
public static function put(string $key, mixed $value): void
{
if (count(self::$items) >= self::MAX) {
array_shift(self::$items); // simple FIFO eviction
}
self::$items[$key] = $value;
}
The same applies to container singletons holding collections, and to event listeners registered in a loop — each registration is a closure the dispatcher keeps forever.
Circular references
PHP frees objects by refcounting. Two objects referencing each other never reach zero, so they are only reclaimed by the cycle collector — which runs when its root buffer fills, not promptly.
class Node
{
public ?Node $parent = null;
public array $children = [];
public function addChild(Node $child): void
{
$child->parent = $this; // cycle
$this->children[] = $child;
}
}
Common in tree structures, ORM relations that reference their parent, and event objects holding the dispatcher that dispatched them.
Force collection in long-running loops to see whether cycles are the cause:
$collected = gc_collect_cycles();
If a periodic gc_collect_cycles() flattens your growth curve, cycles are your problem. The fix is to break them explicitly when done — $node->parent = null — or use WeakReference for back-pointers:
class Node
{
private ?WeakReference $parent = null;
public function setParent(Node $parent): void
{
$this->parent = WeakReference::create($parent);
}
public function parent(): ?Node
{
return $this->parent?->get();
}
}
A weak reference does not contribute to the refcount, so no cycle forms.
Loading result sets whole
Not a leak, but the most frequent cause of exhaustion:
// Materialises every row
foreach (Order::all() as $order) { ... }
Chunk, or stream with a cursor:
Order::chunkById(500, function ($orders) {
foreach ($orders as $order) { ... }
});
foreach (Order::lazyById(500) as $order) { ... }
chunkById paginates by primary key rather than OFFSET, so it stays correct when rows are modified mid-iteration — an important difference from plain chunk.
Unclosed resources
File handles, cURL handles, and stream contexts hold memory outside PHP's tracked heap:
$handle = fopen($path, 'r');
try {
while (($line = fgets($handle)) !== false) { ... }
} finally {
fclose($handle);
}
Memory functions will not show this growth clearly, because it is not in PHP's allocator. Watch RSS from the operating system instead.
Profiling properly
Manual instrumentation finds the fact of a leak. To find the object holding the memory, use a profiler.
memprof gives you allocations by function:
pecl install memprof
memprof_enable();
$this->runWorkload();
memprof_dump_pprof(fopen('/tmp/profile.heap', 'w'));
Read it with pprof, and you get a call graph weighted by retained bytes — which points at the allocation site directly instead of making you bisect.
Xdebug can trace with memory deltas per function call:
xdebug.mode = trace
xdebug.trace_format = 1
xdebug.collect_assignments = 1
Enormously slow. Useful on a reduced reproduction, not on production traffic.
The pragmatic mitigation
You will not always find the leak, and sometimes it is in a dependency you do not control. Bound the process lifetime:
php artisan queue:work --max-jobs=1000 --max-time=3600 --memory=256
The worker exits cleanly after 1000 jobs, an hour, or 256MB — whichever comes first — and the supervisor restarts it fresh. This is not defeat; it is the same reasoning behind PHP-FPM's pm.max_requests, and it has kept production systems stable for two decades.
Recycling is a safety net, not a diagnosis. A worker recycling every ninety seconds is telling you something specific, and the fix is upstream of the restart policy.
A workable order of operations
- Confirm growth is monotonic across thousands of iterations, not just large.
- Disable the query log. Genuinely, check this first.
- Audit static properties and container singletons for unbounded collections.
- Test whether
gc_collect_cycles()flattens the curve. If yes, hunt cycles. - Replace
all()withchunkByIdorlazyByIdon any large result set. - Profile with memprof if it is still unexplained.
- Set
--max-jobsand--memoryregardless, because defence in depth is cheap.
Octane and the persistent-state problem
Laravel Octane keeps the framework booted between requests, which is where its speed comes from and also where its hazards come from. Anything resolved once and held now spans requests.
The classic failure is a singleton that captured the first request:
// Registered as a singleton — captures request #1 and keeps it forever
$this->app->singleton(Reporter::class, fn ($app) => new Reporter($app['request']));
Every subsequent request gets a reporter holding the first request's data. This is a correctness bug and a leak simultaneously.
Octane provides the hooks to reset state:
// config/octane.php
'warm' => [...],
'flush' => [
Reporter::class,
SearchIndexBuffer::class,
],
'listeners' => [
RequestTerminated::class => [
FlushTemporaryContainerInstances::class,
FlushUploadedFiles::class,
],
],
And for anything you own, reset explicitly:
Octane::tick('flush-local-cache', fn () => app(TieredCache::class)->flush())
->seconds(60);
The general rule under Octane: static and singleton state must be either immutable or explicitly reset. Anything else accumulates.
Generators for large transformations
Building a large array in memory to transform it doubles your peak usage. Generators stream instead:
// Peak memory holds the entire result set
function loadAll(): array
{
$rows = [];
foreach (Order::cursor() as $order) {
$rows[] = $this->transform($order);
}
return $rows;
}
// Peak memory holds one row
function stream(): Generator
{
foreach (Order::cursor() as $order) {
yield $this->transform($order);
}
}
This composes well for exports, where the entire pipeline can stay constant-memory:
return response()->streamDownload(function () {
$out = fopen('php://output', 'w');
fputcsv($out, ['id', 'total', 'placed_at']);
foreach (Order::lazyById(1000) as $order) {
fputcsv($out, [$order->id, $order->total_cents, $order->created_at]);
}
fclose($out);
}, 'orders.csv');
A million-row export at constant memory, with the first bytes reaching the client immediately.
Note lazyById rather than cursor(). cursor() streams from a single unbuffered result set, which holds a database connection open for the entire export and can hit max_allowed_packet issues; lazyById paginates by key, which is more robust for long-running exports.
The collection trap
Eloquent collections are convenient and they materialise everything:
// Loads every order into memory, then filters in PHP
Order::all()->where('status', 'pending')->take(10);
// Filters in the database, loads 10 rows
Order::where('status', 'pending')->take(10)->get();
The first form is a leading cause of memory exhaustion in Laravel applications, and it reads almost identically to the second. The tell is all() followed by a collection method that has a query-builder equivalent.
lazy() gives you collection ergonomics without materialising:
Order::lazy()
->filter(fn ($o) => $o->needsReview())
->each(fn ($o) => $this->flag($o));
Interpreting real memory versus script memory
Earlier I noted the difference between memory_get_usage(true) and (false). It is worth being precise about what a divergence tells you.
PHP requests memory from the OS in large chunks and does not return it. So real usage is a staircase that only goes up, while script usage moves with your data.
- Both climbing together — genuine growth. You are retaining objects.
- Script flat, real climbing — fragmentation. Many differently-sized allocations and frees leave gaps the allocator cannot reuse. Common with heavy string manipulation.
- Script sawtoothing, real flat — healthy. Allocating and freeing within a stable working set.
For fragmentation, the practical fix is usually to reduce allocation churn — reuse buffers, avoid building large strings by concatenation in a loop, prefer implode on an array over repeated .=.
A reproducible harness
Guessing is slow. Build something that answers the question in one run:
class MemoryProbe extends Command
{
protected $signature = 'debug:memory {job} {--iterations=2000}';
public function handle(): int
{
$job = $this->argument('job');
$n = (int) $this->option('iterations');
gc_collect_cycles();
$base = memory_get_usage(true);
$samples = [];
for ($i = 1; $i <= $n; $i++) {
app($job)->handle();
if ($i % 100 === 0) {
$samples[$i] = memory_get_usage(true) - $base;
}
}
$first = reset($samples);
$last = end($samples);
$perIteration = ($last - $first) / ($n - 100);
$this->table(['iteration', 'growth (MB)'],
collect($samples)->map(fn ($b, $i) => [$i, round($b / 1048576, 2)])->all());
$this->line(sprintf('Growth per iteration: %.1f bytes', $perIteration));
$this->line($perIteration > 100 ? '<fg=red>LEAK</>' : '<fg=green>stable</>');
return self::SUCCESS;
}
}
The per-iteration figure is what makes this actionable. "Grows by 40MB over 2000 iterations" is 20KB per iteration — enough to identify roughly what size of object is being retained, which usually narrows the search immediately.
When the leak is not yours
Sometimes it is in a dependency. Bisect by disabling service providers:
// config/app.php — comment out one at a time and re-run the probe
'providers' => [
// App\Providers\TelemetryServiceProvider::class,
App\Providers\AppServiceProvider::class,
],
Crude, and it finds the culprit in about six runs on a typical application. Once identified, options are a version bump, a workaround, or bounding the process lifetime and moving on — the third is a legitimate engineering decision, not a failure.
Production guardrails regardless
Set these whether or not you have a known leak:
php artisan queue:work \
--max-jobs=1000 \
--max-time=3600 \
--memory=256 \
--timeout=60
; php-fpm pool
pm.max_requests = 500
; Supervisor
stopwaitsecs = 90
pm.max_requests recycles FPM workers after a set number of requests, which contains slow leaks in web code the same way --max-jobs does for queues. Both have been standard practice for two decades precisely because leaks are hard and recycling is cheap.
The order of operations, condensed
- Confirm growth is monotonic over thousands of iterations, not merely large.
DB::connection()->disableQueryLog(). Check this first, every time.- Audit static properties, singletons, and anything Octane keeps between requests.
- Test whether periodic
gc_collect_cycles()flattens the curve — if yes, hunt circular references and considerWeakReference. - Replace
all()withlazyById/chunkByIdon every large result set. - Look for fragmentation by comparing real against script usage.
- Profile with memprof if it remains unexplained.
- Set
--max-jobs,--memory, andpm.max_requestsregardless — defence in depth is nearly free.