Introduction

Laravel's Scheduler has one of the cleanest APIs in the framework:

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

That one line can replace a cron entry, keep deployment configuration in source control, and make a production task easy to discover. But it can also make the actual mechanics easy to overlook.

What happens when cron runs php artisan schedule:run? How does Laravel decide that an event is due? Why are onOneServer() and withoutOverlapping() different? Why can a command run in the background but a closure cannot? And how can Laravel run a task every second when cron normally only runs every minute?

In this article, let's do a deep dive into the Laravel Scheduler. We will follow a scheduled task from its definition to its execution, look at the Schedule, Event, and mutex classes in the framework, and finish with the production decisions that make scheduled work reliable.

The Scheduler Is Not Cron

The first mental-model change is simple:

Cron wakes Laravel up. Laravel owns the schedule.

Cron does not know what dailyAt(), weekdays(), or withoutOverlapping() mean. Its only job is to start one Artisan command every minute:

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

When that command starts, Laravel boots the console application, loads your schedule definitions, and asks its own scheduler which events are due at the current time.

This is why a scheduler definition is application code instead of server configuration:

use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:generate')->dailyAt('02:00');
Schedule::job(new RefreshSearchIndex)->hourly();
Schedule::call(fn () => cache()->flush())->sundays()->at('03:00');

The code above registers events. It does not start a daemon and it does not independently execute anything. An external process still has to invoke schedule:run, or locally, you can use php artisan schedule:work to keep invoking it for you.

That separation is important when debugging. If a task never ran, first ask whether the scheduler was started at all. If it was started but a task was skipped, then investigate Laravel's due checks, filters, locks, and maintenance-mode rules.

The Big Picture

At a high level, a normal scheduler tick looks like this:

flowchart TD
    A[System cron runs every minute] --> B[php artisan schedule:run]
    B --> C[Laravel boots the console application]
    C --> D[Schedule definitions register Event objects]
    D --> E[Schedule::dueEvents]
    E --> F{Cron expression, environment, and maintenance mode pass?}
    F -- No --> G[Event is not due]
    F -- Yes --> H{Paused and event filters pass?}
    H -- No --> I[Dispatch ScheduledTaskSkipped]
    H -- Yes --> J{onOneServer lock acquired?}
    J -- No --> K[Skip on this server]
    J -- Yes --> L[Event::run]
    L --> M{withoutOverlapping lock acquired?}
    M -- No --> N[Skip overlapping execution]
    M -- Yes --> O[Run callback, dispatch job, or start command process]
    O --> P[Run hooks, release lock, and dispatch lifecycle events]

A scheduler tick evaluates registered events, applies guards, and then executes only the eligible work.

The interesting part is that Laravel does not have one giant if statement for all of this. It models a schedule as a collection of event objects, then lets each object answer focused questions about itself.

From Fluent Methods to Event Objects

The central class is:

Illuminate\Console\Scheduling\Schedule

It keeps an internal array of scheduled events. The fluent facade is only a convenient way to interact with that object.

When you schedule a command, Schedule::command() turns the Artisan command into a command string and delegates to Schedule::exec(). That creates an Event instance:

public function exec($command, array $parameters = [])
{
    $this->events[] = $event = new Event(
        $this->eventMutex,
        $command,
        $this->timezone,
    );

    return $event;
}

The real implementation also merges any pending group attributes, but the important detail is that a scheduled command becomes an Event object. Its properties hold the command, cron expression, timezone, filters, callbacks, lock settings, output destination, and execution options.

The scheduler uses a related event type for callbacks:

Schedule::call(function (): void {
    app(PruneOldTokens::class)->handle();
})->daily();

That creates a CallbackEvent. Instead of launching a shell command, it calls the callback through the service container. That means dependency injection works naturally for scheduled closures and invokable objects:

use App\Services\ReportGenerator;
use Illuminate\Support\Facades\Schedule;

Schedule::call(function (ReportGenerator $reports): void {
    $reports->generateDailySummary();
})->dailyAt('02:00');

Schedule::job() is also worth understanding. It creates a callback event whose callback resolves the job and then decides whether to dispatch it to a queue or run it immediately. A job implementing ShouldQueue is dispatched; a job that does not implement it is dispatched synchronously.

So the Scheduler and the Queue are related, but they are not the same system. The Scheduler answers when should work begin? The Queue answers where and how should queued work be processed?

For substantial work, that distinction is useful. The scheduler can make a small, predictable decision every minute, while queue workers handle the expensive processing independently.

Frequency Methods Build a Cron Expression

Methods such as dailyAt(), hourly(), weekdays(), and mondays() look like separate scheduling features. Under the hood, most of them are small helpers that modify an event's cron expression.

For example, daily() delegates to an internal helper that sets the minute and hour positions:

public function daily()
{
    return $this->hourBasedSchedule(0, 0);
}

An event starts with this default expression:

* * * * *

Calling dailyAt('02:30') changes it to:

30 2 * * *

Calling weekdays() changes the day-of-week position. Calling between() and when() does something slightly different: it adds a callback filter that Laravel evaluates after it has found due events.

This gives us two layers of eligibility:

  • Cron expression: Is this event due at this minute?
  • Filters and rejects: Is it acceptable to run right now for this application state?

Laravel evaluates the cron expression using the dragonmantank/cron-expression package. The framework's Event::isDue() method is intentionally small:

public function isDue($app)
{
    if (! $this->runsInMaintenanceMode() && $app->isDownForMaintenance()) {
        return false;
    }

    return $this->expressionPasses()
        && $this->runsInEnvironment($app->environment());
}

expressionPasses() gets the current Laravel date, applies the event timezone when configured, and asks CronExpression whether the expression is due.

This also explains a common timezone mistake. A task timezone changes the clock used to evaluate that task. It does not change the server's clock or convert the work itself into a different timezone. And, as with any local-time scheduling, daylight-saving changes can make a local time occur twice or not at all. For work that must happen exactly once, UTC is usually the easier operational choice.

How Laravel Finds and Filters Due Events

ScheduleRunCommand is the Artisan command behind schedule:run. Once Laravel injects the Schedule instance into its handle() method, it asks for all due events:

$events = $this->schedule->dueEvents($this->laravel);

Schedule::dueEvents() simply filters the registered collection through Event::isDue(). At this point, cron timing, the environment restriction, and maintenance mode have been considered.

The run command then performs the remaining checks for each due event:

foreach ($events as $event) {
    if ($paused && ! $event->runsWhenPaused()) {
        // Dispatch ScheduledTaskSkipped and continue.
    }

    if (! $event->filtersPass($this->laravel)) {
        // Dispatch ScheduledTaskSkipped and continue.
    }

    $this->runEvent($event);
}

filtersPass() runs every when() callback and every skip() callback through the container. A when() callback must return true; a skip() callback must return false.

Schedule::command('billing:reconcile')
    ->dailyAt('02:30')
    ->environments(['production'])
    ->when(fn (): bool => config('billing.enabled'))
    ->skip(fn (): bool => app()->isDownForMaintenance());

In this example, the cron expression determines when the event is a candidate. The environment, configuration, and skip callback determine whether Laravel should actually run it on this tick.

The scheduler can also be paused without deploying code:

php artisan schedule:pause
php artisan schedule:continue

The pause state is a cache value. ScheduleRunCommand checks it before executing events, while an event marked with evenWhenPaused() is allowed through. This is a useful operational switch, but it is not a replacement for safe deployments or idempotent tasks.

Two Locks, Two Different Problems

onOneServer() and withoutOverlapping() are often chained together, which can make them look like the same feature. They solve different problems.

sequenceDiagram
    participant A as Server A
    participant B as Server B
    participant C as Shared cache
    participant E as Scheduled event
    participant P as Command process

    A->>C: Acquire scheduling mutex for this tick
    B->>C: Acquire scheduling mutex for this tick
    C-->>A: Acquired
    C-->>B: Denied
    A->>E: Event::run()
    E->>C: Acquire overlap mutex
    C-->>E: Acquired
    E->>P: Start work
    P-->>E: Exit code
    E->>C: Release overlap mutex

The single-server mutex chooses one scheduler instance for a tick; the overlap mutex protects the event's running lifetime.

onOneServer() is a scheduling mutex

In a multi-server deployment, every server can receive the same cron tick. Without a guard, every server would start the same event.

When an event uses onOneServer(), ScheduleRunCommand calls:

$this->schedule->serverShouldRun($event, $this->startedAt);

The default CacheSchedulingMutex creates a cache lock from the event mutex name plus the current hour and minute. The lock lasts one hour. The first server that acquires it runs the event; the other servers skip that event for this scheduled minute.

This needs a shared, atomic cache store. Laravel supports the database, Memcached, DynamoDB, and Redis cache drivers for this purpose. An array cache store on each server cannot coordinate anything between servers.

withoutOverlapping() is an execution mutex

An event can run longer than its schedule interval even on one server. A command scheduled every minute that takes five minutes can otherwise have five copies running at once.

withoutOverlapping() adds a reject filter and marks the event as overlap-protected. Then, immediately before starting work, Event::run() attempts to create its event mutex:

if ($this->shouldSkipDueToOverlapping()) {
    $this->skippedBecauseOverlapping = true;

    return;
}

That second check is important. A filter check is useful for normal control flow, but the actual mutex acquisition is the race-safe decision right before execution.

The default CacheEventMutex uses a cache lock when the store supports locks, or falls back to an atomic cache add. The mutex lifetime defaults to 1,440 minutes, which is 24 hours. Laravel releases it after a foreground event finishes, but the expiration protects you if a process crashes and cannot clean up.

Choose that expiration deliberately. It should be comfortably longer than normal execution, but not so long that a crashed task silently blocks an important workflow for a day. If a stale lock needs manual cleanup, Laravel provides:

php artisan schedule:clear-cache

Use both for long-running distributed tasks

For a production report that runs on several servers and might take longer than its interval, both locks are usually appropriate:

use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:rebuild')
    ->name('reports:rebuild')
    ->dailyAt('02:30')
    ->timezone('UTC')
    ->environments(['production'])
    ->onOneServer()
    ->withoutOverlapping(90)
    ->runInBackground()
    ->sendOutputTo(storage_path('logs/reports-rebuild.log'));

onOneServer() prevents three servers from starting the report at 02:30. withoutOverlapping(90) prevents a second report from starting while the first one still owns its 90-minute execution lock.

Neither lock makes business work idempotent. A process can still fail after writing some data, a lock can expire while a badly behaving task is still running, and an operator can rerun a command manually. Tasks that change important data should still be designed to tolerate retries safely.

Foreground Commands, Background Commands, and Callbacks

By default, due events run sequentially in the order they were defined. If the first command is slow, the second command waits.

For a command or system command, runInBackground() changes that behavior:

Schedule::command('analytics:aggregate')
    ->hourly()
    ->runInBackground();

An Event executes commands with Symfony Process. In the foreground, Laravel builds a shell command, redirects output, and waits for the process exit code.

In the background, CommandBuilder builds a detached shell command. On Unix-like systems it starts the configured command in the background and appends an internal schedule:finish Artisan command. When the child command exits, schedule:finish locates the matching event, calls finish(), releases the overlap mutex, runs after callbacks, and dispatches ScheduledBackgroundTaskFinished.

That cleanup step is why background execution is more than adding & to a command yourself.

There are two important limits:

  • runInBackground() is available only for events created by command() and exec().
  • CallbackEvent explicitly rejects it because a PHP closure runs inside the scheduler process, not as a separate shell command.

If a scheduled closure becomes expensive, do not try to force it into the background. Move the work into an Artisan command or dispatch a queued job:

use App\Jobs\RebuildAnalytics;
use Illuminate\Support\Facades\Schedule;

Schedule::job(new RebuildAnalytics)->everyFiveMinutes();

This keeps the scheduler's responsibility small and gives the queue system responsibility for retries, timeouts, worker concurrency, and failure handling.

Output, Hooks, Context, and Events

Scheduled commands default to discarding output to /dev/null on Unix-like systems. For a task that you need to diagnose, capture the output explicitly:

Schedule::command('imports:partners')
    ->hourly()
    ->appendOutputTo(storage_path('logs/partner-imports.log'));

Output capture is also required for the output-email helpers. These helpers only apply to command() and exec() events because a closure does not produce a shell process output stream.

Each event can also have lifecycle hooks:

use Illuminate\Support\Stringable;

Schedule::command('imports:partners')
    ->hourly()
    ->before(fn (): mixed => logger()->info('Partner import started.'))
    ->onSuccess(function (Stringable $output): void {
        logger()->info('Partner import completed.', [
            'output' => $output->trim()->value(),
        ]);
    })
    ->onFailure(function (Stringable $output): void {
        logger()->error('Partner import failed.', [
            'output' => $output->trim()->value(),
        ]);
    });

Laravel calls before hooks before execution and after hooks after it knows the exit code. A non-zero command exit code is a failed scheduled command. Callback events treat a false return value as exit code one, and capture thrown exceptions so the scheduler can report the task as failed.

For application-wide observability, the scheduler dispatches events such as:

  • ScheduledTaskStarting
  • ScheduledTaskFinished
  • ScheduledBackgroundTaskFinished
  • ScheduledTaskSkipped
  • ScheduledTaskFailed

You can attach listeners to these events to create metrics, alerts, or audit records without putting the same logging callbacks on every scheduled definition.

There is another subtle detail in Event::execute(): Laravel dehydrates the current Context repository and passes it to command processes through the __LARAVEL_CONTEXT environment variable. This lets context such as a trace ID continue into a scheduled command. If you want to go further into that feature, read Everything About the Context Facade.

How Sub-Minute Schedules Work

Cron normally cannot invoke Laravel every second. Laravel works around that limitation without asking cron

When you write this:

Schedule::job(new PollPaymentProvider)->everyTenSeconds();

Laravel stores two pieces of information on the event:

  • the normal cron expression remains every minute
  • repeatSeconds is set to 10

When schedule:run finds a repeatable event, it does not exit after the first pass. It stays alive until

while (Date::now()->lte($endOfMinute)) {
    foreach ($events as $event) {
        if ($event->shouldRepeatNow()) {
            $this->runEvent($event);
        }
    }

    Sleep::usleep(100_000);
}

The real method also checks interruption, pause state, maintenance mode, filters, and single-server locks inside that loop. The important point is that the first cron invocation owns the rest of that minute.

This has practical consequences:

  • A slow synchronous sub-minute task delays later repetitions and other synchronous tasks.
  • A deployment can leave the previous code in memory until the current minute ends.
  • php artisan schedule:interrupt sets a cache signal that asks an in-progress sub-minute scheduler to stop, so it belongs after a deployment has completed.

For this reason, Laravel recommends dispatching a queued job or running a command in the background for sub-minute work. The scheduler should coordinate frequent work, not become the process doing the heavy lifting.

A Practical Production Checklist

The public API is small, but scheduled work is operational code. These are the checks I use before shipping an important task:

  1. Make the trigger explicit. Confirm the production platform runs schedule:run every minute.
  2. Choose the right execution model. Use a command for process-oriented work, a queued job for retriable or scalable work, and a closure only for short in-process work.
  3. Use a shared cache before using onOneServer(). A per-server cache cannot provide a distributed lock.
  4. Use withoutOverlapping() for variable-duration work. Pick an expiration based on real execution time and document why it is safe.
  5. Keep important tasks idempotent. Locks reduce duplicates; they do not eliminate every failure or retry path.
  6. Use UTC where possible. It makes cron expressions, logs, and daylight-saving behavior easier to reason about.
  7. Capture output and add observability. Logs, scheduler events, and failure alerts turn a silent background process into an operable system.
  8. Inspect the registered schedule. Run php artisan schedule:list after a deploy to confirm the command, frequency, next run time, and description are what you expect.

The Scheduler gives you a very expressive way to declare work, but it cannot decide what safe business semantics look like for your application. That part is still our job.

Conclusion

The Laravel Scheduler is not a replacement implementation of cron. It is a small orchestration layer that cron wakes up every minute.

Your fluent definitions become Event and CallbackEvent objects. The scheduler evaluates their cron expressions, environment and maintenance-mode rules, pause state, filters, single-server mutexes, and overlap mutexes. Then it either calls a callback, dispatches a job, or starts a command process. Hooks, output handling, Context propagation, and lifecycle events complete the execution path.

Once you understand that flow, methods such as onOneServer(), withoutOverlapping(), and runInBackground() stop feeling like interchangeable safety switches. Each one protects a different part of the lifecycle.

That is the real value of looking under the hood: you can keep Laravel's elegant API while making better decisions about reliability, concurrency, deployments, and observability.

I hope that you liked this article and if you do, don't forget to share this article with your friends!!! See ya!