DEV Community

Cover image for Laravel Queue Worker on cPanel Without Supervisor
Faizan
Faizan

Posted on Originally published at headsntail.com

Laravel Queue Worker on cPanel Without Supervisor

The Laravel docs tell you to keep queue:work running with Supervisor. On cPanel shared hosting you cannot install it, so emails, PDFs and imports sit in the jobs table and never run. This guide shows two ways to run a Laravel queue worker on cPanel without Supervisor: a cron job every minute that starts a worker and lets it exit, or the same worker started by Laravel's scheduler. Both stay inside CloudLinux limits if you set the options correctly.

Quick answer: use the database queue driver, then add one cPanel cron job that runs every minute: cd /home/USERNAME/app && /opt/cpanel/ea-php83/root/usr/bin/php artisan queue:work --stop-when-empty --max-time=50 --tries=3 --timeout=45 >/dev/null 2>&1. The worker processes what is waiting, exits, and cron starts a fresh one the next minute (add flock as shown below so two never overlap). If schedule:run is already in your crontab, schedule the same command with ->everyMinute()->withoutOverlapping(5) instead. Run one worker, keep --timeout several seconds below retry_after (90 by default), and run php artisan queue:restart after every deploy.

Why Supervisor is not an option on shared cPanel

Supervisor is a process monitor that restarts queue:work whenever it stops. The Laravel docs install it with sudo apt-get install supervisor and keep its config in /etc/supervisor/conf.d. Both need root, and a shared cPanel account has no root, no sudo and no way to register a systemd service.

Starting the worker by hand does not help either. cPanel's docs say Terminal sessions close shortly after you log out of cPanel, and commands still running in them only keep going until the session closes. A worker started with nohup or & may survive longer, but nothing restarts it when it crashes, it keeps running old code after a deploy, and it holds memory all day. Some hosts also kill long-running processes on shared plans.

On top of that, CloudLinux puts each account in an LVE with limits on memory, processes and concurrent entry processes. A permanent worker uses part of those limits around the clock, even when the queue is empty. The answer is a worker that runs for less than a minute, exits, and is started again by cron.

Choose the queue driver first: database, not sync

Open .env and check QUEUE_CONNECTION:

grep QUEUE_CONNECTION .env
Enter fullscreen mode Exit fullscreen mode

In new Laravel applications the default is database. Apps that started on Laravel 10 or older often still have sync, which was the old default. The sync driver runs each job immediately inside the web request that dispatched it. That is not a fix: the visitor waits for the job, a slow job holds an entry process and can hit PHP's execution time limit, and nothing is retried in the background.

Switch to the database driver:

QUEUE_CONNECTION=database
Enter fullscreen mode Exit fullscreen mode

If your app has no jobs table yet, create it. New apps already include it in the 0001_01_01_000002_create_jobs_table.php migration.

php artisan make:queue-table
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

On Laravel 10 and older the first command is php artisan queue:table. Redis is faster, but many shared plans do not offer it. If you cached config, run php artisan config:cache again after editing .env.

Method 1: a cPanel cron job that starts a worker and exits

Step 1: find the right PHP binary. A plain php in cron may not be the version your website uses, and /usr/bin/php on cPanel calls the CGI binary, not the command-line one. CloudLinux's knowledge base names the CGI binary as the reason PHP scripts hang or fail in cron. Open cPanel → Terminal (or SSH) and list what is installed:

ls -d /opt/cpanel/ea-php*/root/usr/bin/php /opt/alt/php*/usr/bin/php 2>/dev/null
/opt/cpanel/ea-php83/root/usr/bin/php -v
Enter fullscreen mode Exit fullscreen mode

EasyApache 4 installs each version at /opt/cpanel/ea-php##/root/usr/bin/php, where ## is the two-digit version (ea-php83 for PHP 8.3). If your host uses the CloudLinux PHP Selector, the binary is usually under /opt/alt/php##/usr/bin/php. Use the same version your site runs.

Step 2: check that PCNTL is loaded. The Laravel docs say the PCNTL extension is required for job timeouts. Without it, --timeout is not enforced.

/opt/cpanel/ea-php83/root/usr/bin/php -m | grep pcntl
Enter fullscreen mode Exit fullscreen mode

If nothing prints, enable pcntl in Select PHP Version → Extensions or ask the host.

Step 3: add the cron job. In cPanel → Cron Jobs, choose Once Per Minute (* * * * *) and paste this into Command, with your username and app folder:

cd /home/USERNAME/laravel-app && flock -n storage/framework/queue-worker.lock /opt/cpanel/ea-php83/root/usr/bin/php artisan queue:work --stop-when-empty --max-time=50 --tries=3 --timeout=45 --memory=128 >/dev/null 2>&1
Enter fullscreen mode Exit fullscreen mode

What each part does:

Part Purpose
cd /home/USERNAME/laravel-app Run from the folder that contains artisan
flock -n ...lock Skip this run if the previous worker still holds the lock
--stop-when-empty Exit once the queue is empty
--max-time=50 Exit after 50 seconds, even if jobs are left
--tries=3 Mark a job as failed after three attempts
--timeout=45 Kill a job that runs longer than 45 seconds
--memory=128 Exit after a job if the worker uses more than 128 MB (the default)
>/dev/null 2>&1 Discard output so cron sends no email

--max-time is checked between jobs, so a job that starts at second 49 keeps the worker alive until it finishes or hits --timeout. That is why the flock lock matters: without it, cron starts a second worker while the first is still busy. flock -n gives up at once instead of waiting. If your server has no flock, drop that part and use Method 2, whose withoutOverlapping() does the same job.

The cron job generator builds the schedule part of the line if you need something other than every minute.

Step 4: verify. Dispatch a test job (send a password reset email, for example). Within a minute the row should disappear from the jobs table in phpMyAdmin. From Terminal you can also check the queue size:

php artisan queue:monitor database:default
php artisan queue:failed
Enter fullscreen mode Exit fullscreen mode

While testing, swap >/dev/null 2>&1 for >> storage/logs/queue-cron.log 2>&1 to see errors, then switch back so the file does not grow forever.

From my own setup: on my own cPanel hosting I run Laravel's queue exactly this way: a cron job every minute that runs queue:work --stop-when-empty, so the worker drains the queue and exits. A shared plan gives you no Supervisor, so cron is the option that actually works there.

Method 2: run the worker from the Laravel scheduler

If you already have the schedule:run cron line, let the scheduler start the worker. The cron line from the Laravel docs, with a full PHP path:

cd /home/USERNAME/laravel-app && /opt/cpanel/ea-php83/root/usr/bin/php artisan schedule:run >/dev/null 2>&1
Enter fullscreen mode Exit fullscreen mode

The scheduler starts queue:work with the same PHP binary that runs schedule:run, so the full path here fixes both. Add the worker to routes/console.php (Laravel 11 and newer):

use Illuminate\Support\Facades\Schedule;

// other scheduled tasks above

Schedule::command('queue:work --stop-when-empty --max-time=50 --tries=3 --timeout=45')
    ->everyMinute()
    ->withoutOverlapping(5);
Enter fullscreen mode Exit fullscreen mode

On Laravel 10 and older, put the same $schedule->command(...) call in the schedule() method of app/Console/Kernel.php.

  • withoutOverlapping() stops a second worker while the first is still running. Laravel's default lock lasts 24 hours, so if the worker is killed and never releases it, the queue stops for a day. withoutOverlapping(5) makes the lock expire after 5 minutes.
  • The lock uses your cache store. New apps use the database cache store, which supports locks, so make sure the cache and cache_locks tables exist.

runInBackground() or not. Tasks due in the same minute run one after another, in the order you define them, so without runInBackground() the worker can delay every task defined after it by up to 50 seconds. Putting the worker last in routes/console.php avoids that with no extra processes. With ->runInBackground(), schedule:run exits at once and the worker runs as a separate process. The cost is an extra shell process and a schedule:finish call when the worker ends; if the worker is killed for memory, that call never runs and the lock stays until it expires. runInBackground() only works with command and exec tasks.

If schedule:run never fires, fix that first: see Laravel schedule:run not working on cPanel.

Pick one method, not both. Two cron-based workers on the same queue is two workers.

Stay inside CloudLinux limits

Your queue worker shares the same LVE as your website. These are the CloudLinux defaults and Namecheap's published shared plan limits as an example. Your host sets its own numbers, so check cPanel → Resource Usage.

Limit CloudLinux default Namecheap shared Stellar Plus Stellar Business
EP (entry processes) 20 20 30 40
PMEM (physical memory) 1024 MB 1 GB 2 GB 2 GB
NPROC (processes) 100 not listed not listed not listed

CloudLinux's limits table says EP usually means concurrent requests to dynamic scripts, "as well as SSH and cron jobs running simultaneously". NPROC counts every process and thread in the account. PMEM is all your processes together: at the limit CloudLinux first frees disk cache, then kills processes inside the LVE.

That is why you run one worker, not five. Each extra worker adds a PHP process with its own memory, a MySQL connection and possibly an entry process. If five workers stack up from overlapping cron jobs and each uses 150 MB, they take 750 MB of a 1 GB plan before any visitor arrives. Then pages can fail with 500 or 503 errors, new requests get a 508, and MySQL starts refusing connections with max_user_connections (the max_user_connections guide covers that error).

Settings that keep the worker small:

  • Keep --memory=128 or lower. The worker checks it after each job and exits cleanly if it is over.
  • Chunk big jobs: process 500 rows per job instead of 50,000 in one. The chaining queue batches guide shows how to split work into smaller jobs.
  • Use priorities instead of extra workers: --queue=high,default empties high first.

[YOUR EXPERIENCE: write 1-2 lines about running queue workers from cron on your own cPanel shared plan for your HR or payroll app: which method you use, what went wrong at first (stacked workers, a 508, memory kills) and which setting fixed it. Delete this paragraph if it never happened to you.]

Deploy, restart and check the worker

Long-lived workers keep the booted app in memory and do not notice code changes. A cron worker lives for about a minute, but it can still run old code right after a deploy. Add this to the end of your deploy steps:

php artisan migrate --force
php artisan optimize
php artisan queue:restart
Enter fullscreen mode Exit fullscreen mode

queue:restart tells running workers to exit after their current job. It stores the signal in the cache, so it does nothing if CACHE_STORE is array. With database or file it works. Cron starts a fresh worker with the new code the next minute. The full deploy order is in the Laravel on cPanel guide.

Check timeout against retry_after. In config/queue.php, the database connection has retry_after set from DB_QUEUE_RETRY_AFTER, 90 seconds by default. A job still running after that is released and can be picked up again. The Laravel docs say --timeout should always be at least several seconds shorter than retry_after, or jobs may run twice. --timeout=45 with retry_after 90 is safe. If a job really needs 120 seconds, set --timeout=120 and DB_QUEUE_RETRY_AFTER=150.

See what is running. In cPanel Terminal:

ps aux | grep "[q]ueue:work"
Enter fullscreen mode Exit fullscreen mode

You should see zero or one worker. More than one means two cron lines, both methods active, or a hand-started worker. Stop a stray one with kill PID, using the PID from the second column.

Common problems and fixes

Jobs stay pending forever. Cron is not reaching Laravel. Usual causes: wrong PHP path, a missing cd (cron starts in your home folder, so php artisan finds no artisan), or QUEUE_CONNECTION still on sync or pointing to another connection. Run the exact cron command in Terminal, without >/dev/null 2>&1, and read the error.

A job runs twice. --timeout is longer than retry_after, or PCNTL is missing so the timeout is never enforced. Fix the numbers or enable pcntl.

The worker is killed mid-job. The account hit PMEM, or the job went past PHP's memory_limit. Resource Usage shows PMEM faults at that minute. The job comes back after retry_after and lands in failed_jobs once it runs out of tries. Chunk it so each job uses less memory.

Your inbox fills with cron emails. Every-minute cron that prints anything sends about 1,440 emails a day. Add >/dev/null 2>&1 to the command, or clear the address under Cron Email in cPanel.

.env changes are ignored. The running worker loaded the old config, and cached config ignores .env completely. Run php artisan config:cache and then php artisan queue:restart.

Jobs wait up to a minute. That is the trade-off of --stop-when-empty: a job dispatched after the worker exits waits for the next cron run. If you need instant processing, move to a VPS with a real process monitor.

FAQ

Can I run queue:work forever on shared hosting?

Usually not reliably. Without Supervisor nothing restarts the worker when it crashes, hits its memory limit or gets killed by the host, and a permanent process uses your LVE memory all day. A cron-started worker that exits every minute does the same job and recovers on its own.

Is queue:listen better on cPanel?

No. queue:listen reloads your code without a restart, but it starts a new queue:work --once process for each job, and the Laravel docs call it significantly less efficient than queue:work. It is also meant to run permanently, which has the same problem as above.

How many queue workers fit on a 20 EP plan?

One is the right number for most shared plans. CloudLinux counts cron jobs running at the same time toward EP, so every worker takes a slot from visitors, and memory usually runs out before EP does. Add a second worker only if the queue keeps growing and Resource Usage shows plenty of free memory and EP.

Does the database queue driver slow down MySQL?

Not noticeably at shared hosting volumes. A worker runs a small locking query on the jobs table, and Laravel uses FOR UPDATE SKIP LOCKED on MySQL 8.0.1+ and MariaDB 10.6+, so workers do not block each other. With --stop-when-empty the worker exits instead of polling an empty table every few seconds. Prune failed_jobs now and then so the tables stay small.

Sources


Originally published at headsntail.com. Free tools for PHP, Laravel and cPanel work: headsntail.com/tools.

Top comments (0)