Setting up the Laravel scheduler on a server, and fixing it when it stops

The one crontab line Laravel needs, which user and PHP binary it must use, why it runs by hand but not from cron, and how it fits a release layout.

Jacob Søndergaard ·

Laravel's scheduler needs exactly one cron entry: run php artisan schedule:run every minute, and let Laravel decide what is due. When the scheduler "does not run" on a server, the cause is nearly always in that one line: the wrong user, the wrong PHP, the wrong directory, or output that goes nowhere so nobody sees the error.

The server is the one used across the series. The app lives in /var/www/example.com with a current symlink to the live release, set up in the zero-downtime deploy post, and runs as a site account called example. This post sets up the entry, then works through the reasons it stops. Tested on Ubuntu 26.04 with PHP 8.5 and 24.04 with PHP 8.3; on 24.04 write 8.3 where the commands say 8.5.

The Laravel scheduler crontab line

* * * * * cd /var/www/example.com/current && /usr/bin/php8.5 artisan schedule:run >> /dev/null 2>&1
  • * * * * * runs it every minute. Laravel's own frequencies (hourly(), dailyAt('02:00')) are checked inside schedule:run, so the cron schedule never changes.
  • cd /var/www/example.com/current. The live release, through the symlink. Each run starts in whichever release is live at that minute.
  • /usr/bin/php8.5. The PHP version the site runs, by absolute path. Plain php may be another version.
  • >> /dev/null 2>&1. Discards the output. That is fine once it works. While it does not, send it to a file instead, as the checklist below shows.

Install it in the site account's crontab. sudo crontab -u example -e opens an editor; this does the same without one and prints the result:

(sudo crontab -u example -l 2>/dev/null || true; echo '* * * * * cd /var/www/example.com/current && /usr/bin/php8.5 artisan schedule:run >> /dev/null 2>&1') | sudo crontab -u example -
sudo crontab -u example -l

schedule:list shows what Laravel thinks is scheduled and when it runs next. If your task is not in the list, cron is not the problem.

$ sudo -u example php8.5 /var/www/example.com/current/artisan schedule:list

  * * * * *  tick .............................. Next Due: 19 seconds from now

When nothing is due, schedule:run prints No scheduled commands are ready to run. That is the normal output for most minutes, not an error.

Which user's crontab

The site account's, the one that owns the code and runs PHP-FPM. Two other choices look reasonable and are not:

  • root. The scheduler runs and works, until a task writes a log line. Laravel creates laravel.log owned by root, and from then on every web request and queue job that logs fails with:
The stream or file "/var/www/example.com/releases/20261021114934/storage/logs/laravel.log"
could not be opened in append mode: Failed to open stream: Permission denied
  • www-data. In this layout shared/.env is mode 600 and owned by the site account, so www-data cannot read it.

If you prefer system files to crontab -e, an entry in /etc/cron.d takes the user as a sixth field:

# /etc/cron.d/example-scheduler: root-owned, mode 644, no dots in the file name
* * * * * example cd /var/www/example.com/current && /usr/bin/php8.5 artisan schedule:run >> /dev/null 2>&1
echo '* * * * * example cd /var/www/example.com/current && /usr/bin/php8.5 artisan schedule:run >> /dev/null 2>&1' | sudo tee /etc/cron.d/example-scheduler > /dev/null

Ubuntu's cron ignores files in /etc/cron.d whose names contain a dot, so example.com-scheduler would never run and nothing would say why.

The PHP binary cron finds

Cron runs commands with /bin/sh and a minimal PATH of /usr/bin:/bin. /usr/bin/php is a link managed by update-alternatives, and installing a second PHP from ondrej/php or deb.sury.org can move it to that version. The site keeps running on its FPM version, the scheduler silently moves to the other one, and a task that needs an extension you only installed for one version starts failing.

readlink -f "$(command -v php)"      # what cron's PATH finds
php8.5 -v | head -1                 # what you want
ls /usr/bin/php*

Naming /usr/bin/php8.5 in the crontab removes the question. Commands you schedule with Schedule::command() run as child processes with the same binary as schedule:run, so pinning it once covers them too.

Paths in a releases/current layout

Point cron at current, never at a release directory, or it will keep running the code from the day you wrote the crontab. Because cd resolves the symlink when each run starts, every minute picks up the live release on its own, and a deploy needs no change to cron. On the test server the task logged the release it ran in, and the name changed on the first run after each deploy.

A long task that started before a deploy finishes in the old release. The deploy script keeps the five newest releases, so the directory is still there when it does.

Laravel scheduler not running: a checklist

Work down this list in order. Each step rules out one layer.

  1. Is cron running, and did it run your line? The journal records every run, with the user in brackets:
    systemctl status cron
    journalctl -u cron --since '10 min ago' | grep example
    (example) CMD (cd /var/www/example.com/current && /usr/bin/php8.5 artisan schedule:run >> /dev/null 2>&1)
    pam_unix(cron:session): session closed for user example
    No CMD line means cron never ran it: wrong crontab, a typo in the schedule, or an /etc/cron.d file with a dot in its name.
  2. Is it in the crontab you think? sudo crontab -u example -l. Check root's too, with sudo crontab -l, in case it was added there by mistake.
  3. Does it work by hand, as that user? Run the command exactly as cron would, without the redirect, so you see the error:
    sudo -u example sh -c 'cd /var/www/example.com/current && /usr/bin/php8.5 artisan schedule:run'
  4. Where did the output go? Without a redirect, cron tries to email the output. A server with no mail transfer agent throws it away and logs only this:
    (CRON) info (No MTA installed, discarding output)
    While debugging, append to a file in shared/storage/logs instead:
    * * * * * cd /var/www/example.com/current && /usr/bin/php8.5 artisan schedule:run >> /var/www/example.com/shared/storage/logs/scheduler.log 2>&1
  5. Is the app in maintenance mode? After php artisan down, every run reports No scheduled commands are ready to run. until artisan up, unless a task has evenInMaintenanceMode().
  6. Is a task stuck behind its own lock? A task with withoutOverlapping() that was killed mid-run leaves its lock behind, and the lock lasts 24 hours by default. Clear it:
    sudo -u example php8.5 /var/www/example.com/current/artisan schedule:clear-cache
  7. Is it the time zone? dailyAt('02:00') means 02:00 in the app's time zone, the timezone in config/app.php, which is UTC in a new app. The server's time zone does not matter. schedule:list shows the next run time.
  8. Did it run as root at some point? Look for files owned by root in shared/storage and give them back with sudo chown -R example:example /var/www/example.com/shared/storage.

One more, if you write your own cron lines: cron treats % as a newline, so a date +%F in the command has to be written date +\%F.

withoutOverlapping and onOneServer

withoutOverlapping() skips a run while the previous one is still going. onOneServer() runs a task on only one of several servers that share the schedule. Both take a lock in the cache, so the cache store must be one every server can reach (database, Redis, Memcached or DynamoDB), and a closure needs a name() to lock on. The task used to test this post:

routes/console.php
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    Log::info('scheduler tick in '.basename(base_path()));
})->everyMinute()->name('tick')->withoutOverlapping()->onOneServer();

onOneServer() has a side effect that confuses people testing by hand: run schedule:run yourself in a minute cron has already handled, and Laravel skips the task, even on a single server.

   INFO  Skipping [tick] because the command already ran on another server.

That is the lock working, not the scheduler failing. Wait for the next minute or use schedule:test.

schedule:run vs schedule:work

schedule:work stays in the foreground and runs the schedule every minute itself, with no cron involved:

$ sudo -u example php8.5 /var/www/example.com/current/artisan schedule:work
  2026-10-21 11:56:00 Running [tick] ............................. 6.54ms DONE

It is for local development and for containers that have no cron. On a server it is the worse choice. It is a long-running process, so it needs supervising like a queue worker, and it holds the release it started in, so every deploy has to restart it. Cron starts a fresh process every minute and needs neither. The scheduling documentation covers the task frequencies and the rest of the API.

What Provisore does here

Creating a Laravel site in Provisore creates its scheduler entry. It is written to /etc/cron.d, tied to the site, and runs php artisan schedule:run as the site's own account, in the current release, with the site's pinned PHP first on the path. You can see and remove it under the server's scheduled tasks; removing it stops every Schedule:: line in the repository, so delete it only if the site schedules nothing. The Laravel runtime and Laravel page list what else a site gets.

Run it on a server you own

Provisore provisions the server, deploys on push and keeps watch, on your own provider account.