Kennedy Mutisya
Queue Workers in Laravel: How They Work
Trace what happens after `php artisan queue:work`: daemon loops, signals, timeouts, and how jobs are fetched and processed.
A queue worker is a PHP process that runs in the background, picks jobs from storage, and executes them according to your configuration. Let us trace how it all happens from the moment you type php artisan queue:work.
queue:work vs queue:listen
There are two commands for running workers, and the difference matters.
php artisan queue:workboots your Laravel application once and keeps the same instance alive indefinitely to process jobs. This is efficient, since you are not rebuilding the framework on every job, but it also means you need to restart the worker manually after any code change for the change to take effect.php artisan queue:work --onceboots the app, processes a single job, and dies.php artisan queue:listenrunsqueue:work --onceinside an infinite loop. Every job gets a fresh application instance. Code changes are picked up automatically because each iteration is a new process, but you pay the price of booting the framework on every single job.
Inside queue:work
The handle() method of Queue\Console\WorkCommand is where it starts.
Maintenance Mode
If the app is in maintenance mode and --once is set, the worker just sleeps for the configured duration and dies gracefully:
if ($this->downForMaintenance() && $this->option('once')) {
return $this->worker->sleep($this->option('sleep'));
}
Without that sleep, a supervising process (like Supervisor) would restart the worker instantly on every loop iteration, creating a tight restart cycle that burns CPU for nothing.
Event Listeners
The command registers listeners for three events right before the main loop starts:
$this->laravel['events']->listen(JobProcessing::class, function ($event) {
$this->writeOutput($event->job, 'starting');
});
$this->laravel['events']->listen(JobProcessed::class, function ($event) {
$this->writeOutput($event->job, 'success');
});
$this->laravel['events']->listen(JobFailed::class, function ($event) {
$this->writeOutput($event->job, 'failed');
$this->logFailedJob($event);
});
Failed jobs are logged via the queue.failer service, which by default stores the failure info (connection name, queue name, raw payload, and exception) in a failed_jobs database table.
Running the Worker
The runWorker() method collects the connection and queue options, sets a cache driver, then decides between two code paths:
runNextJob(): used in--oncemode, runs one job and exits.daemon(): used in persistent mode, keeps running.
The Daemon Loop
The Worker::daemon() method is where persistent workers live.
Signal Handling
If PHP 7.1+ with the pcntl extension is available, the worker registers async signal handlers:
pcntl_signal(SIGTERM, function () {
$this->shouldQuit = true;
});
pcntl_signal(SIGUSR2, function () {
$this->paused = true;
});
pcntl_signal(SIGCONT, function () {
$this->paused = false;
});
- SIGTERM tells the worker to shut down (sent by Supervisor when you restart).
- SIGUSR2 pauses the worker.
- SIGCONT resumes a paused worker.
The Main Loop
The loop does this every iteration:
- Check if the worker should run (not in maintenance mode, not paused, no listeners returning false).
- If it should not run, sleep and check again.
- Pull the next available job from the queue storage.
- Register a timeout alarm so the process gets killed if the job takes too long.
- Run the job.
- Check if the worker needs to stop (SIGTERM received, memory limit hit, restart requested).
while (true) {
if (! $this->daemonShouldRun($options, $connectionName, $queue)) {
$this->pauseWorker($options, $lastRestart);
continue;
}
$job = $this->getNextJob(
$this->manager->connection($connectionName), $queue
);
$this->registerTimeoutHandler($job, $options);
if ($job) {
$this->runJob($job, $connectionName, $options);
} else {
$this->sleep($options->sleep);
}
$this->stopIfNecessary($options, $lastRestart);
}
Checking if the Worker Should Run
daemonShouldRun() checks:
- Application is not in maintenance mode (unless
--forcewas passed). - Worker is not paused.
- No event listener returned
falsefrom theLoopingevent.
That last check is useful: you can register a Looping listener that returns false to temporarily pause processing under certain conditions, like during a deployment window.
Fetching the Next Job
getNextJob() loops through the queues (you can specify multiple comma-separated queues) and asks the queue connection to pop a job:
foreach (explode(',', $queue) as $queue) {
if (! is_null($job = $connection->pop($queue))) {
return $job;
}
}
The actual query looks for the oldest job that belongs to the target queue, is not already reserved, is available to run (not delayed), or has been reserved so long it may have frozen (retry timeout). Once found, the job is marked as reserved and its attempt count is incremented.
Monitoring Timeouts
If async signals are supported, the worker uses pcntl_alarm() to send a SIGALRM after the timeout period. If the job finishes in time, the next loop iteration resets the alarm. If the job hangs, the alarm fires and kills the process, and Supervisor starts a fresh one.
$timeout = $this->timeoutForJob($job, $options);
pcntl_alarm($timeout > 0 ? $timeout : 0);
Only one alarm can be active per process, so the next job's alarm replaces the previous one.
Processing a Job
process() fires the JobProcessing event, checks whether the job has already exceeded max attempts (and marks it failed if so), then calls $job->fire():
$this->raiseBeforeJobEvent($connectionName, $job);
$this->markJobAsFailedIfAlreadyExceedsMaxAttempts($connectionName, $job, (int) $options->maxTries);
$job->fire();
$this->raiseAfterJobEvent($connectionName, $job);
The $job object returned by getNextJob() is an implementation of Contracts\Queue\Job, for example, Queue\Jobs\DatabaseJob when using the database queue driver.
End-of-Loop Checks
After every job, stopIfNecessary() checks three things:
$this->shouldQuit: set by a SIGTERM signal or a lost database connection.- Memory exceeded: if the worker's memory usage passes the
--memorylimit, it stops. Memory leaks in long-running processes are real; this is the safety valve. - Queue restart requested: compares the stored restart timestamp against the current value. If they differ, someone ran
php artisan queue:restart. The worker stops so it will be restarted with fresh code.
Key Takeaways
queue:workboots the app once and stays alive: efficient but needs manual restarting after code changes.queue:listenboots the app on every job: picks up code changes automatically but costs more resources.- Signal handling (SIGTERM, SIGUSR2, SIGCONT) lets Supervisor orchestrate the worker lifecycle.
- Timeouts use
pcntl_alarmto kill hung jobs, on PHP 7.1+ only. - Memory limits and restart signals keep long-running workers from degrading over time.
- The
Loopingevent gives you a hook to pause processing during specific conditions.