Chasing a memory leak in a Laravel queue worker
Published March 21, 2026 · by Majd Ghithan, written with the help of AI
The alert came in at 4am: one of our queue:work processes had been OOM-killed. The supervisor restarted it, it processed jobs for about forty minutes, and got killed again. It wasn't crashing on a bad job — the same jobs ran fine right after a restart. It was slowly, patiently eating memory until the kernel put it down, and then doing it all over again.
A web request in PHP is a clean slate. The process boots, handles one request, and dies — any memory mess is swept away every few milliseconds. A queue worker breaks that assumption. It's one long-lived PHP process handling thousands of jobs in a row, and every byte you leak in job number one is still there in job number ten thousand. Things that are invisible in a request are fatal in a worker.
Here's how I found it, and what actually leaks.
That straight climb with no plateau is the signature. A healthy worker's memory rises and settles. A leaking one just keeps going up until it hits the ceiling.
Step one: confirm it's the worker, not a job
I added a one-line log of memory usage after each job, so I could see whether it grew per-job or spiked on one type:
Queue::after(function (JobProcessed $event) {
Log::info('mem', [
'job' => $event->job->resolveName(),
'mb' => round(memory_get_usage(true) / 1048576, 1),
]);
});
The log was damning: memory went up a little after every job, regardless of type, and never came back down. That rules out one heavy job and points at something accumulating across all of them — global state that survives from one job to the next.
The three usual suspects
The DB query log. This one gets almost everyone. Laravel can keep an in-memory log of every query it runs. In a web request that list is tiny and thrown away instantly. In a worker running for hours it grows without bound — every query from every job, kept forever.
// somewhere in boot, "for debugging", left on:
DB::enableQueryLog();
That single line, forgotten in a service provider, was most of our leak. The worker was hoarding a record of every query it had ever run. The fix is to not enable it in long-running processes — or disable it explicitly for the worker.
Static properties and singletons. Anything static, or any singleton bound in the container, persists across jobs. A cache array on a static property, a collection you keep appending to on a singleton service — it never resets, because the process never restarts between jobs.
class Enricher
{
private static array $seen = [];
public function handle($id)
{
self::$seen[$id] =
$this->lookup($id);
// never cleared;
// grows every job
}
}
class Enricher
{
public function handle($id)
{
$result =
$this->lookup($id);
// local var, freed
// when the job ends
return $result;
}
}
Event listeners registered per job. If a job registers a model event listener or a closure on boot and never removes it, each processed job adds another. Ten thousand jobs, ten thousand listeners, all held in memory and all firing.
The fixes: reset the state, and don't outrun the GC
Two layers of fix. First, kill the actual leaks — turn off the query log, clear or scope the static state, stop stacking listeners. That's the real repair.
Second — and this is the safety net every worker should have regardless — tell the worker to restart itself before it can grow too big. Laravel builds this in:
php artisan queue:work --max-jobs=1000 --max-time=3600 --memory=512
--max-jobs restarts the worker after N jobs. --max-time restarts it after N seconds. --memory makes it quit gracefully if it crosses a memory limit, before the kernel does it violently. A fresh process starts with a clean heap, so even a small residual leak never gets the hours it needs to matter.
What I actually changed my mind about
I used to think of a queue worker as "the same code as a controller, just triggered differently." It isn't. A controller lives for milliseconds and forgets everything; a worker lives for hours and forgets nothing unless you make it. Every convenience that relies on the process dying soon — the query log, a lazily-cached static, an unremoved listener — becomes a slow leak the moment the process stops dying.
So now I read job code with one extra question running: what does this keep? If the answer is anything that grows per job and isn't scoped to the job, that's the leak, sitting there, waiting for a long enough uptime to matter.
