Skip to content
Engineering notes

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:work boots 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 --once boots the app, processes a single job, and dies.
  • php artisan queue:listen runs queue:work --once inside 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 --once mode, 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:

  1. Check if the worker should run (not in maintenance mode, not paused, no listeners returning false).
  2. If it should not run, sleep and check again.
  3. Pull the next available job from the queue storage.
  4. Register a timeout alarm so the process gets killed if the job takes too long.
  5. Run the job.
  6. 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 --force was passed).
  • Worker is not paused.
  • No event listener returned false from the Looping event.

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:

  1. $this->shouldQuit: set by a SIGTERM signal or a lost database connection.
  2. Memory exceeded: if the worker's memory usage passes the --memory limit, it stops. Memory leaks in long-running processes are real; this is the safety valve.
  3. 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:work boots the app once and stays alive: efficient but needs manual restarting after code changes.
  • queue:listen boots 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_alarm to kill hung jobs, on PHP 7.1+ only.
  • Memory limits and restart signals keep long-running workers from degrading over time.
  • The Looping event gives you a hook to pause processing during specific conditions.