← Back to blog

Scheduled Tasks in Laravel: The Cron Setup I Use for Every SaaS

25 May 2026 · 5 min read

There's a moment in almost every SaaS project where you realise you need something to happen automatically on a schedule. Maybe it's sending a weekly digest email, churning out invoices at the end of the month, clearing stale sessions, or syncing data from a third-party API overnight. Whatever it is, you reach for Laravel's task scheduler — and if you've not thought carefully about how to set it up, that's when things can quietly go wrong.

I've been using Laravel's scheduler since it was first introduced, and over the years I've developed a setup that I trust in production. Let me walk you through it.

The Basics (That Everyone Knows)

Most Laravel developers know that you add a single cron entry to your server:

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

This runs every minute and hands control to Laravel, which then decides what to execute based on your routes/console.php file (or app/Console/Kernel.php in older projects). It's elegant, and it means your scheduling logic lives in your codebase rather than scattered across server config.

But there's a lot more to it than that one cron line.

Where I Define Scheduled Tasks Now

Since Laravel 11, the Kernel.php approach has been deprecated in favour of defining scheduled tasks directly in routes/console.php. I've fully embraced this. It feels more natural and keeps things lean.

use Illuminate\Support\Facades\Schedule;

Schedule::command('invoices:generate')
    ->monthlyOn(1, '08:00')
    ->withoutOverlapping()
    ->onFailure(function () {
        // notify the team
    });

Schedule::command('digest:send')
    ->weeklyOn(1, '09:00')
    ->withoutOverlapping();

Simple, readable, and version-controlled.

withoutOverlapping() — Don't Skip This

This is the big one that trips people up. If a scheduled task takes longer than its interval, Laravel will happily start a second (or third) instance of it before the first finishes. That's a recipe for duplicate emails, double-charged invoices, or corrupted data.

withoutOverlapping() creates a cache lock to prevent this. I add it to virtually every task by default now. It costs you nothing and can save you a very awkward conversation with a client.

You can also pass a timeout in minutes if you want to be explicit about how long a lock should be held:

->withoutOverlapping(10) // release lock after 10 minutes regardless

Running Long Tasks in the Background

For anything that might take a while — generating reports, processing large datasets — I use ->runInBackground(). Without this, the scheduler waits for the command to finish before moving on to the next task in the same minute window. That can cause other tasks to be skipped entirely.

Schedule::command('reports:generate')
    ->dailyAt('02:00')
    ->runInBackground()
    ->withoutOverlapping();

Combine this with withoutOverlapping() and you've got a much more resilient setup.

Handling Failures Gracefully

In a SaaS context, silent failures are dangerous. If your invoice generation job fails at midnight and nobody knows until a customer complains, that's a bad day.

Laravel gives you hooks for this:

Schedule::command('invoices:generate')
    ->monthlyOn(1, '08:00')
    ->withoutOverlapping()
    ->onFailure(function () {
        Notification::route('slack', config('services.slack.webhook'))
            ->notify(new ScheduledTaskFailed('invoices:generate'));
    })
    ->onSuccess(function () {
        // optionally log success somewhere
    });

I use this pattern for every business-critical task. It ties in nicely with Laravel Pulse too — I can see task health at a glance in the dashboard.

The schedule:work Command for Local Dev

One thing that used to annoy me is that the scheduler only runs when triggered by a cron job. Locally, that means tasks never fire unless you run them manually.

The fix is simple:

php artisan schedule:work

This starts a long-running process that fires the scheduler every minute, just like cron would. I add it to my project's Procfile or just run it in a terminal tab when I'm working on scheduler-related features. It's made local testing so much less painful.

Scheduler Monitoring With schedule:list

I didn't use this nearly enough when it was first added, but php artisan schedule:list is genuinely useful. It gives you a table of all your scheduled tasks, their next run times, and whether they're enabled. When you're onboarding a new project or debugging a missed task, it's invaluable.

Multi-Server Environments

If you're running your SaaS across multiple servers or using something like Laravel Forge with horizontal scaling, you need to be careful. By default, the scheduler will run on every server — meaning your monthly invoice job could fire three times.

Laravel handles this with ->onOneServer():

Schedule::command('invoices:generate')
    ->monthlyOn(1, '08:00')
    ->withoutOverlapping()
    ->onOneServer();

This uses your cache driver to ensure only one server picks up the task. Just make sure you're using a shared cache (Redis, for example) across your servers, not the file driver.

Dispatching Jobs From the Scheduler

Finally — and this is a pattern I use a lot — don't put heavy logic directly into Artisan commands if it can be avoided. Instead, dispatch a queued job from the scheduler:

Schedule::call(function () {
    ProcessMonthlyInvoices::dispatch();
})->monthlyOn(1, '08:00');

This way, the scheduler just kicks things off and returns immediately. The actual work happens in your queue, where you get retry logic, monitoring, and all the other goodness that comes with Laravel's queue system. It's a much cleaner separation of concerns.

Wrapping Up

Laravel's task scheduler is one of those features that feels almost too easy at first — drop in a cron entry, write a schedule, done. But the production details matter enormously. withoutOverlapping(), onOneServer(), background execution, and failure hooks are the difference between a scheduler that just works and one that causes you grief at 2am.

If you're building a SaaS on Laravel and you've not audited your scheduled tasks recently, it's worth taking an hour to do it. Future you will be grateful.