Skip to content

[12.x] Add schedule:why-not command for debugging scheduler failures#59129

Open
OthmanHaba wants to merge 2 commits intolaravel:masterfrom
OthmanHaba:feature/improv-scheduler-debuging
Open

[12.x] Add schedule:why-not command for debugging scheduler failures#59129
OthmanHaba wants to merge 2 commits intolaravel:masterfrom
OthmanHaba:feature/improv-scheduler-debuging

Conversation

@OthmanHaba
Copy link

Summary

This PR adds a new php artisan schedule:why-not Artisan command that helps developers diagnose why their scheduled tasks are failing or not running. It addresses a common pain point: when a scheduled task silently fails or gets skipped, there's currently no built-in way to understand what went wrong.

The command combines two sources of information:

  1. Failure history — A ScheduleFailureLogger listens to ScheduledTaskFailed and ScheduledTaskSkipped events during schedule:run and writes JSON line entries to storage/logs/schedule-failures.json. The log auto-rotates at 1,000 entries.

  2. Real-time diagnostics — For each registered scheduled event, the command checks:

    • Whether the cron expression currently matches (due / not due)
    • Whether a mutex is active (overlapping prevention)
    • Whether the event's environment restriction matches the current environment
    • Whether the application is in maintenance mode
    • Whether the event's filter/reject callbacks pass

Example output

$ php artisan schedule:why-not

+------------------------------------------+---------+--------------------------------------+-----------------------------------------------+---------------------------+
| Command                                  | Status  | Diagnostics                          | Last Failure                                  | Last Failed At            |
+------------------------------------------+---------+--------------------------------------+-----------------------------------------------+---------------------------+
| php artisan inspire                      | FAILED  | Due, No mutex                        | RuntimeException: Connection to database lost | 2026-03-08T10:30:00+00:00 |
| php artisan migrate:status               | SKIPPED | Not due, No mutex                    | —                                             | 2026-03-08T09:00:00+00:00 |
| php artisan queue:work --stop-when-empty | OK      | Wrong environment, Not due, No mutex | —                                             | —                         |
+------------------------------------------+---------+--------------------------------------+-----------------------------------------------+---------------------------+

Command options

Option Description
--json Output results as JSON for programmatic use
--event=<name> Filter to a specific command or description
--limit=N Number of failure log entries to consider per event (default: 1)

New files

  • src/Illuminate/Console/Scheduling/ScheduleFailureLogger.php — Event listener that writes failure/skip entries to the JSON log with automatic rotation
  • src/Illuminate/Console/Scheduling/ScheduleWhyNotCommand.php — The Artisan command that reads the log and runs real-time diagnostics
  • tests/Integration/Console/Scheduling/ScheduleFailureLoggerTest.php — 3 tests covering failure logging, skip logging, and log rotation
  • tests/Integration/Console/Scheduling/ScheduleWhyNotCommandTest.php — 7 tests covering empty schedule, no-failure display, failure display, skip display, JSON output, event filtering, and the limit option

Modified files

  • Event.php — Made expressionPasses() public (was protected) so the diagnostic command can check if a task's cron expression currently matches
  • ScheduleRunCommand.php — Added registerFailureLogger() method that wires up the ScheduleFailureLogger as a listener for ScheduledTaskFailed and ScheduledTaskSkipped events during schedule:run
  • ArtisanServiceProvider.php — Registered the new ScheduleWhyNotCommand in the $commands array

Design decisions

  • JSON lines format for the failure log — one JSON object per line, easy to append and parse without loading the entire file as a JSON document
  • Log rotation at 1,000 entries — prevents unbounded growth while keeping enough history for debugging
  • Listener registered in ScheduleRunCommand::handle() rather than globally — the logger only needs to be active during scheduler execution, keeping it scoped and avoiding boot overhead for non-scheduler requests
  • No database dependency — uses a simple log file so the command works immediately without migrations

Test plan

  • ScheduleFailureLoggerTest — 3 tests, all passing
  • ScheduleWhyNotCommandTest — 7 tests, all passing
  • Full scheduling test suite — 138 tests, all passing (2 pre-existing skips)
  • Manually tested in a Laravel application referencing the framework via path repository

@OthmanHaba OthmanHaba marked this pull request as draft March 8, 2026 00:12
@shaedrich
Copy link
Contributor

Event.php — Made expressionPasses() public (was protected) so the diagnostic command can check if a task's cron expression currently matches

This probably means, your PR should target master

*/
public function __construct(
protected Filesystem $files,
protected int $maxEntries = 1000,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this be configurable?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor already accepts $maxEntries as a parameter, so it's configurable at the code level when instantiating the logger. Adding a config file entry for this felt unnecessary since it's a framework-internal default.

* Create a new failure logger instance.
*
* @param \Illuminate\Filesystem\Filesystem $files
* @param int $maxEntries
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can be more explicit here:

Suggested change
* @param int $maxEntries
* @param non-negative-int $maxEntries

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — updated to non-negative-int.

/**
* Write an entry to the failure log.
*
* @param array $entry
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can type the array:

Suggested change
* @param array $entry
* @param array{
* timestamp: string,
* command: string,
* description: string,
* type: 'failed'|'skipped',
* exit_code: int|null,
* exception: string,
* mutex: string
* } $entry

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — added array shape annotation. Adjusted slightly since exit_code and exception are only present on failed entries, so those keys are marked optional.

*
* @param \Illuminate\Console\Scheduling\Event $event
* @param \Illuminate\Support\Collection $failures
* @return array
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here:

Suggested change
* @return array
* @return array{
* command: string,
* status: 'OK'|'FAILED'|'SKIPPED',
* diagnostics: string,
* last_failure: string,
* last_failed_at: string
* }

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — added return array shape and typed $failures param.

Comment on lines +113 to +148
/**
* Get the real-time diagnostics for an event.
*
* @param \Illuminate\Console\Scheduling\Event $event
* @return string
*/
protected function getDiagnostics(Event $event)
{
$issues = [];

if (! $event->runsInEnvironment($this->laravel->environment())) {
$issues[] = 'Wrong environment';
}

if (! $event->runsInMaintenanceMode() && $this->laravel->isDownForMaintenance()) {
$issues[] = 'In maintenance';
}

if (! $event->filtersPass($this->laravel)) {
$issues[] = 'Filters failing';
}

if (! $event->expressionPasses()) {
$issues[] = 'Not due';
} else {
$issues[] = 'Due';
}

if ($event->mutex->exists($event)) {
$issues[] = 'Mutex active';
} else {
$issues[] = 'No mutex';
}

return implode(', ', $issues);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be interesting to explore having the diagnostics as enum array and concat them in buildRow():

namespace Illuminate\Console\Scheduling;

enum ScheduleDiagnostic: string
{
    case WrongEnvironment = 'Wrong environment';
    case InMaintenance = 'In maintenance';
    case FiltersFailing = 'Filters failing';
    case NotDue = 'Not due';
    case Due = 'Due';
    case MutexActive = 'Mutex active';
    case NoMutex = 'No mutex';
}
Suggested change
/**
* Get the real-time diagnostics for an event.
*
* @param \Illuminate\Console\Scheduling\Event $event
* @return string
*/
protected function getDiagnostics(Event $event)
{
$issues = [];
if (! $event->runsInEnvironment($this->laravel->environment())) {
$issues[] = 'Wrong environment';
}
if (! $event->runsInMaintenanceMode() && $this->laravel->isDownForMaintenance()) {
$issues[] = 'In maintenance';
}
if (! $event->filtersPass($this->laravel)) {
$issues[] = 'Filters failing';
}
if (! $event->expressionPasses()) {
$issues[] = 'Not due';
} else {
$issues[] = 'Due';
}
if ($event->mutex->exists($event)) {
$issues[] = 'Mutex active';
} else {
$issues[] = 'No mutex';
}
return implode(', ', $issues);
}
/**
* Get the real-time diagnostics for an event.
*
* @param \Illuminate\Console\Scheduling\Event $event
* @return \Illuminate\Console\Scheduling\ScheduleDiagnostic[];
*/
protected function getDiagnostics(Event $event)
{
$issues = [];
if (! $event->runsInEnvironment($this->laravel->environment())) {
$issues[] = 'Wrong environment';
}
if (! $event->runsInMaintenanceMode() && $this->laravel->isDownForMaintenance()) {
$issues[] = 'In maintenance';
}
if (! $event->filtersPass($this->laravel)) {
$issues[] = 'Filters failing';
}
if (! $event->expressionPasses()) {
$issues[] = 'Not due';
} else {
$issues[] = 'Due';
}
if ($event->mutex->exists($event)) {
$issues[] = 'Mutex active';
} else {
$issues[] = 'No mutex';
}
return implode(', ', $issues);
}
        return [
            'command' => $command,
            'status' => $status,
-           'diagnostics' => $diagnostics,
+           'diagnostics' => implode(', ', array_map(enum_value(...), $diagnostics)),
            'last_failure' => $lastFailure['exception'] ?? $lastFailure['reason'] ?? '—',
            'last_failed_at' => $lastFailure['timestamp'] ?? '—',
        ];

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting idea, but these strings are only used for display output — they're not consumed programmatically or compared anywhere. Introducing an enum for internal display-only values adds a new file and indirection without a clear consumer benefit. If the diagnostics become part of a public API or need to be matched on, an enum would make sense then.

* Build a row of diagnostic data for the given event.
*
* @param \Illuminate\Console\Scheduling\Event $event
* @param \Illuminate\Support\Collection $failures
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and here:

Suggested change
* @param \Illuminate\Support\Collection $failures
* @param \Illuminate\Support\Collection<int, array{
* mutex: string|null,
* command: string|null,
* exception: string|null,
* reason: string|null,
* timestamp: string|null
* }> $failures

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — covered in the same commit as the buildRow return type.

return $value;
}

return mb_substr($value, 0, $length - 3).'...';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ellipsis (U+2026) would be more correct:

Suggested change
return mb_substr($value, 0, $length - 3).'...';
return mb_substr($value, 0, $length - 3).'';

Technically, if it is cut right at the end of the word, there has to be a space between the last character and the ellipsis.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — switched to (U+2026), consistent with RouteListCommand.

@OthmanHaba OthmanHaba changed the base branch from 12.x to master March 8, 2026 03:13
Add a new `php artisan schedule:why-not` Artisan command that helps
diagnose why scheduled tasks are failing or not running.

The command combines two sources of information:

1. A failure log (JSON lines at storage/logs/schedule-failures.json)
   that records ScheduledTaskFailed and ScheduledTaskSkipped events
   during schedule:run execution.

2. Real-time diagnostics that check each event's current state:
   whether it's due, environment restrictions, maintenance mode,
   filter conditions, and mutex status.

New files:
- ScheduleFailureLogger: listens to scheduler events and writes
  failure/skip entries to a JSON log with automatic rotation
- ScheduleWhyNotCommand: reads the log and runs diagnostics,
  outputs a table or JSON

Also makes Event::expressionPasses() public so the command can
check whether a task's cron expression currently matches.
@OthmanHaba OthmanHaba force-pushed the feature/improv-scheduler-debuging branch from e0fbb73 to c1297e8 Compare March 8, 2026 03:15
- Use non-negative-int for $maxEntries parameter
- Add array shape annotations for $entry, $failures, and buildRow return
- Use Unicode ellipsis (U+2026) for truncation, matching RouteListCommand
@OthmanHaba OthmanHaba marked this pull request as ready for review March 8, 2026 04:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants