Laravel Task Scheduling and Cron: A Practical Guide

How Laravel's scheduler works with system cron, how to write the right expressions, and how to keep scheduled jobs reliable in production.

Laravel's scheduler is one of the framework's best ideas: instead of scattering crontab entries across servers, you declare every recurring task in code and let a single system cron entry drive them. That simplicity hides a few sharp edges — overlapping runs, timezone drift, and jobs that fail silently for weeks.

This guide covers the mental model, the expressions, and the production hygiene that keeps scheduled work trustworthy. To build or verify a schedule expression, use the cron expression generator — it outputs both raw cron and the matching Laravel scheduler method.

Key takeaways

  • One system cron entry runs schedule:run every minute; Laravel decides what is due.
  • Always add withoutOverlapping() to anything that can run long.
  • Set the schedule timezone explicitly — do not inherit it from the server.
  • Failing silently is the default. Add onFailure(), monitoring, or a heartbeat ping.

How the two layers fit together

System cron is the heartbeat. Laravel is the brain.

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

That single line runs every minute. schedule:run then evaluates every task you defined and executes only those due right now. This means you never edit the crontab again when adding a task — you edit your application code, which is versioned, reviewed and deployed like everything else.

Reading a cron expression

A standard expression has five fields:

┌───── minute (0-59)
│ ┌───── hour (0-23)
│ │ ┌───── day of month (1-31)
│ │ │ ┌───── month (1-12)
│ │ │ │ ┌───── day of week (0-6, Sunday = 0)
│ │ │ │ │
* * * * *

Useful operators:

  • * — every value
  • */5 — every fifth value (step)
  • 1,15 — a list
  • 9-17 — a range

Common patterns:

Expression — Meaning

*/15 * * * * — Every 15 minutes

0 * * * * — Hourly, on the hour

0 3 * * * — Daily at 03:00

0 3 * * 1 — Mondays at 03:00

0 0 1 * * — First day of the month, midnight

If you are unsure whether an expression means what you think, paste it into the cron expression generator and read the plain-English description and next run times.

Defining tasks in Laravel

In modern Laravel, schedules live in routes/console.php (or app/Console/Kernel.php on older versions):

use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:daily')
    ->dailyAt('03:00')
    ->timezone('America/New_York')
    ->withoutOverlapping()
    ->onOneServer()
    ->emailOutputOnFailure('ops@example.com');

Schedule::job(new PruneStaleSessions)->hourly();

Schedule::call(fn () => Cache::forget('homepage.stats'))->everyFifteenMinutes();

You can always drop to a raw expression when the fluent helpers do not fit:

Schedule::command('sync:inventory')->cron('*/10 6-22 * * 1-5');

The five production rules

1. Prevent overlap

withoutOverlapping() uses a cache lock so a slow run does not stack on top of itself. Give it an expiry so a crashed process cannot hold the lock forever:

->withoutOverlapping(30) // minutes

2. Pin the timezone

Server timezones change. Deployments move between regions. Daylight saving shifts your 02:30 job into a gap that does not exist twice a year. Set ->timezone() explicitly, and prefer times outside 01:00–03:00 local if the task must run exactly once.

3. Run once across a fleet

With multiple app servers, every one of them runs schedule:run. Add ->onOneServer() (requires a shared cache like Redis) or your daily email goes out three times.

4. Make failures loud

Scheduled jobs fail quietly because nobody is watching a terminal. Attach hooks:

->onFailure(function () {
    report(new SchedulerTaskFailed('reports:daily'));
})
->pingOnSuccess(config('services.heartbeat.daily_report'));

A dead-man's-switch service that alerts when a ping *stops* arriving is more reliable than an alert that depends on your failing app to send it.

5. Keep tasks short and idempotent

Long tasks should dispatch queued jobs, not do the work inline. And every scheduled task should be safe to run twice — because eventually it will.

Debugging a schedule that is not firing

Work down this list:

  1. php artisan schedule:list — is the task registered and due when you expect?
  2. Is the system cron entry present for the *correct user*, with the correct absolute path?
  3. Is the app in maintenance mode? Scheduled commands are skipped by default (->evenInMaintenanceMode() overrides this).
  4. Is the cache driver available? withoutOverlapping() and onOneServer() silently skip runs when the lock cannot be acquired.
  5. Check storage/logs and your process manager's output — cron discards stdout unless you redirect it.
  6. Run php artisan schedule:run manually and read the output.

A sane default schedule

Most production Laravel apps end up with something like this:

  • Queue health check — every five minutes
  • Cache warming — every fifteen minutes
  • Failed job retry sweep — hourly
  • Reports and digests — daily, early morning, pinned timezone
  • Database and storage backups — daily
  • Log and soft-delete pruning — weekly
  • Dependency audit report — weekly

Declare them all in code, keep each one under a minute of inline work, and make every failure page someone.

Next steps

Build the expression with the cron expression generator, then keep the rest of your Laravel toolbox nearby: the migration generator for schema changes the jobs depend on, and the .env generator for consistent configuration across environments.