← Blog

Give your Laravel scheduler a memory: a run history in 30 lines

php artisan schedule:list tells you what should happen. It says nothing about what did. When someone asks “did the nightly export run on Sunday?”, the honest answer in most applications is a shrug and a grep through logs that were rotated away.

Laravel already emits everything you need to answer that question properly. The scheduler dispatches events at each transition, and thirty lines of listener turns them into a table you can query. Here’s the whole thing.

The events

Five events, all in Illuminate\Console\Events:

EventWhen
ScheduledTaskStartingtask is about to run
ScheduledTaskFinishedtask finished; carries $runtime in seconds
ScheduledTaskFailedtask threw; carries $exception
ScheduledTaskSkippeda filter or an overlap prevented the run
ScheduledBackgroundTaskFinisheda runInBackground() task finished

Every one of them exposes $event->task, an Illuminate\Console\Scheduling\Event. The parts worth recording:

  • getSummaryForDisplay() — the task’s description, or the command line if none was set.
  • expression — the cron expression, as the scheduler resolved it.
  • exitCode — set once the process has run.
  • skippedBecauseOverlapping — reads like the way to tell an overlap skip from a when()/environments() one. It is not, and this trips people up: withoutOverlapping() is implemented as a skip() filter, so an overlapping task is rejected by filtersPass() before Event::run() is ever entered — and that flag is only set inside run(). On an ordinary overlap it is still false when the event fires. Check the mutex instead; there is a helper for it below.
  • mutexName() — stable per task, which makes it a decent grouping key.
  • withoutOverlapping and mutex — public on the event, which is what makes that check possible.

The table

Schema::create('scheduled_task_runs', function (Blueprint $table) {
    $table->id();
    $table->string('task');
    $table->string('mutex')->index();
    $table->string('expression')->nullable();
    $table->string('status');            // running, ok, failed, skipped
    $table->unsignedInteger('exit_code')->nullable();
    $table->float('runtime')->nullable(); // seconds
    $table->text('error')->nullable();
    $table->timestamp('started_at')->nullable();
    $table->timestamp('finished_at')->nullable();
    $table->index(['mutex', 'started_at']);
});

One row per run. status starts as running and is updated in place, so a row still marked running an hour later is itself a signal: the process died without reaching any terminal event.

The listener

Register it in AppServiceProvider::boot():

use Illuminate\Console\Events\ScheduledBackgroundTaskFinished;
use Illuminate\Console\Events\ScheduledTaskFailed;
use Illuminate\Console\Events\ScheduledTaskFinished;
use Illuminate\Console\Events\ScheduledTaskSkipped;
use Illuminate\Console\Events\ScheduledTaskStarting;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;

public function boot(): void
{
    Event::listen(function (ScheduledTaskStarting $e) {
        DB::table('scheduled_task_runs')->insert([
            'task' => $e->task->getSummaryForDisplay(),
            'mutex' => $e->task->mutexName(),
            'expression' => $e->task->expression,
            'status' => 'running',
            'started_at' => now(),
        ]);
    });

    Event::listen(function (ScheduledTaskFinished $e) {
        // A background task is only *spawned* by the time this fires: its
        // exit code is not known yet and $runtime is the spawn time, not the
        // task's. Leave the row open for ScheduledBackgroundTaskFinished.
        if ($e->task->runInBackground) {
            return;
        }

        // Finished fires for a non-zero exit too, and it fires BEFORE
        // ScheduledTaskFailed does — so the status has to come from the exit
        // code here, not from the event's name.
        $this->closeRun($e->task, [
            'status' => $e->task->exitCode === 0 ? 'ok' : 'failed',
            'exit_code' => $e->task->exitCode,
            'runtime' => round($e->runtime, 3),
        ]);
    });

    Event::listen(function (ScheduledTaskFailed $e) {
        // Two paths arrive here. A closure that throws never reaches
        // Finished, so its row is still open. A command that exits non-zero
        // does reach Finished first, so its row is already closed — and the
        // reason would be lost unless we allow writing to a closed row.
        $this->closeRun($e->task, [
            'status' => 'failed',
            'exit_code' => $e->task->exitCode,
            'error' => $e->exception->getMessage(),
        ], onlyOpen: false);
    });

    Event::listen(function (ScheduledTaskSkipped $e) {
        DB::table('scheduled_task_runs')->insert([
            'task' => $e->task->getSummaryForDisplay(),
            'mutex' => $e->task->mutexName(),
            'expression' => $e->task->expression,
            'status' => 'skipped',
            'error' => $this->skippedByLock($e->task)
                ? 'previous run still holding the lock'
                : 'filtered out (when/skip/environments/between)',
            'started_at' => now(),
            'finished_at' => now(),
        ]);
    });

    Event::listen(function (ScheduledBackgroundTaskFinished $e) {
        $this->closeRun($e->task, [
            'status' => $e->task->exitCode === 0 ? 'ok' : 'failed',
            'exit_code' => $e->task->exitCode,
        ]);
    });
}

private function skippedByLock($task): bool
{
    // skippedBecauseOverlapping alone is not enough: see the note above.
    // The lock is still held by the other process while this runs, so asking
    // the mutex is both simpler and correct.
    return $task->skippedBecauseOverlapping
        || ($task->withoutOverlapping && $task->mutex->exists($task));
}

private function closeRun($task, array $attributes, bool $onlyOpen = true): void
{
    // Find the row first, then update it by primary key. Doing this in one
    // statement (->orderByDesc('id')->limit(1)->update(...)) only works on
    // MySQL — PostgreSQL and SQLite reject UPDATE with ORDER BY / LIMIT.
    $query = DB::table('scheduled_task_runs')
        ->where('mutex', $task->mutexName());

    if ($onlyOpen) {
        $query->where('status', 'running');
    }

    $id = $query->orderByDesc('id')->value('id');

    if ($id === null) {
        return;
    }

    DB::table('scheduled_task_runs')
        ->where('id', $id)
        ->update($attributes + ['finished_at' => now()]);
}

That’s it. Every scheduler tick now leaves a trace.

Three things worth knowing before you ship it

Background tasks finish in another process. A task marked runInBackground() is spawned detached, and its completion is reported when schedule:finish runs in that separate process — which is why it gets its own event and why there’s no $runtime to read. If most of your schedule runs in the background, record durations from started_at/finished_at instead.

The catch is that ScheduledTaskFinished also fires for such a task, in the parent, right after the spawn — with a null exit code and the spawn duration. Close the row there and you get a row that says ok before the task has done anything, which is why the listener above bails out early for background tasks. The cost is that a background row stays running until schedule:finish reports back — and if that process never gets to run, it stays running forever. That is the correct answer, not a bug: nobody told us how the task ended.

A non-zero exit is not an exception. Schedule::exec('...') returning 1 does not throw on its own — the scheduler dispatches ScheduledTaskFinished first and only then raises an exception of its own, which is what produces ScheduledTaskFailed. By that point the row is already written, so whichever status ScheduledTaskFinished recorded is the one that sticks. Read the exit code there, as above, and treat the Failed listener as the path for tasks that genuinely threw — a closure that blows up never reaches Finished at all. The Failed listener still earns its place for exit-code failures, though: it carries the message, and the row it writes to has already been closed by Finished, which is why it is allowed to update a closed row.

One more wrinkle if you schedule closures: withoutOverlapping() on a Schedule::call() task requires a name, and Laravel enforces it by throwing LogicException — “A scheduled event name is required to prevent overlapping. Use the ‘name’ method before ‘withoutOverlapping’.” — while the schedule is being defined. The order matters as much as the name:

Schedule::call($job)->name('nightly-export')->withoutOverlapping(); // fine
Schedule::call($job)->withoutOverlapping()->name('nightly-export'); // throws

Schedule::exec() has no such requirement — the guard lives in CallbackEvent, not in Event. And if you go reading that class, note that shouldSkipDueToOverlapping() there is additionally gated on the description being set; that guard is unreachable through the public API, since every route to withoutOverlapping on a callback — including Schedule::group() — goes through the check above. It is a belt-and-braces line, not a behaviour you can observe.

Skips are not failures, but they are not successes either. Most teams discover this the hard way: a task wrapped in ->environments(['production']) on a server where APP_ENV=prod is skipped forever, and before you log skips that state is indistinguishable from a healthy run. This is the single most valuable row in the table.

Prune it, and be aware of the recursion. The table grows by one row per task per tick:

Schedule::call(fn () => DB::table('scheduled_task_runs')
    ->where('started_at', '<', now()->subDays(30))->delete()
)->daily()->name('prune-scheduled-task-runs');

The prune task is itself a scheduled task, so it writes its own rows. That’s fine — it’s also a nice canary: if the newest row is older than a day, something stopped.

A dashboard in one command

use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;

Artisan::command('schedule:history {--limit=20}', function () {
    $rows = DB::table('scheduled_task_runs')
        ->orderByDesc('id')->limit((int) $this->option('limit'))->get();

    // The two limits are what keeps the table inside 120 columns once task
    // names get realistic: getSummaryForDisplay() returns the whole command
    // line for an exec task, and at 40 characters each these two columns
    // alone pushed the output to 142. 35 is deliberate on the last column —
    // it is the length of "previous run still holding the lock".
    $this->table(
        ['Task', 'Status', 'Exit', 'Runtime', 'Started', 'Why'],
        $rows->map(fn ($r) => [
            (string) str($r->task)->limit(22),
            $r->status,
            // ?? and not ?: — a task that exited 0 must show 0, not a dash.
            $r->exit_code ?? '—',
            $r->runtime ? $r->runtime.'s' : '—',
            $r->started_at,
            (string) str($r->error ?? '—')->limit(35),
        ])->all()
    );
})->purpose('Show recent scheduled task runs');

Now php artisan schedule:history answers the Sunday-export question in a second, and the runtime column quietly warns you about the job that grew from four minutes to forty.

The one thing this cannot tell you

Every event above is dispatched by the scheduler, inside schedule:run. If the cron entry is gone, if the crontab belongs to the wrong user, if the server was rebuilt without its cron config, if the scheduler is paused — no event fires, no row is written, and your history table simply stops growing in silence. Laravel 13 helps a little here by dispatching SchedulePaused and ScheduleResumed, so a forgotten schedule:pause at least leaves a trace. The rest, it cannot see: a process that never starts cannot report that it didn’t.

Closing that last gap needs something outside the application — a monitor that expects a signal on a schedule and raises the alarm when it stops arriving. Laravel ships the hooks for exactly that: pingBefore(), thenPing(), pingOnSuccess() and pingOnFailure() will call any URL you give them, and any dead man’s switch service can receive them — Healthchecks.io, Cronitor, or ours. With CronAlive’s Laravel package the ping URL doesn’t even need wiring: one macro per task, and the check creates itself from the schedule.

Used together the two halves cover each other: the events table tells you what happened and how long it took, and the external heartbeat tells you when nothing happened at all. How the second half works — and why in-process hooks can’t replace it — is the subject of monitoring Laravel scheduled jobs.

The code above was verified on a clean Laravel 13 app before publishing — three of the five listener branches recorded the wrong thing on the first draft. If you find a fourth, write to support@cronalive.com.


CronAlive monitors cron jobs, scheduled tasks and HTTP endpoints: if a job stops pinging on schedule, you get an alert. Start with the guide or see the plans.