Laravel Task Scheduling: Cron Jobs Made Beautiful

SS Saurav Sitaula

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

  1. One cron entry to rule them all. Don’t add individual cron entries for each task.

  2. Use onOneServer() in multi-server setups. Otherwise you’ll send duplicate emails.

  3. withoutOverlapping() prevents chaos. Slow tasks won’t pile up.

  4. Log or ping for visibility. Silent failures are the worst failures.

  5. Test locally with schedule:run. Don’t deploy and hope.

The Complete Laravel Journey

Looking back at my Laravel journey:

  1. Basics & Blade - MVC, templates, getting started
  2. Eloquent Relationships - hasMany, belongsTo, eager loading
  3. Authentication - Built-in auth, middleware
  4. Events & Listeners - Decoupled architecture
  5. Facades & Container - The magic explained
  6. Migrations & Seeding - Database version control
  7. File Storage - Local, S3, abstraction
  8. Caching - Performance optimization
  9. 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.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism