Long-running queue workers process hundreds of jobs without restarting the PHP process. That persistent runtime makes performance great. It also introduces a subtle trap. Any object held in static properties, or any query result left lingering in memory across jobs, accumulates quietly until the operating system steps in with an out-of-memory kill. You'll watch your daemon worker vanish from process lists with no exception logged. Your payload remains stranded in the queue.
Understanding a laravel queue worker memory leak requires looking closely at how PHP manages memory under the hood. When a standard web request finishes, PHP flushes everything. Daemon workers don't do that. They boot the framework once, containerize your application state, and loop indefinitely. If your job handler builds a large Eloquent collection or attaches deep relation trees, those models stay in memory after the job completes. The next job runs, allocates more memory on top of the old footprint, and eventually breaches your configured limit.
You can monitor these memory spikes locally by running your workers with verbose flags. Production environments often reveal leaks under heavy traffic loads that never appeared in staging. If you aren't careful, simple reporting jobs or data migration tasks will quietly devour your server resources. For related data migration challenges, you might want to review Safe, resumable data backfills for Laravel to see how to handle massive datasets safely without crashing your database or worker nodes.
Chunking Database Results Inside Batch Jobs
The most common culprit is loading entire database tables into a single collection inside a queued job. If you need to process ten thousand user records, fetching them with User::all() will immediately strain your worker. Instead, use cursor pagination or lazy collections to stream rows through memory one small batch at a time. Eloquent models are heavy objects. They store original attributes, relationships, and dirty tracking states. When you pull thousands of them into an array or a standard collection, the garbage collector often struggles to reclaim that memory between queue iterations.
Here is how you should iterate over large record sets using lazy collections to keep memory usage flat:
<?php
namespace App\Jobs;
use App\Models\User;
use App\Notifications\AccountDeactivationNotice;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
class ProcessLargeDatasetJob implements ShouldQueue
{
use Queueable;
public function handle(): void
{
User::where('active', false)->cursor()->each(function (User $user) {
$user->notify(new AccountDeactivationNotice());
$user->update(['notified_at' => now()]);
});
}
}
Using the cursor() method instructs Laravel to execute a generator-backed query that yields one model instance at a time rather than loading an entire result set into RAM. It's a small syntax adjustment that completely changes the memory profile of your worker. If your processing logic involves complex queries or raw performance tuning, you can also explore Query Builder vs Eloquent: Real Cost in Laravel to decide when to drop down to the query builder for even lower overhead.
Managing Garbage Collection and Worker Limits
Even with lazy collections, long-running processes can suffer from memory fragmentation within the underlying Zend Engine. PHP doesn't always return freed memory directly back to the operating system immediately. To combat this, you should configure sensible limits when starting your worker via Artisan. The --memory option tells the daemon to gracefully exit once it hits a specific threshold. This allows your process manager like Supervisor to spin up a fresh instance with a clean slate.
Don't rely solely on operating system limits that trigger abrupt segmentation faults. Instead, configure your deployment scripts to set explicit boundaries:
php artisan queue:work redis --memory=512 --timeout=300 --tries=3
When the worker reaches 512 megabytes of allocated RAM, it finishes its current job and exits with a status code that signals the process supervisor to restart it. That brief restart overhead is vastly preferable to an unhandled crash that leaves jobs stuck in an unreserved state. If you are also dealing with concurrent workers accessing the same resources or processing overlapping batches, you should look into Laravel Queue Locks: ShouldBeUnique vs WithoutOverlapping to prevent race conditions and duplicate execution.
Cleaning Up Event Listeners and Service Containers
Another subtle source of memory bloat is registering listeners or bindings inside long-running loops or singleton services that accumulate state. If your job interacts with custom singleton classes that store references to processed items in an internal array, that array grows indefinitely across every job handled by that worker process. You'll need to inspect any custom service providers or application bindings to ensure they don't hold stateful references.
Always flush or reset service container bindings if your job registers temporary contextual instances. When you dispatch hundreds of jobs through a single worker daemon, every static cache, global log array, and event listener closure stays resident in memory. Keeping your job classes stateless, utilizing lazy database cursors, and enforcing strict memory caps via command-line options will keep your production queue humming along without unexpected outages.













