skip to content

How do you capture a Laravel scheduled command's output and get alerted when it fails, using sendOutputTo(), onFailure() and thenPing()?

level: middleimportance: should knowfreq 40%

answer

  1. default output goes to /dev/null
  2. sendOutputTo overwrites, appendOutputTo appends
  3. failure means non-zero exit code
  4. Stringable $output in the hook
  5. ping URLs for heartbeat monitors

basics

~20 s

Output of scheduled commands is discarded unless you chain sendOutputTo() or appendOutputTo(). onSuccess()/onFailure() run on exit code zero or non-zero and can receive the output as a Stringable; thenPing() and pingOnFailure() hit URLs for external monitors.

solid answer

~40 s

By default a scheduled command's output goes to `/dev/null`. `->sendOutputTo($path)` writes it to a file (overwriting), `->appendOutputTo($path)` appends, and `->emailOutputTo()` / `->emailOutputOnFailure()` mail it; these output methods are for `command` and `exec` tasks only. Hooks: `before()`, `after()` (alias of `then()`), `onSuccess()` and `onFailure()`; failure means a non-zero exit code, and for closures a `false` return or an exception. Type-hint `Stringable $output` in a hook to receive the output — the scheduler then captures it to a log file automatically. `pingBefore()`, `thenPing()`, `pingOnSuccess()`, `pingOnFailure()` and their `...If()` variants send a GET request to a URL, which suits heartbeat monitors; ping errors are reported, not thrown. Remember the gap: hooks only fire for runs that happen, so detecting a task that never ran needs a heartbeat that alerts on silence.

code

php · 15 lines
php
<?php

use App\Notifications\ReportRunFailed;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Schedule;
use Illuminate\Support\Stringable;

Schedule::command('reports:generate')
    ->fridays()->at('17:00')
    ->appendOutputTo(storage_path('logs/reports.log'))
    ->onFailure(function (Stringable $output) {
        Notification::route('mail', '[email protected]')
            ->notify(new ReportRunFailed((string) $output));
    })
    ->pingOnSuccessIf(app()->isProduction(), config('services.monitor.reports_url'));

go deeper

for a junior

Know that command output is discarded by default and that sendOutputTo(), appendOutputTo() and onFailure() capture it and react to failure.

for a middle

Explain exit-code-based success, how closures map to exit codes, the Stringable $output hook parameter, and the ping helpers.

for a senior

Build monitoring that catches both failures and missing runs: failure hooks plus an external heartbeat, with scheduler events logged centrally.

for a principal

Standardise how every critical scheduled task is observed: expected cadence, output retention, and who is alerted on failure versus silence.

## Where output goes by default A scheduled Artisan or shell command is a child process. Unless told otherwise, the scheduler redirects its standard output and error to `/dev/null` (`NUL` on Windows). If `reports:generate` prints "0 rows processed" every night, nobody sees it. | Method | Effect | | --- | --- | | `->sendOutputTo($path)` | Redirects output to a file, **overwriting** it each run | | `->appendOutputTo($path)` | Redirects output to a file, **appending** | | `->emailOutputTo($address)` | Mails the captured output after the run (if there is any) | | `->emailOutputOnFailure($address)` | Mails it only when the exit code is non-zero | | `->storeOutput()` | Captures to `storage/logs/schedule-<hash>.log` | The documentation marks the output methods as exclusive to `Schedule::command()` and `Schedule::exec()`. A `Schedule::call()` closure runs inside `schedule:run` and has no separate stream to redirect; log from inside the closure instead. Mailing output needs a configured mailer. ## Hooks and what "failure" means ```php Schedule::command('reports:generate') ->daily() ->before(fn () => Log::info('Report run starting')) ->onSuccess(fn (Stringable $output) => Log::info('Report done', ['out' => (string) $output])) ->onFailure(fn (Stringable $output) => Log::error('Report failed', ['out' => (string) $output])); ``` - `before()` runs just before the task. - `after()` — an alias of `then()` — runs after every run. - `onSuccess()` runs when the exit code is `0`; `onFailure()` when it is anything else. - For a closure task, the scheduler derives the exit code itself: returning `false` or throwing counts as failure. Hook closures are called through the container, so they can type-hint services. If a hook type-hints `Illuminate\Support\Stringable $output`, the scheduler makes sure output is captured — using the file you set, or an automatic log file under `storage/logs` — and passes its contents in. For a foreground command, `schedule:run` also reports a non-zero exit code through the exception handler and dispatches `ScheduledTaskFailed`, so failures reach your normal error logging even without a hook. ## Pinging URLs Heartbeat and cron-monitoring services give you a URL to call when a job starts or ends. The scheduler has built-in helpers: 1. `->pingBefore($url)` — before the run. 2. `->thenPing($url)` — after every run. 3. `->pingOnSuccess($url)` / `->pingOnFailure($url)` — depending on the exit code. 4. `->pingBeforeIf($condition, $url)`, `->thenPingIf(...)`, `->pingOnSuccessIf(...)`, `->pingOnFailureIf(...)` — only when the condition is true, handy for production-only monitoring. Each ping is a GET request made with Guzzle (a bound client if you registered one, otherwise a new client with a 10-second connect timeout and 30-second timeout). If the request fails, the exception is **reported**, not thrown, so a monitoring outage cannot fail the task. ## The gap hooks cannot close Every hook runs as part of a run. If the task never starts — cron is broken, the host is down, the app is in maintenance mode, or a stale overlap lock makes the scheduler skip it — no hook fires. That is why the most useful monitoring is a **heartbeat**: `pingOnSuccess()` to a service that expects a ping every day and alerts when one does not arrive. It catches both failed runs and missing ones. ## A practical setup for a nightly report - `appendOutputTo(storage_path('logs/reports.log'))` for a history of runs. - `onFailure()` that notifies the on-call channel with the output tail. - `pingOnSuccess()` to an external monitor configured with the expected daily cadence. - Scheduler events (`ScheduledTaskFailed`, `ScheduledTaskSkipped`) logged centrally for anything the hooks miss. ## Background tasks With `runInBackground()`, the after, success and failure hooks and the ping-after helpers run in the separate `schedule:finish` process when the command ends, not in `schedule:run`. They still fire; they just fire later and elsewhere. ## Common mistakes - **Using `sendOutputTo()` for a history.** It truncates the file on every run, so only the last run survives; use `appendOutputTo()` and rotate the file. - **Expecting output from a closure.** The output methods do nothing useful for `Schedule::call()`; log from inside the closure. - **Treating stderr as failure.** A command that prints warnings but exits `0` is a success; return a non-zero exit code from the command when it really failed. - **Letting monitoring break the job.** Ping helpers already swallow and report HTTP errors; a hand-written hook that calls an API should do the same. - **Alerting only on failure.** Without a heartbeat, a task that silently stopped running looks exactly like a task that is healthy.

  • Does a Laravel scheduled closure that returns false trigger its onFailure() hook?
    Yes. A closure task has no process exit code, so the scheduler derives one: a `false` return or a thrown exception gives exit code 1, and anything else gives 0. `onFailure()` runs for exit code 1. A thrown exception is also reported and fires `ScheduledTaskFailed`.
  • Why use pingOnSuccess() to an external monitor rather than only onFailure() for a critical Laravel task?
    `onFailure()` fires only when a run happens and fails. A task that never runs — broken cron, host down, maintenance mode, stale overlap lock — produces no hook at all. A monitor that expects a success ping on a cadence alerts on silence, covering both failed and missing runs.

saying these in an interview costs you the question

  • Laravel logs every scheduled command's output to laravel.log by default.
  • sendOutputTo() appends to the file on each run.
  • onFailure() fires when a scheduled task is skipped or never starts.
  • A failed thenPing() request makes the scheduled task fail.
  • sendOutputTo() captures the output of Schedule::call() closures.