Laravel ships with an elegant task scheduler and a queue system, but both were designed with a persistent server in mind. The documentation assumes you can add one cron entry calling schedule:run every minute and keep a queue:work process running forever through a supervisor daemon. On managed shared hosting you have neither root, SSH supervisor control, nor the ability to edit /etc/ or install Supervisor. The good news is that every piece of the puzzle can be reconstructed using the cron manager inside cPanel Jupiter or DirectAdmin Evolution, the correct CloudLinux PHP CLI path, and a queue driver that does not depend on a live worker. Understanding why the default advice fails is the fastest route to a configuration that actually runs.

Why the default scheduler advice fails on shared hosting

The Laravel scheduler is not a daemon. The single system cron entry that triggers php artisan schedule:run every minute is what makes it work, and Laravel internally decides which of your defined tasks are due in that minute. When scheduled tasks never fire on shared hosting, the cause is almost always one of three things: the cron job was never created, it points at the wrong PHP binary, or it runs from the wrong working directory so the framework cannot find artisan and your .env.

The PHP binary issue is the most common and the most confusing. CloudLinux uses the alt-php system, so the php that a bare cron command resolves to is frequently an old default or a CGI wrapper that behaves differently from the version your site runs through LiteSpeed. If your application targets PHP 8.3 but cron silently calls PHP 7.4, Composer autoloading and framework bootstrapping throw fatal errors that land in a log you never see. You must call the exact CLI binary that matches the version you selected in MultiPHP Manager (cPanel) or PHP Selector (DirectAdmin/CloudLinux).

Queues are a different problem entirely. A queue worker is a long-running loop, and shared hosting process managers kill lingering CLI processes to protect resources. A queue:work command started manually will be reaped within minutes, leaving jobs stuck in the jobs table. The workable pattern is to let the per-minute scheduler cron drain a bounded number of jobs and exit cleanly, so you never rely on a process staying alive. This trades a few seconds of latency for reliability, which is the correct compromise on a non-root account.

Building the correct cron entry in cPanel and DirectAdmin

First, confirm the active PHP version. In cPanel open MultiPHP Manager, locate your domain, and note the version (for example ea-php83). In DirectAdmin the equivalent is under Select PHP Version. The matching CLI binaries live at predictable paths. On cPanel with EasyApache the CLI is typically /opt/cpanel/ea-php83/root/usr/bin/php. On CloudLinux PHP Selector (common in DirectAdmin) the per-user alt-php binary is at /opt/alt/php83/usr/bin/php. If you are unsure, create a temporary cron job that writes the version to a file:

/opt/cpanel/ea-php83/root/usr/bin/php -v > /home/USERNAME/php_check.txt 2>&1

Check php_check.txt in File Manager, then delete the temporary job. Once you have a working binary, add the real scheduler entry. In cPanel go to Cron Jobs, set the schedule to Once Per Minute (* * * * *), and enter the command. In DirectAdmin the panel is Advanced Features → Cron Jobs with the same five-field timing.

* * * * * /opt/cpanel/ea-php83/root/usr/bin/php /home/USERNAME/laravel/artisan schedule:run >> /home/USERNAME/laravel/storage/logs/cron.log 2>&1

Replace USERNAME with your account user and the path with your real project root. Two details matter. The command references artisan by absolute path, which sets the working directory implicitly through Laravel's bootstrap, so you do not need a separate cd. The redirection appends both standard output and errors to a log inside storage/logs, giving you a place to read failures that the panel would otherwise discard.

If you prefer to be explicit about the directory, a chained form is equally valid and sometimes clearer when debugging relative paths:

* * * * * cd /home/USERNAME/laravel && /opt/alt/php83/usr/bin/php artisan schedule:run >> storage/logs/cron.log 2>&1

Avoid setting the cron to run more frequently than once per minute; Laravel expects exactly one invocation per minute and schedules sub-minute granularity itself. Multiple overlapping panel entries will double-fire your tasks. Define everything inside the schedule method of app/Console/Kernel.php (or bootstrap/app.php on Laravel 11+) and let one cron drive all of it.

Running queues without a persistent worker

Because you cannot keep a worker alive, switch to a driver that stores jobs durably and drain them from the scheduler. The database driver is the best fit on shared hosting: jobs live in a MySQL table, so nothing is lost if a process is killed. Set QUEUE_CONNECTION=database in .env, then make sure the jobs tables exist. If you cannot run migrations over SSH, you can import the table definitions through phpMyAdmin → Import, or run the queue migration once via a one-off cron job pointing at artisan migrate. The required tables are jobs, job_batches, and failed_jobs.

Rather than calling queue:work (which loops forever), register a bounded drain inside your scheduler so it rides on the per-minute cron you already created:

// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
    $schedule->command('queue:work --stop-when-empty --max-time=50 --tries=3')
             ->everyMinute()
             ->withoutOverlapping();
}

The --stop-when-empty flag makes the worker exit as soon as the queue is drained, and --max-time=50 guarantees it never runs past the one-minute window even under load. The withoutOverlapping guard prevents a second minute from launching a duplicate worker while the first is still processing, using Laravel's cache-based mutex. This pattern keeps every process short-lived and well within shared hosting limits, while still processing queued mail, notifications, imports, and event listeners within roughly a minute.

After deploying, clear and rebuild caches so cron sees your new schedule and config. You can do this with one-off cron entries or through a File Manager-triggered route if you have one, running artisan config:clear followed by artisan config:cache. Stale config cache is a frequent reason a changed QUEUE_CONNECTION appears to be ignored.

Verifying output and reading the right logs

Confirm the scheduler is firing by checking storage/logs/cron.log in File Manager a few minutes after saving the cron job. An empty file means the job runs cleanly; a populated file usually shows the exact fatal error, most often a wrong PHP version or a permissions problem on storage. Laravel needs storage and bootstrap/cache to be writable; set them to 755 (or 775 if your account requires group write) through File Manager's permission dialog.

For queue diagnostics, inspect the database directly in phpMyAdmin. Rows accumulating in the jobs table that never clear mean the drain command is not running or is erroring out; rows in failed_jobs mean jobs are being attempted but throwing exceptions, and the exception column holds the stack trace. Pair that with storage/logs/laravel.log for application-level detail. If notifications or imports fire late rather than never, that is expected behaviour with the per-minute drain and not a fault.

One last check prevents a silent failure: verify that your host has not disabled the PHP CLI functions Laravel needs. If proc_open or exec are listed in disable_functions, schedule:run can still work but withoutOverlapping and nested command dispatch may misbehave. You can review the active CLI configuration in your temporary php -v or php -i output and, where the panel allows, adjust the PHP options through PHP Selector → Options or a .user.ini in your project root. With the right binary, a single per-minute cron, and a database-backed drain, Laravel's background processing runs dependably inside the limits of an unprivileged hosting account.