Vivere

Laravel scheduler

Everything scheduled hangs off one crontab line. One package monitors every task, or use the built-in ping methods.

Everything in Schedule hangs off a single crontab line. If that line is lost in a server rebuild, or php moves after an upgrade, or the application is in maintenance mode, every scheduled task stops at once and Laravel has nowhere to report it from.

* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1

Every task at once, with the package

vivere/laravel monitors every task in your schedule without touching each one. It creates one monitor per task with the task's own cron expression and timezone, and reports each run from the scheduler's own events. Laravel 12 and 13.

composer require vivere/laravel

Add an API key from your keys page to .env, then create the monitors. Run the second command on each deploy; tasks that already have a monitor are left alone.

VIVERE_API_KEY=vv_...

php artisan vivere:sync

From then on every run sends a start ping and then success, its exit code, or a fail with the exception, with the end of the task's output when it writes to a file. A run that withoutOverlapping() skips sends nothing, so a hung task shows up as a missed run. To set a task's grace, opt one out, or report to a monitor you made yourself:

Schedule::command('backup:run')->dailyAt('02:00')->vivere(grace: '30m');
Schedule::command('telescope:prune')->daily()->withoutVivere();
Schedule::command('invoices:send')->hourly()->vivere(monitor: 'your-monitor-id');

php artisan vivere:list shows each task, its monitor and the monitor's state. The README covers the rest: closures need a ->name(), schedules that change, several servers and several apps in one account.

Laravel pings without a package

The scheduler has ping methods built in. They need Guzzle (composer require guzzlehttp/guzzle), which most applications already have.

// routes/console.php on Laravel 11 and later
use Illuminate\Support\Facades\Schedule;

Schedule::command('backup:run')
    ->dailyAt('02:00')
    ->pingBefore(config('services.vivere.ping').'/start')
    ->pingOnSuccess(config('services.vivere.ping'))
    ->pingOnFailure(config('services.vivere.ping').'/fail');

On Laravel 10 and earlier the same calls go in the schedule() method of app/Console/Kernel.php, as $schedule->command(...).

Put the URL in config/services.php rather than calling env() directly, so it survives php artisan config:cache:

'vivere' => ['ping' => env('VIVERE_PING_URL')],

Closures and queued jobs

Schedule::call(function () {
    Report::generate();
})->weeklyOn(1, '08:00')->thenPing(config('services.vivere.ping'));

Schedule::job(new SendDigest)->hourly()->thenPing(config('services.vivere.ping'));

thenPing on a queued job reports that the job was dispatched, not that it ran. If the queue worker is the part you are worried about, ping from inside the job's handle() instead.

Monitor settings

Use the task's schedule as a cron expression with the application timezone. Two Laravel behaviours to allow for in the grace: withoutOverlapping() silently skips a run while the previous one still holds the lock, and a task marked onOneServer() only runs where the cache lock is won, so the run can start on a machine with a slower disk than the one you timed. The calculator takes the worst normal duration and gives you a figure.