Laravel Task Scheduling: Stop Writing Cron Jobs Like It's 1999 ⏰
Laravel Task Scheduling: Stop Writing Cron Jobs Like It's 1999 ⏰
There's a special kind of panic that hits when you SSH into a production server, open the crontab, and see 23 lines of */15 3 * * 1-5 that nobody wrote down anywhere and nobody remembers why.
I've lived that panic. Our e-commerce backend had accumulated cron jobs like a drawer collects old batteries — nobody throws them away, nobody knows if they still work. Then three jobs that shouldn't overlap all decided to fire simultaneously on a Sunday morning, locked the database, and woke me up at 6 AM.
That was the day I fully committed to Laravel's task scheduler. And I haven't touched a crontab since.
The Problem with Raw Crontabs 😱
Here's a typical "organized" crontab:
# Send invoices
0 * * * * php /var/www/app/artisan send:invoices
# Cleanup old records
30 2 * * * php /var/www/app/artisan cleanup:old-records
# Check payments every 5 minutes
*/5 * * * * php /var/www/app/artisan check:payments
# Weekly report (Sunday midnight)
0 0 * * 0 php /var/www/app/artisan generate:weekly-report
Problems: it's not in version control, it lives only on the server, there's zero overlap protection, and 30 2 * * * requires a decoder ring to read. Good luck onboarding a new developer.
One Cron Entry to Rule Them All 👑
Laravel's scheduler replaces all of that with a single cron entry:
* * * * * php /var/www/app/artisan schedule:run >> /dev/null 2>&1
That's it. One line on the server. Everything else lives in your codebase.
Then in app/Console/Kernel.php:
protected function schedule(Schedule $schedule): void
{
$schedule->command('send:invoices')->hourly();
$schedule->command('cleanup:old-records')->dailyAt('02:30');
$schedule->command('check:payments')->everyFiveMinutes();
$schedule->command('generate:weekly-report')->weekly()->sundays()->at('00:00');
}
Read that out loud. Even a non-developer can understand it. Your scheduled tasks are now version-controlled, peer-reviewed, and living alongside your application code where they belong.
The Frequencies You'll Actually Use ⏱️
Laravel ships with a surprisingly rich set of scheduling helpers. The ones I reach for constantly:
$schedule->command('my:command')
->everyMinute()
->everyFiveMinutes()
->everyFifteenMinutes()
->hourly()
->hourlyAt(17) // Every hour at :17
->daily() // Midnight
->dailyAt('13:00') // 1 PM every day
->weeklyOn(1, '08:00') // Every Monday at 8 AM
->monthly()
->quarterly();
Real Talk: I spent years calculating cron syntax by hand. */15 * * * * — fine, whatever. But ->everyFifteenMinutes() documents itself. When a junior developer asks "when does this run?", they can read the code instead of Googling cron syntax.
The Feature That Would Have Saved Our Production System 🛡️
Back to that 6 AM disaster. The root cause: overlapping jobs.
Our weekly report took 9 minutes to run. Someone had also added an hourly check that occasionally triggered the same report logic. Both jobs hit the same database tables at the same time. Deadlock. Crashed. Chaos.
Laravel's fix is a single method:
$schedule->command('generate:weekly-report')
->weekly()
->withoutOverlapping(); // This one line would have saved us
withoutOverlapping() creates an atomic lock. If the previous run is still going, the new run simply skips — no pile-up, no database locks, no Sunday morning incidents.
Pro Tip: Pass a timeout to release the lock if a job hangs and never finishes:
->withoutOverlapping(30) // Release the lock after 30 minutes if the job dies
Don't Block Your Other Tasks 🏃
By default, scheduled tasks run sequentially. If you have two tasks firing at the same time and one is slow, it blocks the other.
$schedule->command('process:large-export')
->hourly()
->runInBackground(); // Doesn't block other scheduled tasks
In production systems I've built for high-traffic e-commerce, this was the difference between tasks completing on time and backing up like checkout queues on Black Friday.
Protect Your Dev Environment 🏠
Early in my career I ran a cleanup job on my local machine. It deleted "old" records. From my only test database. Before I had a backup.
Never again:
$schedule->command('cleanup:expired-sessions')
->daily()
->environments(['production', 'staging']);
Now the job only runs where it should. Your local data is safe from your own automation.
Log Task Output So You Know If It Ran 📝
Scheduled tasks run silently by default. If something fails, you find out when an angry client emails asking why their weekly invoice never arrived.
$schedule->command('send:invoices')
->hourly()
->appendOutputTo(storage_path('logs/invoices.log'))
->emailOutputOnFailure('[email protected]');
As a Technical Lead, I've learned: Silent failures in scheduled tasks are the sneakiest production bugs. Two minutes adding output logging and failure alerts has saved us countless hours of "wait, has this job been running at all?"
Critical for Multi-Server Deployments 🖥️
Here's one that'll bite you when you scale: if you run Laravel on multiple servers (auto-scaling, load balancing), every server runs schedule:run every minute. Without protection, that weekly report runs on all 5 of your EC2 instances simultaneously.
$schedule->command('generate:weekly-report')
->weekly()
->withoutOverlapping()
->onOneServer(); // Runs on exactly ONE server, not all of them
onOneServer() requires a shared cache driver (Redis is the obvious choice). We added this to every scheduled task the moment we went multi-server. Non-negotiable.
Bonus Tips 🎯
Debug your schedule without guessing:
php artisan schedule:list # See every task and when it's due next
php artisan schedule:run # Manually trigger due tasks right now
php artisan schedule:work # Run the scheduler every minute locally
schedule:work is brilliant for local dev — no need to set up a local cron at all.
Run closures for quick one-off tasks:
$schedule->call(function () {
DB::table('failed_jobs')->where('created_at', '<', now()->subDays(30))->delete();
})->daily();
No artisan command needed for simple operations.
Chain hooks for pre/post actions:
$schedule->command('generate:report')
->weekly()
->before(function () {
Log::info('Report generation starting');
})
->after(function () {
Notification::send($admins, new ReportReadyNotification());
});
A pattern that saved us in a real project: sending Slack notifications after critical jobs complete so the team has a paper trail without digging through logs.
Real Talk: Mistakes I See Constantly 💬
-
Skipping
withoutOverlapping()on any job that runs more than a few seconds — overlapping long jobs will eventually cause deadlocks -
No
onOneServer()when scaling horizontally — you're running your jobs N times and wondering why records are duplicating -
No environment check — developers accidentally running cleanup jobs on their local test data
-
No failure notifications — silent failures in scheduled tasks can go unnoticed for days or weeks
-
Forgetting to add the actual cron entry on the server — your
Kernel.phpis completely useless without* * * * * php artisan schedule:run
I've made all five. Multiple times. You're welcome.
TL;DR 🚀
- One cron entry on the server:
* * * * * php artisan schedule:run - Everything else goes in
app/Console/Kernel.php - Always use
->withoutOverlapping()on jobs that could run long - Always use
->onOneServer()when you're running multiple instances - Use
->environments(['production'])to protect dev data - Log output and set failure notifications — silent failures will haunt you
- Run
php artisan schedule:listto audit what you've got scheduled
Your cron jobs deserve to be in version control. Your future teammates deserve readable scheduling logic. Your Sunday morning deserves to stay uninterrupted.
Stop writing */5 * * * * like it's 1999.
Further reading:
- Laravel Task Scheduling Docs — comprehensive and actually readable
- Laravel Queues deep dive — scheduling and queues work beautifully together
- Laravel Horizon — monitor your background jobs like a pro
Have a scheduling disaster story? Hit me up on LinkedIn — I guarantee mine is worse. 😅