jiordiviera/laravel-smart-scheduler
A Laravel package for smarter task scheduling: manage scheduled jobs with dynamic frequencies, overlap prevention, condition-based runs, and clear logging. Simplify cron management and keep recurring tasks predictable, organized, and easy to monitor.
Upgrade and publish what changed
composer require <vendor>/smart-scheduler:^1.1
php artisan vendor:publish --tag=smart-scheduler-config # pick up stuck_timeout_minutes + notify_on_stuck
If your config file was published previously it will not be overwritten — diff it against the package's config/smart-scheduler.php and add the two new keys manually, otherwise config('smart-scheduler.stuck_timeout_minutes') resolves to null and detection silently does nothing useful.
Run the new migration if one ships with the release. STATUS_STUCK needs to be storable; if status is an enum column, confirm the migration adds the value before you deploy code that writes it.
Look here first:
config/smart-scheduler.php → stuck_timeout_minutes (default 60), notify_on_stuck.SmartSchedulerManager → the new pre-flight stuck sweep that runs before the overlap check.App\Models\...\ScheduleRun → isStuck().SmartNotifierInterface → sendStuckNotification().First run — always dry:
php artisan smart-scheduler:detect-stuck --timeout=30 --dry-run
Confirm the rows it would mark match your expectations (timezone, long-running imports, queue-backed jobs) before letting it write.
Schedule it once you're happy — this is the intended steady state:
// routes/console.php (Laravel 11+) or Console\Kernel::schedule()
Schedule::command('smart-scheduler:detect-stuck')->everyFiveMinutes();
Tune the threshold to your slowest legitimate job, not your average. stuck_timeout_minutes is a single global value. If you have a nightly 90-minute report, a 60-minute default will flag it every night. Either raise the global default and accept slower detection for short jobs, or run the command with an explicit --timeout on a separate schedule entry per task group:
Schedule::command('smart-scheduler:detect-stuck --timeout=15 --no-notify')
->everyFiveMinutes()
->environments(['production']);
Schedule::command('smart-scheduler:detect-stuck --timeout=180')
->hourly()
->environments(['production']);
Let the manager handle the common case. Since SmartSchedulerManager now sweeps for stuck tasks before the overlap check, the most valuable pattern is simply not fighting it: your next scheduled run will self-heal stale rows. The standalone command exists for the case where nothing else is scheduled to run — a once-daily report, a cron that's been disabled — and the row would otherwise sit in RUNNING forever.
Wire isStuck() into your observability surface rather than re-deriving the logic:
ScheduleRun::query()
->where('status', ScheduleRun::STATUS_RUNNING)
->get()
->filter->isStuck()
->each(fn ($run) => $run->schedule->name);
That keeps the definition of "stuck" in one place as the package evolves, instead of a second where('started_at', '<', now()->subMinutes(...)) in your dashboard.
Custom notifiers must be updated. If you implemented SmartNotifierInterface (Slack, PagerDuty, etc.), add sendStuckNotification():
public function sendStuckNotification(ScheduleRun $run): void
{
Slack::send(":warning: {$run->schedule->name} stuck since {$run->started_at}");
}
Prefer implementing the full contract over adding the method ad hoc — a class ... implements SmartNotifierInterface that's missing the method is a fatal error at container resolution, not a graceful degradation.
Use --no-notify in non-production and in CI. Detection is a state change; notification is a side effect. Running detection on staging shouldn't page anyone.
Dry-run in a deploy step / health check to surface drift without mutating state:
$stuck = Artisan::call('smart-scheduler:detect-stuck', ['--dry-run' => true, '--no-notify' => true]);
sendStuckNotification() was added to SmartNotifierInterface. The release note files this under "Changed", but any third-party or in-house notifier implementing that contract will fatal on composer update. Grep your codebase (and any internal packages) for SmartNotifierInterface before upgrading.detect-stuck command — they cover different failure modes.notify_on_stuck, and consider throttling in your custom notifier (e.g. only notify on transition into stuck, not on every detection pass).match expressions over status, whereIn('status', [...]), chart legends, and any enum cast will now see an unexpected value. A match without a default arm throwing UnhandledMatchError is the classic symptom.now(). If app.timezone differs from the DB server's time or you're mid-DST, you'll get false positives. Test with Carbon::setTestNow() rather than waiting an hour.--dry-run --timeout=<n> with a deliberately large n first; if the row disappears, it's a timeout-tuning problem, not a bug. If it persists, inspect started_at — a null or zeroed timestamp will look infinitely stuck.STATUS_STUCK rows exist leaves rows in a status the old code doesn't understand. Have a one-liner ready to normalise them (update ... set status = 'failed' where status = 'stuck') before reverting.--timeout per group — cheaper than forking, and it keeps you on the upgrade path.How can I help you explore Laravel packages today?