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:
| Event | When |
|---|---|
ScheduledTaskStarting | task is about to run |
ScheduledTaskFinished | task finished; carries $runtime in seconds |
ScheduledTaskFailed | task threw; carries $exception |
ScheduledTaskSkipped | a filter or an overlap prevented the run |
ScheduledBackgroundTaskFinished | a 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 awhen()/environments()one. It is not, and this trips people up:withoutOverlapping()is implemented as askip()filter, so an overlapping task is rejected byfiltersPass()beforeEvent::run()is ever entered — and that flag is only set insiderun(). On an ordinary overlap it is stillfalsewhen 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.withoutOverlappingandmutex— 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.