Laravel Task Scheduling: Cron Jobs Made Beautiful
Sending daily reports, cleaning up old files, expiring unused tokens — all the recurring tasks your app needs. Learn Laravel's scheduler, which turns ugly cron syntax into expressive PHP methods.
The Cron Nightmare
My app needed recurring tasks:
- Send daily email digests at 8 AM
- Clean up expired tokens every hour
- Generate weekly reports on Mondays
- Delete temp files older than 24 hours
- Check for abandoned carts every 15 minutes
The traditional approach: write each task as a script and add cron entries:
0 8 * * * cd /var/www/app && php artisan email:digest >> /dev/null 2>&1
0 * * * * cd /var/www/app && php artisan tokens:cleanup >> /dev/null 2>&1
0 9 * * 1 cd /var/www/app && php artisan report:weekly >> /dev/null 2>&1
*/30 * * * * cd /var/www/app && php artisan temp:cleanup >> /dev/null 2>&1
*/15 * * * * cd /var/www/app && php artisan carts:abandoned >> /dev/null 2>&1
Problems:
- Cron syntax is cryptic. What’s
0 9 * * 1? - Each task needs its own cron entry
- Deployments might forget to update cron
- Testing scheduled tasks is painful
- No central place to see all scheduled tasks
Laravel’s Scheduler
Laravel has ONE cron entry:
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
That’s it. One entry that runs every minute.
All scheduling logic lives in app/Console/Kernel.php:
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected function schedule(Schedule $schedule)
{
// All your scheduled tasks go here
$schedule->command('email:digest')->dailyAt('08:00');
$schedule->command('tokens:cleanup')->hourly();
$schedule->command('report:weekly')->weeklyOn(1, '09:00');
$schedule->command('temp:cleanup')->everyThirtyMinutes();
$schedule->command('carts:abandoned')->everyFifteenMinutes();
}
}
Readable. Expressive. All in one place.
Creating Commands to Schedule
First, create an Artisan command:
php artisan make:command SendDailyDigest
<?php
namespace App\Console\Commands;
use App\Mail\DailyDigest;
use App\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendDailyDigest extends Command
{
protected $signature = 'email:digest';
protected $description = 'Send daily email digest to subscribed users';
public function handle()
{
$users = User::where('daily_digest', true)->get();
$this->info("Sending digest to {$users->count()} users...");
foreach ($users as $user) {
Mail::to($user)->queue(new DailyDigest($user));
$this->line("Queued for: {$user->email}");
}
$this->info('Daily digest completed!');
return 0; // Success
}
}
Run manually to test:
php artisan email:digest
Then schedule it:
$schedule->command('email:digest')->dailyAt('08:00');
Schedule Frequency Options
// Minutes
$schedule->command('task')->everyMinute();
$schedule->command('task')->everyFiveMinutes();
$schedule->command('task')->everyTenMinutes();
$schedule->command('task')->everyFifteenMinutes();
$schedule->command('task')->everyThirtyMinutes();
// Hours
$schedule->command('task')->hourly();
$schedule->command('task')->hourlyAt(17); // At 17 minutes past
$schedule->command('task')->everyTwoHours();
$schedule->command('task')->everyFourHours();
// Days
$schedule->command('task')->daily();
$schedule->command('task')->dailyAt('13:00');
$schedule->command('task')->twiceDaily(1, 13); // At 1:00 & 13:00
// Specific days
$schedule->command('task')->weekdays();
$schedule->command('task')->weekends();
$schedule->command('task')->sundays();
$schedule->command('task')->mondays();
// ... through ->saturdays()
// Weekly
$schedule->command('task')->weekly();
$schedule->command('task')->weeklyOn(1, '8:00'); // Monday at 8 AM
// Monthly
$schedule->command('task')->monthly();
$schedule->command('task')->monthlyOn(4, '15:00'); // 4th day at 3 PM
$schedule->command('task')->lastDayOfMonth('15:00');
// Quarterly & Yearly
$schedule->command('task')->quarterly();
$schedule->command('task')->yearly();
// Custom cron expression (when nothing else fits)
$schedule->command('task')->cron('0 */2 * * 1-5'); // Every 2 hours on weekdays
Scheduling Closures
Don’t need a full command? Schedule a closure:
$schedule->call(function () {
// Clean up expired tokens
Token::where('expires_at', '<', now())->delete();
})->hourly();
Or a class method:
$schedule->call([new CleanupService, 'run'])->daily();
Scheduling Jobs
Queue a job on a schedule:
$schedule->job(new ProcessAnalytics)->daily();
// With queue specification
$schedule->job(new ProcessAnalytics, 'analytics')->daily();
Preventing Overlaps
What if a task takes longer than expected and the next run starts?
$schedule->command('report:generate')
->hourly()
->withoutOverlapping();
The new run won’t start if the previous one is still going.
Set a maximum lock duration:
->withoutOverlapping(30); // Lock expires after 30 minutes
Running on One Server Only
In a multi-server setup, you don’t want every server running the same scheduled task:
$schedule->command('report:generate')
->daily()
->onOneServer();
Requires a centralized cache (Redis, Memcached) to coordinate.
Background Tasks
By default, scheduled tasks run sequentially. Run in the background to not block others:
$schedule->command('slow:task')
->daily()
->runInBackground();
Maintenance Mode Behavior
By default, scheduled tasks don’t run in maintenance mode. Override this:
$schedule->command('critical:task')
->daily()
->evenInMaintenanceMode();
Output Handling
Log Output to File
$schedule->command('report:generate')
->daily()
->sendOutputTo('/var/log/report.log');
// Append instead of overwrite
$schedule->command('report:generate')
->daily()
->appendOutputTo('/var/log/report.log');
Email Output
$schedule->command('report:generate')
->daily()
->emailOutputTo('admin@example.com');
// Only email if there's output
$schedule->command('report:generate')
->daily()
->emailOutputOnFailure('admin@example.com');
Hooks: Before and After
Run code before or after a task:
$schedule->command('backup:run')
->daily()
->before(function () {
Log::info('Backup starting...');
})
->after(function () {
Log::info('Backup completed!');
});
On Success / Failure
$schedule->command('important:task')
->daily()
->onSuccess(function () {
// Task succeeded
Cache::forget('last_task_failed');
})
->onFailure(function () {
// Task failed
Notification::send($admin, new TaskFailedNotification());
});
Ping URLs (Webhooks)
Notify external services:
$schedule->command('important:task')
->daily()
->pingBefore('https://healthcheck.io/start/xxx')
->thenPing('https://healthcheck.io/complete/xxx')
->pingOnSuccess('https://healthcheck.io/success/xxx')
->pingOnFailure('https://healthcheck.io/fail/xxx');
Great for uptime monitoring services.
Conditional Scheduling
// Only in production
$schedule->command('stats:report')
->daily()
->when(function () {
return app()->environment('production');
});
// Skip based on condition
$schedule->command('email:digest')
->daily()
->skip(function () {
return Holiday::isToday();
});
Timezones
By default, schedules use the app’s timezone. Override for specific tasks:
$schedule->command('report:timezone')
->dailyAt('09:00')
->timezone('America/New_York');
Testing Schedules
List all scheduled tasks:
php artisan schedule:list
Output:
+-------------+-------------+-------------------+----------------------------+
| Command | Interval | Description | Next Due |
+-------------+-------------+-------------------+----------------------------+
| email:digest| 0 8 * * * | Send daily digest | 2019-08-16 08:00:00 +00:00 |
| tokens:cleanup| 0 * * * * | Cleanup tokens | 2019-08-15 15:00:00 +00:00 |
+-------------+-------------+-------------------+----------------------------+
Run a specific task manually:
php artisan email:digest
Test the scheduler locally:
php artisan schedule:run
This runs all tasks due “now” — useful for testing.
Real-World Schedule Example
protected function schedule(Schedule $schedule)
{
// Every minute - process queued jobs check
$schedule->command('queue:work --stop-when-empty')
->everyMinute()
->withoutOverlapping();
// Every 15 minutes
$schedule->command('carts:send-reminders')
->everyFifteenMinutes()
->onOneServer();
// Hourly
$schedule->command('tokens:cleanup')->hourly();
$schedule->command('cache:prune-stale-tags')->hourly();
// Daily
$schedule->command('email:daily-digest')
->dailyAt('08:00')
->timezone('America/New_York')
->onOneServer()
->emailOutputOnFailure('admin@example.com');
$schedule->command('temp:cleanup')
->dailyAt('03:00')
->runInBackground();
$schedule->command('backup:run')
->dailyAt('04:00')
->onOneServer()
->before(function () {
Log::info('Starting daily backup');
})
->pingOnSuccess(env('BACKUP_HEALTHCHECK_URL'));
// Weekly
$schedule->command('report:weekly')
->weeklyOn(1, '09:00')
->emailOutputTo('reports@example.com');
// Monthly
$schedule->command('report:monthly')
->monthlyOn(1, '06:00')
->onOneServer();
$schedule->command('subscriptions:renew')
->lastDayOfMonth('23:00');
}
Setting Up the Cron Entry
On your server, add the single cron entry:
crontab -e
Add:
* * * * * cd /var/www/myapp && php artisan schedule:run >> /dev/null 2>&1
That’s the only cron entry you’ll ever need for Laravel.
What I Wish I’d Known Earlier
-
One cron entry to rule them all. Don’t add individual cron entries for each task.
-
Use
onOneServer()in multi-server setups. Otherwise you’ll send duplicate emails. -
withoutOverlapping()prevents chaos. Slow tasks won’t pile up. -
Log or ping for visibility. Silent failures are the worst failures.
-
Test locally with
schedule:run. Don’t deploy and hope.
The Complete Laravel Journey
Looking back at my Laravel journey:
- Basics & Blade - MVC, templates, getting started
- Eloquent Relationships - hasMany, belongsTo, eager loading
- Authentication - Built-in auth, middleware
- Events & Listeners - Decoupled architecture
- Facades & Container - The magic explained
- Migrations & Seeding - Database version control
- File Storage - Local, S3, abstraction
- Caching - Performance optimization
- Task Scheduling - Automated recurring tasks
Laravel gave me everything I needed to build complete applications. From database to deployment, from authentication to automation.
P.S. — The day I replaced 15 cron entries with one schedule() method was liberating. All my scheduled tasks in one file, version controlled, with expressive syntax instead of cryptic cron expressions. Laravel’s scheduler is one of those features that, once you use it, you can’t go back.
Saurav Sitaula
Software Architect • Nepal