Blog · 9 min read

Mastering Scheduled Tasks in Laravel

Laravel scheduled tasks are a powerful feature in Laravel. They let you run background tasks on a schedule without the need for setting up a separate cron entry for each one. But if you aren't careful, They may silently fail or result in performance bottlenecks. In this post, I will show you how to manage your scheduled tasks efficiently.

1. Avoid Silent Delays in Schedule Tasks

Lets assume you have two scheduled tasks in your routes/console.php file as below.

Schedule::command('task:a')
    ->everyMinute();
    
Schedule::command('task:b')
    ->everyMinute();

You would assume these two scheduled tasks run every minute without any delays. But that's not the case and let me explain why.

class TaskA extends Command
{
    protected $signature = 'task:a';

    public function handle()
    {
        Log::info('log from task A');
        sleep(10);
    }
}
class TaskB extends Command
{
    protected $signature = 'task:b';

    public function handle()
    {
        Log::info('log from task B');
    }
}

Now, when you run `php artisan schedule:run` and inspect your log file, You will see below.

[2026-02-10 04:43:00] local.INFO: log from task A  
[2026-02-10 04:43:10] local.INFO: log from task B

Noticed how log entry from TaskB is delayed by 10 seconds? That's how long previous scheduled task(TaskA) took to run. If TaskA would take 5 minutes to complete, Then your TaskB will be delayed for 5 minutes.

How to Avoid Delays?

To avoid delays, You simply chain runInBackground() to your scheduled tasks.

Schedule::command('task:a')
    ->everyMinute()
    ->runInBackground();

Schedule::command('task:b')
    ->everyMinute()
    ->runInBackground();

This will run both of your scheduled tasks in parallel in a separate PHP process.

2. Avoid Overlaps and Duplicates

Lets assume we have a scheduled task TaskA scheduled to run every minute like below.

Schedule::command('task:a')
    ->everyMinute();
class TaskA extends Command
{
    protected $signature = 'task:a';

    public function handle()
    {
        $emails = DB::table('emails')->whereNull('sent_at')->get();
        foreach ($emails as $email) {
            $this->sendEmail($email);
            $email->update(['sent_at' => now()]);
        }
    }
}
If this task runs longer than one minute, Then next schedule will kick in and start running it again. This may result in sending duplicate emails. To avoid, chain the withoutOverlapping() method.
Schedule::command('task:a')
    ->everyMinute()
    ->withoutOverlapping();

By default withoutOverlapping method will put a 24 hour lock on your scheduled task which gets released after scheduled task completes execution. This will work 99% of the time as tasks usually do not need to run more than 24 hours and the lock will be released immediately when the execution is completed. But in some cases, If your task crashes or if you wish to release lock sooner, You can pass number of minutes as a parameter to the method. For example, If you wish to release lock after five minutes, pass 300 as below.

Schedule::command('task:a')
    ->everyMinute()
    ->withoutOverlapping(300);

Run only on one server

If your task scheduler is running on multiple servers, Then withoutOverlapping isn't always enough. As multiple servers might start running before the lock even is put in place. To truly avoid overlaps when task scheduler is running on multiple servers, You should also chain onOneServer() method.

Schedule::command('task:a')
    ->everyMinute()
    ->withoutOverlapping()
    ->onOneServer();

3. Conditionally run or skip task executions

Sometimes, you may not want to run a scheduled task for various reasons. You can define custom logic to tell Laravel whether a schedule task should run or not. Laravel checks the logic before each execution. So you can safely rely on it to skip only certain times where you do not want it to run.

In the above example where you are sending emails, You do not want to run the scheduled task if you hit a rate limit. You can do this by chaining the skip method as below.

Schedule::command('task:a')
    ->everyMinute()
    ->skip(fn () => (new EmailService())->isRateLimited());

Run only on specific environments

class TaskA extends Command
{
    protected $signature = 'task:a';

    public function handle()
    {
        DB::table('emails')
            ->where('sent_at', '<=', now()->subDays(60))
            ->delete();
    }
}
Consider the above scheduled task where you are deleting emails which are older than 60 days to save storage costs. But you only want to delete these emails in your non production environments without affecting the production data. In this case, You can chain the environments() method to instruct Laravel which environments this scheduled task can run on.
Schedule::command('task:a')
    ->everyMinute()
    ->environments(['local', 'testing', 'development', 'staging']);

Run only during specific time(business hours)

If you wish to run a scheduled task at a specific time, Lets say during your business hours, You can do that as shown below.

Schedule::command('task:a')
    ->everyMinute()
    ->weekdays()
    ->timezone('EDT')
    ->between('9:00', '17:00');

You can further customize this to exclude lunch hours, quiet hours or holidays.

Schedule::command('task:a')
    ->everyMinute()
    ->weekdays()
    ->timezone('EDT')
    ->between('9:00', '12:00') // Before Lunch
    ->between('13:00', '17:00') // After Lunch
    ->skip(function () { // Exclude holidays
        $today = now();
        $holidays = [
            // month => [days]
            1 => [
                1, // New Year
            ],
            7 => [
                4, // US Independence Day
            ],
            11 => [
                26, // Thanksgiving day
            ],
            12 => [
                24, // Christmas Eve,
                25, // Christmas,
                31, // New year Eve
            ],
        ];

        if (in_array($today->month, array_keys($holidays)) ) {
            return in_array($today->day, $holidays[$today->month]);
        }

        return false;
    });

4. Save Server Resources when Running Scheduled Tasks

Some scheduled tasks are resource intensive and some are not. But when you are running multiple scheduled tasks at the same time parallel, Each of them will put stain on your server and database resources. Lets look at the below use case.

Schedule::command('task:a')
    ->runInBackground()
    ->everyFiveMinutes();

Schedule::command('task:b')
    ->runInBackground()
    ->everyFiveMinutes();

Schedule::command('task:c')
    ->runInBackground()
    ->everyFiveMinutes();

Schedule::command('task:d')
    ->runInBackground()
    ->everyFiveMinutes();

Schedule::command('task:e')
    ->runInBackground()
    ->everyFiveMinutes();
In the example above, I have five scheduled tasks running every five minutes. This will put strain on my server and database. But i cannot afford to delay them. They must run at least once every five minutes.

In this case, I can have them run each task at a different minute within the five minute interval, so each of them still runs at least once every five minutes.

Schedule::command('task:a')
    ->runInBackground()
    ->cron('0/5 * * * *');

Schedule::command('task:b')
    ->runInBackground()
    ->cron('1/5 * * * *');

Schedule::command('task:c')
    ->runInBackground()
    ->cron('2/5 * * * *');

Schedule::command('task:d')
    ->runInBackground()
    ->cron('3/5 * * * *');

Schedule::command('task:e')
    ->runInBackground()
    ->cron('4/5 * * * *');

5. Capture Scheduled Task Output

Most of your scheduled tasks are artisan commands which will produce some output. You can see this output in terminal when running the commands manually. But when they were ran as part of the scheduled tasks, There are certain ways where you can still capture and view the output.

// Send output to email even if there is no output produced.
Schedule::command('task:a')
    ->everyFiveMinutes()
    ->emailOutputTo('[email protected]');

// Send output to email only if there is output produced.
Schedule::command('task:a')
    ->everyFiveMinutes()
    ->emailWrittenOutputTo('[email protected]');

// Send output to email only if the task failed.
Schedule::command('task:a')
    ->everyFiveMinutes()
    ->emailOutputOnFailure('[email protected]');

// Store output in a file. This will replace the content of the file if it already exists.
Schedule::command('task:a')
    ->everyFiveMinutes()
    ->sendOutputTo(storage_path('logs/task-a-output.log'));

// Store output in a file. This will append output to the file if it already exists.
Schedule::command('task:a')
    ->everyFiveMinutes()
    ->appendOutputTo(storage_path('logs/task-a-output.log'));

6. Execute Before and After Hooks

In certain cases, You would like to execute custom logic before and after a scheduled task has been executed.

In the below example, We are ensuring the image directory exists before generating images and cleaning up raw images after generation.

Schedule::command('generate:images')
    ->everyFiveMinutes()
    ->before(function () {
        // Make sure the image generation directory exists
        if (! Storage::directoryExists('images/generated')) {
            Storage::makeDirectory('images/generated');
        }
    })
    ->after(function () {
        // Clean up raw files after generation
        Storage::deleteDirectory('images/raw');
        Storage::makeDirectory('images/raw');
    });
You can also ping specific urls like monitoring services to inform them the start and completion of your scheduled tasks.
// Ping before and after execution
Schedule::command('generate:images')
    ->everyFiveMinutes()
    ->pingBefore('https://laritor.com/before-execution')
    ->thenPing('https://laritor.com/after-execution');

// Only ping in production environment
Schedule::command('generate:images')
    ->everyFiveMinutes()
    ->pingBeforeIf(
        app()->environment('production'),
        'https://laritor.com/before-execution'
    )
    ->thenPingIf(
        app()->environment('production'),
        'https://laritor.com/after-execution'
    );

// Ping a separate url for success and failures
Schedule::command('generate:images')
    ->everyFiveMinutes()
    ->pingBefore('https://laritor.com/before-execution')
    ->pingOnFailure('https://laritor.com/failed-execution')
    ->pingOnSuccess('https://laritor.com/success-execution');

7. Maintenance Mode and Run As User

By default, Your scheduled tasks wont be executed if your application is under maintenance mode. If you wish to run them even in maintenance mode, chain evenInMaintenanceMode() method.

Schedule::command('generate:images')
    ->everyFiveMinutes()
    ->evenInMaintenanceMode();
By default, All your scheduled tasks will be ran by the same system user who is running the task scheduler. If you wish to change the system user which it runs as, You can chain the user() method.
Schedule::command('generate:images')
    ->everyFiveMinutes()
    ->user('www-data');

8. Monitor Scheduled Tasks For Failures, Skips and Delays

Scheduled tasks are crucial for any business which relies on them. Delays, failures and overlaps can cause serious problems if there is no proper visibility into the execution of your scheduled tasks.

That's where Laritor comes in. Laritor is an end to end performance monitoring and observability tool designed specifically for Laravel applications. It lets you monitor your Laravel scheduled tasks for failures, delays and skipped executions and instantly alerts you when problems are detected. Laritor captures your entire scheduled task output so you can view the output of each task which will help you debug further. Click Here to learn more about Scheduled Tasks Monitoring in Laritor.

Scheduled tasks are a very powerful feature in Laravel. If you enjoyed this article, Share this with your network to spread the knowledge.

Try Laritor

Turn insights into real production visibility.

Reading about performance is one thing. Seeing it live in your own app is another. Laritor helps you monitor, filter, and understand every request, query, and background task in real time.

Back to all articles →