0x55aa
← Back to Blog

Laravel Task Scheduling: Stop Writing Cron Jobs Like It's 1999 🕰️

6 min read

Laravel Task Scheduling: Stop Writing Cron Jobs Like It's 1999 🕰️

Picture this: it's 2am, production is on fire, and the on-call engineer is digging through a server's crontab -e trying to figure out which mysterious * * * * * /usr/bin/php /var/www/html/scripts/sendReminders.php is causing chaos.

No README. No Git history. No memory of who wrote it.

That was a real Monday morning at a client project I inherited years ago. I counted 23 undocumented cron jobs spread across two servers, two PHP versions, and one guy's personal home directory.

Laravel Task Scheduling exists so you never have to have that Monday.

What Even Is Laravel's Scheduler? 🤔

You add one single cron entry to your server:

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

That's it. One line. Now every task your app needs lives inside your codebase, in version control, readable by every developer on your team.

The scheduler runs every minute, checks what needs to run, and executes it. Laravel handles all the "should this run now?" logic for you.

Your First Scheduled Task ⚡

In app/Console/Kernel.php (Laravel 10 and below) or directly in routes/console.php (Laravel 11+):

// Laravel 11+ style (cleaner!)
Schedule::command('emails:send-daily-digest')
    ->dailyAt('08:00')
    ->onOneServer(); // Important on multi-server setups!

Compare that to the old way:

0 8 * * * /usr/bin/php /var/www/html/artisan emails:send-daily-digest

One is in Git. One is... somewhere. Guess which one your teammate can review in a PR?

The Frequency Options That'll Make You Smile 😄

Laravel's scheduler reads like English, which is a feature, not a bug:

// The obvious ones
Schedule::command('reports:daily')->daily();
Schedule::command('cleanup:temp-files')->hourly();
Schedule::command('sync:inventory')->everyFiveMinutes();

// The "oh wow that exists" ones
Schedule::command('birthday:emails')->monthlyOn(1, '9:00');
Schedule::command('reports:weekly')->weeklyOn(1, '8:00'); // Monday 8am
Schedule::command('peak:cache-warm')->twiceDaily(8, 18); // 8am and 6pm

// The power user option
Schedule::command('custom:job')->cron('0 */4 * * *'); // Every 4 hours

In production systems I've built, the weeklyOn() and monthlyOn() saved us from cron syntax typos that would have sent 10,000 reports at 3am instead of 8am. One letter wrong in a cron expression. That's all it takes.

Real Talk: withoutOverlapping() Saved My Job 🛡️

Here's a scenario that happens constantly in production: you have a task that normally takes 2 minutes. One day the database is slow and it takes 6 minutes. Your next scheduled run starts at minute 5. Now two instances are fighting over the same data.

Chaos. Race conditions. Duplicate emails. Angry users.

// BEFORE: Two instances running simultaneously 😱
Schedule::command('invoices:generate')->everyFiveMinutes();

// AFTER: If the previous run isn't done, skip this one 🎯
Schedule::command('invoices:generate')
    ->everyFiveMinutes()
    ->withoutOverlapping();

withoutOverlapping() uses atomic cache locks to prevent concurrent execution. One line. One saved incident.

As a Technical Lead, I've learned that withoutOverlapping() should be the default for any task that touches the database. Put it on everything that mutates data. Your future self will thank you at 2am.

Running on Multiple Servers? onOneServer() Is Your Friend 🖥️

If you run multiple app servers (and in a scalable e-commerce setup you almost certainly do), every server will try to run your scheduled tasks. That means invoices:generate runs on Server A AND Server B simultaneously.

Schedule::command('reports:generate')
    ->daily()
    ->onOneServer(); // Uses Redis/cache to elect one server as leader

At Cubet, we built a multi-region e-commerce backend on AWS. The day we discovered onOneServer() was the day we stopped getting duplicate order confirmation emails. It uses distributed locking via your cache driver — so make sure Redis is configured, not the file driver.

Pro Tip: Use both together for bulletproof scheduling:

Schedule::command('orders:process-pending')
    ->everyMinute()
    ->withoutOverlapping()
    ->onOneServer();

Scheduling Closures for Quick Tasks 🪄

Not everything needs a full Artisan command. For lightweight tasks:

Schedule::call(function () {
    DB::table('sessions')
        ->where('last_activity', '<', now()->subHours(24)->timestamp)
        ->delete();
})->daily()->at('03:00');

Works great for simple housekeeping. But if the logic grows beyond 10 lines, extract it into a proper command — your future self debugging at midnight will appreciate it.

Output Logging: Because Silent Failures Are Evil 🔇

By default, scheduled tasks run silently. That's great for noisy tasks, terrible for debugging.

Schedule::command('sync:external-api')
    ->hourly()
    ->appendOutputTo(storage_path('logs/sync.log'))
    ->emailOutputOnFailure('[email protected]');

A pattern that saved us in a real project: appendOutputTo() + a weekly log rotation. We caught an external API silently returning empty responses for 3 days because the output log showed "Synced 0 records" every hour instead of the expected 500+. Without logging, we'd have shipped a feature based on 3-day-stale data.

Conditional Scheduling: Skip When You Don't Need It 🎭

// Only run the cache-warming job if the cache is actually cold
Schedule::command('cache:warm-products')
    ->everyFifteenMinutes()
    ->when(fn() => Cache::missing('products.featured'));

// Skip during maintenance windows
Schedule::command('sync:inventory')
    ->hourly()
    ->skip(fn() => app()->isDownForMaintenance());

The when() and skip() callbacks are evaluated fresh every run. No config changes, no redeployment needed to add conditional logic.

Bonus Tips 🎯

Test your schedule locally:

php artisan schedule:list        # See what's scheduled and when it'll run next
php artisan schedule:work        # Run the scheduler continuously in foreground (dev only)
php artisan schedule:run         # Manually trigger one cycle

schedule:list is criminally underused. Run it after every deploy to confirm your tasks are registered correctly.

Serverless/Lambda? Use schedule:run in a CloudWatch Event:

At Cubet, we deployed Laravel on AWS Lambda. No persistent server means no cron. Solution: a CloudWatch Events rule that triggers a Lambda function running php artisan schedule:run every minute. Same scheduler, zero servers. Combined with onOneServer() via ElastiCache Redis, it works perfectly.

Want email alerts when something fails?

Schedule::command('reports:generate')
    ->daily()
    ->emailOutputOnFailure('[email protected]');

Set it up once and forget about it — until it saves you on a Friday afternoon.

The TL;DR ✅

Laravel's Task Scheduler turns server cron chaos into clean, version-controlled code:

  • One cron entry on the server, everything else in PHP
  • withoutOverlapping() prevents race conditions
  • onOneServer() is mandatory on multi-server deployments
  • appendOutputTo() for debugging, emailOutputOnFailure() for peace of mind
  • schedule:list to verify after every deploy

If your app has more than 2 scheduled tasks and they're still raw cron entries on a server, today is the day to migrate. I've done this migration at three different companies and it's never taken more than an afternoon.

Your sysadmin will thank you. Your on-call engineer will thank you. Your 2am self will really thank you.


Shipping a Laravel app with complex scheduling? Hit me up on LinkedIn — I love nerding out about this stuff.

More Laravel deep-dives? Star the repo on GitHub and I'll keep them coming!

Now go rescue those orphaned cron jobs. They deserve a proper home. 🏠