On this page
- How I tested
- Step 1: Confirm it is a leak, not just a big job
- Cause 1: a static array, singleton or global that only grows
- Cause 2: circular references, and why PHP's garbage collector does not always save you
- The sneaky cycle: a closure that captures $this
- Cause 3: listeners and callbacks registered over and over
- Cause 4: the query log in a long-running process
- Find out which objects are being kept: php-meminfo
- Contain a leak you cannot fix today
- Common mistakes
- Related Dev Talk articles and tools
- Frequently asked questions
- Does PHP have memory leaks?
- Should I call gc_collect_cycles() in my code?
- Why does the process memory in top not go down after unset()?
- Is Xdebug the right tool for finding leaks?
- Is a memory limit on queue workers a real fix?
Last updated: 5 November 2026. See the update log at the end.
In a normal PHP-FPM request, a memory leak mostly does not exist: PHP frees everything when the request ends. The leaks people actually hit happen in processes that stay alive: queue workers, daemons, long import scripts, and application servers like Laravel Octane. The symptom is always the same. Memory climbs a little with every job or request, then something dies with Allowed memory size exhausted, or the operating system kills the process, hours after the code that caused it ran. This article shows how to confirm that you have a leak, the four causes I would check first (each reproduced and measured), how to find the guilty objects, and how to contain a leak you cannot fix immediately.
If your problem is one request using too much memory in a single go, that is not a leak. See PHP "Allowed memory size exhausted" instead.
How I tested
PHP 8.3.6 CLI, and the Laravel 13.35 database and events code (the real classes, run outside a full application) on SQLite. All memory figures come from memory_get_usage() unless stated. Each result is one run; the growth patterns are what matter, not the exact megabytes.
Step 1: Confirm it is a leak, not just a big job
A leak is memory that keeps growing as you repeat the same work. Process the same unit of work many times and print memory every so often. If it plateaus, you do not have a leak. If it rises in a straight line, you do.
<?php
function report(string $label): void
{
printf("%s: %.1f MB (peak %.1f MB)\n",
$label, memory_get_usage() / 1048576, memory_get_peak_usage() / 1048576);
}
for ($round = 1; $round <= 5; $round++) {
for ($i = 0; $i < 10000; $i++) {
handleOneJob(); // your code here
}
gc_collect_cycles(); // so cycles don't hide the real result
report("after round $round");
}The gc_collect_cycles() call matters. It frees unreachable circular references, so whatever growth remains afterwards is memory that your code is still holding on to. Three PHP functions worth knowing while you measure:
memory_get_usage()is what your script has allocated.memory_get_usage(true)is what PHP has reserved from the system, in larger chunks. In my test with one 5 MB string they reported 5.4 MB and 7.0 MB. Laravel's queue worker checks thetruevalue.memory_get_peak_usage()is the high-water mark. Since PHP 8.2,memory_reset_peak_usage()resets it so you can measure one phase at a time. My test: a freed 50 MB string left the peak at 50.4 MB, and after the reset the peak read 0.4 MB.
Cause 1: a static array, singleton or global that only grows
The classic leak. Something stores data "for later" and nothing ever removes it. I simulated 50,000 units of work, each storing a 1 KB payload:
class Cache { public static array $items = []; }
Cache::$items[$id] = $payload; // grows foreverunbounded static cache: after 10000: 12.9 MB ... after 50000: 62.5 MB
bounded cache (max 1000): after 10000: 1.7 MB ... after 50000: 1.7 MBFixes, in order of preference: do not cache at all if the data is cheap to reload; bound the cache (a maximum size that evicts the oldest entry); or tie the lifetime of cached data to the object it belongs to with WeakMap, which drops an entry when its key object is freed:
$map = new WeakMap();
$user = new stdClass();
$map[$user] = ['expensive' => 'data'];
echo count($map); // 1
unset($user);
echo count($map); // 0In Laravel the same trap hides in singletons registered in the container, static properties on services, and traits with static state. They are harmless under PHP-FPM and dangerous under Octane or queue:work. The Octane documentation (the 9.x docs page; check the current one) gives exactly this example: adding data to a static array on each request is a memory leak, because Octane keeps the application in memory between requests.
Cause 2: circular references, and why PHP's garbage collector does not always save you
PHP frees most values the moment nothing references them. Objects that reference each other never reach zero, so PHP has a separate cycle collector. It works, and that is why cycles are less often the culprit than people expect. I created 100,000 pairs of objects pointing at each other and threw them away:
GC enabled: after 100000: 2.5 MB gc runs=19 collected=285000
GC disabled: after 100000: 42.1 MB gc runs=0 roots=200000
manual gc_collect_cycles() freed 300000; memory back to 2.4 MBSo with the collector on, those cycles cost nothing lasting. The catch is when it runs. The collector triggers when the number of possible cycle roots reaches a threshold (gc_status()['threshold'] was 10001 in my test), not when memory gets high. Large objects in cycles can therefore exhaust memory before the collector ever wakes up. I created objects that referenced themselves and each held 200 KB, with a 128 MB limit:
GC enabled, large self-referencing objects:
iteration 500: 96.2 MB
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted
same loop with gc_collect_cycles() every 100 iterations:
iteration 3000: 0.4 MB finishedIn a loop that creates big cyclic structures (parsed documents, large object graphs), call gc_collect_cycles() every few hundred iterations, or break the cycle. Do not call it on every iteration and do not turn the collector off: it has a cost, and disabling it just made the first test above 17 times worse.
The sneaky cycle: a closure that captures $this
An arrow function or closure created inside a method captures $this automatically. If you store that closure on the same object, you have built a cycle. I tested with WeakReference to see when the object was really freed, with the collector disabled:
class Report {
public $cb; public $data;
public function __construct(bool $static) {
$this->data = str_repeat('x', 100000);
$this->cb = $static ? static fn () => 1 : fn () => $this->data;
}
}capturing closure: freed right after unset? NO (waits for GC)
static closure: freed right after unset? yesIf a callback does not use $this, declare it static. It cannot create the cycle, and the object is released at once.
Cause 3: listeners and callbacks registered over and over
Registering an event listener inside code that runs per job or per request adds a new listener every time. Each one holds a closure and everything that closure captured. I registered one listener per "job" on Laravel's event dispatcher:
registered once: after 20000 jobs: 0.9 MB listeners=1
registered per job: after 20000 jobs: 16.2 MB listeners=20000Memory grew in a straight line, and every event dispatch also gets slower because it now calls 20,000 listeners. In Laravel, register listeners once, in a service provider's boot() method or the event-to-listener mapping, never inside a job's handle(), a controller, or a loop. The same applies to Model::observe(), macros, and any on()/listen() call on a long-lived object.
Cause 4: the query log in a long-running process
Laravel can record every query. It is off by default, which is why this shows up only when someone turns it on for debugging and forgets, or a package does. In a worker or a long command it grows without limit. I ran 20,000 trivial queries through the database connection:
query log off: after 20000 queries: 4.8 MB log entries=0
query log on: after 20000 queries: 18.2 MB log entries=20000
after DB::connection()->flushQueryLog(): 4.8 MB// if you need the log inside a long loop, clear it as you go
DB::connection()->enableQueryLog();
foreach ($ids->chunk(500) as $chunk) {
// ... work ...
DB::connection()->flushQueryLog();
}
DB::connection()->disableQueryLog();Search your code and your debugging packages for enableQueryLog. A related case is not a leak but looks like one: loading whole tables into memory. Use chunkById(), lazyById() or cursor(). I measured 54 MB for get() on 20,000 rows against 8 to 12 MB for the streaming options in the Laravel 13 performance article.
Find out which objects are being kept: php-meminfo
When the usual suspects do not explain it, you need to see what is in memory. The php-meminfo extension dumps every live item in a process to JSON. I built it from source against PHP 8.3.6 (phpize, ./configure --enable-meminfo, make) and ran a dump after creating 1,500 objects that were kept and 300 that were not:
// php -d extension=/path/to/meminfo.so script.php
$fh = fopen('/tmp/dump.json', 'w');
meminfo_dump($fh);
fclose($fh);Counting the objects by class in that dump:
[('Order', 1500), ('Tmp', 1)]The class that has far more instances than it should is your leak. The project ships an analyzer for this; its README documents the commands bin/analyzer summary, top-children, query -f "class=Order" and ref-path <item-id>, where ref-path shows what is still referencing a given object. I could not run the analyzer (it needs Composer packages that I could not download), so I counted classes with a short script instead.
Two warnings. The README lists PHP 7.x and 8.0 and the last commit I saw was from March 2022, so treat it as an unmaintained tool: it worked for a dump on 8.3.6 in my test, but try it on a staging copy first. And dumps of a real worker are large, so never run it in production traffic.
Contain a leak you cannot fix today
Recycling the process is a legitimate stopgap, as long as you treat it as a stopgap.
Laravel queue workers. From the Laravel 13 source:
php artisan queue:work --memory=256 --max-jobs=1000 --max-time=3600--memorydefaults to 128 (MB). In the 13.x source it also accepts a percentage of PHP'smemory_limit, such as50%. The worker comparesmemory_get_usage(true)in MB with the limit, and when it is reached the worker exits with status code 12.--max-jobsand--max-timestop the worker after that many jobs or seconds.- A stopped worker needs something to start it again, such as Supervisor or systemd. See Laravel queue jobs not processing or stuck. Set
--memorybelow the PHPmemory_limit, so the worker exits cleanly between jobs instead of dying in the middle of one.
PHP-FPM. In a pool configuration, pm.max_requests is the number of requests a child handles before it is respawned. The PHP manual says this can be useful to work around memory leaks in third-party libraries, and the default is 0, meaning never respawn:
pm.max_requests = 500Octane. The 9.x documentation says Octane restarts a worker after 500 requests by default, adjustable with php artisan octane:start --max-requests=250. I did not run Octane for this article.
Common mistakes
- Trusting a single measurement. One reading proves nothing. Repeat the work and watch the trend.
- Measuring without collecting cycles first. You may be chasing garbage that PHP was about to free.
- Calling
unset()and expecting the process size to shrink. PHP reuses freed memory internally. Judge bymemory_get_usage()across rounds, not by the process size intop. - Disabling the garbage collector for speed. My cycle test shows what that costs.
- Raising
memory_limitinstead of finding the growth. It only delays the crash. - Leaving debug tools on. Query logging, event recording and similar tools hold everything they record.
- Treating restarts as the fix.
--max-jobsandpm.max_requestshide the leak, and a leak that grows fast enough still kills the process between restarts.
Related Dev Talk articles and tools
- PHP "Allowed memory size exhausted"
- Laravel queue jobs not processing or stuck
- Docker build taking too long? 10 ways to speed it up
Tools: memory_get_usage(), memory_reset_peak_usage() and gc_status() (built in and used above), the php-meminfo extension, and a memory-capable profiler such as Blackfire. I did not use a profiler for this article.
Frequently asked questions
Does PHP have memory leaks?
PHP itself frees all request memory at the end of a request, so ordinary PHP-FPM apps rarely leak in the long-term sense. Long-running processes can accumulate memory through your code (static data, listeners, logs), through reference cycles that have not been collected yet, and occasionally through bugs in extensions or libraries.
Should I call gc_collect_cycles() in my code?
Only in long loops that create large cyclic structures, and every few hundred iterations rather than every iteration. My test showed it can be the difference between finishing and a fatal error. For small objects the automatic collector is enough.
Why does the process memory in top not go down after unset()?
PHP keeps freed memory to reuse it. memory_get_usage(true) reports memory PHP has reserved, which is larger than what your data uses (7.0 against 5.4 MB in my test). Judge growth by the trend across rounds.
Is Xdebug the right tool for finding leaks?
I did not test it for this. I used built-in memory functions to find the trend and php-meminfo to see what is held, so use those first.
Is a memory limit on queue workers a real fix?
No. It protects you from a crash by restarting cleanly. Use it as a safety net while you find the cause.
Free resources for this topic
Code snippets and fixes for this topic
Get one practical tip every week
New tutorials, fixes and free cheat sheets from DEV Talk, straight to your inbox. No spam, unsubscribe in one click.
Free download: Laravel interview questions (PDF)
Have a question about this post?
Ask the community: other developers and the author can answer.
Get notified about new articles
One click, no email needed. Turn it off anytime with the bell at the top.
Get new articles on your iPhone
Add DEV Talk to your Home Screen, open it, then tap the bell.
Enjoying the content?
If this article helped you, you can support the author with a coffee. It keeps new posts coming.
Comments 0
No comments yet
Be the first to share your thoughts.