Inside the Laravel 13 Scheduler: From Cron Entry to Event Execution

Inside the Laravel 13 Scheduler: From Cron Entry to Event Execution

Trace how the Laravel 13 scheduler builds, filters, runs, and reports scheduled tasks, including mutexes, background processes, and sub-minute execution.

Laravel’s scheduler replaces a crontab full of application commands with one entry that wakes the framework every minute. The individual tasks stay in the codebase, where they can be reviewed, tested, and deployed with the rest of the application.

That is the useful promise described in the Laravel 13 task scheduling documentation. It also hides quite a lot of work. By the time an Artisan command runs, Laravel has built an in-memory schedule, evaluated cron expressions and application state, checked filters and locks, started foreground or background processes, and dispatched events that other parts of the application can observe.

This article follows that path through Laravel 13. It assumes you already know how to define a scheduled task and concentrates on what the framework does after you run schedule:run. The source links point to Laravel’s 13.x branch as it stood on September 12, 2026. If you are investigating a production application, compare them with the exact framework version installed in composer.lock.

The cron entry is only a wake-up call

The server still needs an external process to start Laravel. On a traditional Linux server, the documented cron entry is:

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

Cron knows nothing about reports:generate, emails:send, or any closure registered by the application. Its responsibility ends after starting php artisan schedule:run in the correct project directory. Running crontab -l therefore shows the launcher, not Laravel’s task definitions.

Those definitions usually live in routes/console.php:

php
use App\Jobs\RefreshSearchIndex;
use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:generate')->hourly();
Schedule::job(new RefreshSearchIndex)->everyTenMinutes();
Schedule::exec('/usr/local/bin/archive-reports')->dailyAt('02:00');
Schedule::call(new DeleteExpiredInvitations)->daily();

Laravel 13 also supports defining them with withSchedule() in bootstrap/app.php. Packages and application bootstrapping may register more events, so routes/console.php is the conventional location, not the scheduler’s storage mechanism.

There is no schedule table for schedule:run to query. FoundationServiceProvider binds Illuminate\Console\Scheduling\Schedule as a singleton for the current application process. When that singleton is resolved, the console kernel creates a Schedule, applies its timezone and cache store, and the application’s definitions add Event or CallbackEvent instances to its internal $events array.

The singleton distinction matters when reading commands such as schedule:run and schedule:list. Both receive a Schedule from the service container, but two Artisan invocations are two separate PHP processes. Each boots the application and reconstructs its own in-memory schedule.

What a scheduled definition becomes

The fluent API produces two main event shapes.

Schedule::command() and Schedule::exec() create an Illuminate\Console\Scheduling\Event. An Artisan command is converted into a command line that can later be executed as a process. A system command is already in that form.

Schedule::call() creates a CallbackEvent, a subclass of Event that invokes a closure, callable, or invokable object through Laravel’s service container. Schedule::job() also creates a CallbackEvent; its callback dispatches the supplied job through Laravel’s bus. If the job implements ShouldQueue, the scheduler’s work is the dispatch, while a queue worker performs the job itself.

Every event carries more than a cron expression. It can contain a timezone, environment constraints, when() and skip() callbacks, overlap and single-server settings, output configuration, before and after hooks, a description, and a sub-minute repeat interval. The scheduler evaluates these attributes in stages rather than with one boolean check.

How schedule:run selects due events

The command is implemented by Illuminate\Console\Scheduling\ScheduleRunCommand. Its handle() method receives four dependencies from the container:

php
public function handle(
    Schedule $schedule,
    Dispatcher $dispatcher,
    Cache $cache,
    ExceptionHandler $handler,
)

The schedule supplies events, the dispatcher emits lifecycle events, the cache stores pause and interrupt signals, and the exception handler reports task failures. The first selection is short:

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

Schedule::dueEvents() wraps the registered events in a collection and filters it through each event’s isDue() method. In Laravel 13, isDue() checks three things:

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

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

expressionPasses() obtains the current time through Laravel’s Date facade, converts it to the event timezone when one is configured, and asks dragonmantank/cron-expression whether the five-part expression is due. The environment check accepts the event when it has no environment restriction or when the current application environment appears in its configured list.

This produces a collection of temporally due events. It does not prove that they will execute.

Due does not mean runnable

After dueEvents(), the main command loop applies the remaining gates in a deliberate order.

First, Laravel checks whether scheduled processing has been paused. Laravel 13 provides schedule:pause and schedule:continue, which store the state in the application cache. A task is skipped while paused unless it has been marked with evenWhenPaused().

Next, filtersPass() evaluates the callbacks registered through when() and skip(). All when() callbacks must return a truthy value, while every skip() callback must remain false. Laravel dispatches ScheduledTaskSkipped when the pause check or these filters reject an event.

Then onOneServer() is considered. Schedule::serverShouldRun() asks the scheduling mutex to acquire a cache lock based on the event’s mutex name and the scheduler’s start minute. In a multi-server deployment, only the process that acquires this lock continues to the event. All scheduler nodes must use a shared, lock-capable cache for this coordination to work as intended.

Only after those checks does ScheduleRunCommand call runEvent(). A compact view of the path is:

text
registered event
    -> maintenance mode, cron expression, environment
    -> scheduler pause
    -> when() and skip() filters
    -> onOneServer() scheduling mutex
    -> withoutOverlapping() event mutex
    -> execute callback or command process

withoutOverlapping() belongs one level lower. Event::run() attempts to acquire the event mutex and returns early if another execution still owns it. This mutex prevents two executions of the same task from overlapping over time; the onOneServer() scheduling mutex chooses one server for a particular due minute. They solve different concurrency problems and use different lock names and lifetimes.

By default, the overlap mutex expires after 1,440 minutes. The value is a safety net for a process that terminates without releasing its lock, not a recommended task duration. Laravel normally releases the mutex when a foreground event finishes, and it can release it on supported termination signals. A stale lock can be cleared with php artisan schedule:clear-cache, but doing so while the original task is still running removes the protection against overlap.

Callback events require a name before withoutOverlapping() or onOneServer() can be used. Their default mutex identity would otherwise not distinguish anonymous callbacks reliably:

php
Schedule::call(new RefreshPartnerData)
    ->name('refresh-partner-data')
    ->withoutOverlapping()
    ->onOneServer()
    ->everyFiveMinutes();

What runEvent() actually coordinates

runEvent() prepares a readable command summary, then wraps execution in a console task. Before delegating to the event, it dispatches ScheduledTaskStarting and records a start time. When Event::run() returns, it dispatches ScheduledTaskFinished with the elapsed time.

For a normal Event, the work continues inside Event::run():

  1. Acquire the overlap mutex when required.
  2. Register signal-aware mutex cleanup.
  3. Invoke callbacks registered with before().
  4. Build and execute the command process.
  5. Store its exit code.
  6. Invoke the appropriate after, success, and failure callbacks.
  7. Release the overlap mutex in a finally block.

The command is executed from the application’s base path using Symfony Process. Foreground output is redirected to the configured destination, /dev/null by default on Unix-like systems and NUL on Windows. sendOutputTo() and appendOutputTo() replace that destination, while the email-output methods arrange to read the captured output after execution.

A CallbackEvent inherits the surrounding lifecycle but replaces process execution with a container call. Returning exactly false from the callback becomes exit code 1; other returned values become exit code 0. An exception is captured, converted to a failed exit code for cleanup, and then rethrown so ScheduleRunCommand can dispatch ScheduledTaskFailed and report it through Laravel’s exception handler.

For foreground commands, a non-zero exit code is also turned into an exception after the finished event has been dispatched. ScheduledTaskFinished therefore means that execution returned, not that it succeeded. Listeners may observe both ScheduledTaskFinished and ScheduledTaskFailed for a foreground command that exits unsuccessfully. The failure is caught and reported inside runEvent(), allowing the scheduler loop to continue with later events.

One smaller detail is easy to miss when building monitoring around scheduler events: the pause and filter checks explicitly dispatch ScheduledTaskSkipped, while the overlap check happens inside Event::run() after ScheduledTaskStarting. The event records skippedBecauseOverlapping, but the outer command has already entered its execution lifecycle.

Sequential and background execution

Events due at the same time run sequentially in the order they were registered. If the first foreground command takes five minutes, later events in that schedule:run process wait five minutes before they start. Meanwhile, cron may start another scheduler process at the next minute boundary. Laravel does not place one global mutex around the complete schedule:run command, so concurrency should be controlled on the tasks that need it.

For independent command or system-command events, runInBackground() avoids blocking the loop:

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

Background execution is available only for events created through command() and exec(), not closures or invokable callbacks. CommandBuilder constructs a platform-specific shell command that starts the task asynchronously. On completion, that shell command invokes Laravel’s hidden schedule:finish command with the event mutex name and actual exit code.

ScheduleFinishCommand boots a fresh Laravel process, rebuilds the schedule, locates the matching event, calls its finish() method, and dispatches ScheduledBackgroundTaskFinished. The rebuild is another reason scheduled definitions should be deterministic and inexpensive to register.

Why one cron entry can run tasks every second

Cron still starts Laravel once per minute. Sub-minute scheduling works because schedule:run may remain alive for the rest of that minute.

If the due collection contains an event configured with everySecond(), everyTenSeconds(), or another repeat interval below one minute, Laravel enters repeatEvents(). The loop runs until the end of the minute captured when ScheduleRunCommand was constructed. On each pass it:

  • checks the interrupt signal;
  • checks whether each event’s repeat interval has elapsed;
  • rechecks maintenance and pause state;
  • reevaluates when() and skip() filters;
  • repeats single-server coordination when configured;
  • sleeps for 100 milliseconds before polling again.

A slow foreground task inside this loop delays later repetitions. The Laravel documentation therefore recommends that sub-minute schedules dispatch queued jobs or start background commands, keeping the scheduler itself free to maintain timing:

php
Schedule::job(new DeleteExpiredSessions)->everyTenSeconds();

Schedule::command('metrics:sample')
    ->everyTenSeconds()
    ->runInBackground();

There is also a deployment consequence. A scheduler process already inside the loop continues using the code loaded before the deployment. Running php artisan schedule:interrupt after the new release is live sets a cache signal that asks the old loop to stop. The next cron invocation boots the new code.

For local development, php artisan schedule:work keeps a foreground worker alive and invokes the scheduler every minute. It is a development convenience, not a replacement for a production process manager or platform scheduler.

Inspecting the schedule Laravel actually built

crontab -l can only confirm that the external launcher exists. To inspect the application-level events produced during the current boot, use:

bash
php artisan schedule:list

ScheduleListCommand receives its own process-local Schedule instance and wraps either events() or eventsForEnvironments() in a collection. Laravel 13 exposes four useful options:

text
--timezone=          Display times in this timezone
--environment=*     Display tasks configured for an environment
--next               Sort tasks by their next due date
--json               Return machine-readable JSON

Without --next, events remain in definition order. With --environment=production, Laravel filters by the event’s configured environment list; it does not temporarily change APP_ENV or evaluate whether the event is due now.

The JSON representation contains these fields:

text
expression
command
description
next_due_date
next_due_date_human
timezone
has_mutex
repeat_seconds
environments

There are a few useful limits to that output.

next_due_date is the next time allowed by the cron expression and, for an active sub-minute window, the repeat interval. It does not predict maintenance mode, pause state, when() and skip() results, overlap locks, or which server will acquire an onOneServer() mutex. It is the next temporal opportunity, not a guarantee that the task will execute.

The timezone field is the selected display timezone, which defaults to config('app.timezone'). Laravel may convert the displayed cron expression to that timezone, and one event can produce multiple displayed expressions when a day boundary makes the conversion more complex.

has_mutex performs a live EventMutex::exists() check. It reports whether the event mutex exists at the moment the list is generated; it is not a boolean copy of the withoutOverlapping configuration and does not describe the separate scheduling mutex used by onOneServer().

Finally, schedule:list lists scheduled events, not every Artisan command registered with the application. Use php artisan list for the latter. When you need to execute one registered scheduled event directly, Laravel also provides:

bash
php artisan schedule:test

Without --name, it presents an interactive selection. schedule:test --name=reports:generate can select a uniquely matching scheduled Artisan command. The test command forces a normally background event to run in the foreground, which is useful for observing its output, but it is still real execution against the current application environment.

A practical way to debug a task that did not run

The internal order suggests a more precise checklist than starting with cron syntax alone.

  1. Use crontab -l, your platform dashboard, or process-manager configuration to confirm that something invokes schedule:run every minute from the correct release directory.
  2. Run php artisan schedule:list --next -v in the same environment and release. Confirm that the event exists, its cron expression is correct, and its next due time uses the timezone you expect.
  3. Check maintenance mode, APP_ENV, scheduler pause state, and any when() or skip() callbacks.
  4. Inspect the cache store used for withoutOverlapping() and onOneServer(). A local cache on each node cannot coordinate multiple servers.
  5. Check the task’s configured output, Laravel logs, and listeners for scheduler lifecycle events.
  6. Use schedule:test only when running the task immediately is safe.

The distinction to keep throughout the investigation is simple: registered, due, eligible, started, and completed are different states. Laravel’s scheduler moves an event through them one check at a time.

Laravel 13 scheduler source map

The relevant implementation is small enough to read directly:

  • Schedule stores events and provides the fluent entry points.
  • Event handles command execution, hooks, output, exit codes, and overlap mutexes.
  • CallbackEvent executes closures, callables, invokable objects, and scheduled job dispatches.
  • ScheduleRunCommand selects and coordinates due events.
  • ScheduleListCommand renders the schedule reconstructed for the current process.
  • CommandBuilder creates foreground and background shell commands.
  • ScheduleFinishCommand completes background events in a new Artisan process.

The public scheduling API remains compact because these classes divide the work carefully. The cron daemon wakes Laravel. The application reconstructs the schedule. isDue() handles time, maintenance mode, and environment, then the runner applies operational constraints before the event deals with locking and execution. Once those boundaries are visible, both the source and the failures seen in production become much easier to reason about.

Read Next