Otomatisasi yang gagal secara diam-diam adalah mimpi buruk operasional: laporan harian berhenti terkirim, cache tidak pernah dibersihkan, dan tidak ada seorang pun yang menyadarinya selama berminggu-minggu. Scheduler berjalan di luar request cycle, tanpa user yang komplain ketika error muncul — sehingga observability bukan opsional melainkan bagian dari desain. Pada chapter ini kita susun empat lapis pemantauan: inventarisasi jadwal, penangkapan output, pencatatan kegagalan, dan heartbeat untuk mendeteksi cron yang mati. Semuanya dibuktikan nyata di sandbox.

schedule:list sebagai Radar #

Perintah schedule:list selalu menampilkan ekspresi cron mentah beserta perkiraan eksekusi berikutnya — versi verbose (-v) menambahkan nama task dan waktu absolut:

text
  0  * * * *  ringkasan-tugas ........... Next Due: 2026-10-13 21:00:00 +00:00
              ⇁ ringkasan-tugas
  30 2 * * *  '/home/z/bin/php' 'artisan' tasks:prune  Next Due: 2026-10-14 02:30:00 +00:00
              ⇁ prune-tugas
  0  6 * * *  laporan-harian ............ Next Due: 2026-10-14 06:00:00 +00:00
              ⇁ laporan-harian
  0  3 * * 1  echo "backup database mulai"  Next Due: 2026-10-19 03:00:00 +00:00
              ⇁ backup-database

Untuk dashboard eksternal, flag --json menghasilkan data terstruktur — termasuk status mutex dan penanda on_one_server yang memperlihatkan task mana yang sedang terkunci:

text
0 * * * * | ringkasan-tugas | next: 2026-10-13 21:00:00 +00:00 | mutex: false | one_server: false
30 2 * * * | tasks:prune | next: 2026-10-14 02:30:00 +00:00 | mutex: false | one_server: false
0 6 * * * | laporan-harian | next: 2026-10-14 06:00:00 +00:00 | mutex: false | one_server: false
0 3 * * 1 | echo "backup database mulai" | next: 2026-10-19 03:00:00 +00:00 | mutex: false | one_server: false

Menangkap Output Task #

Task berbasis command menghasilkan output proses; sendOutputTo() mengarahkannya ke berkas, dan emailOutputTo() mengirimkannya sebagai email:

php
Schedule::command('tasks:prune')->everyMinute()->name('prune-tugas')
    ->sendOutputTo(storage_path('logs/prune-output.log'))
    ->emailOutputTo('[email protected]');

Setelah schedule:run dieksekusi, berkas storage/logs/prune-output.log berisi:

text
1 tugas selesai lebih dari 30 hari dihapus.

Dengan MAIL_MAILER=log, emailnya juga bisa dibuktikan — seluruh pesan mendarat di storage/logs/laravel.log:

text
[2026-10-13 20:26:01] local.DEBUG: From: Laravel <[email protected]>
To: [email protected]
Subject: prune-tugas
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable

1 tugas selesai lebih dari 30 hari dihapus.

Di production, emailOutputTo berpasangan secara alami dengan SMTP nyata: laporan hasil task rutin tiba di inbox setiap pagi tanpa perlu membuka server. Gunakan appendOutputTo() bila riwayat perlu terkumpul, bukan tertimpa.

Kegagalan Mendarat di Log #

Task yang melempar exception tidak menghentikan scheduler — framework menangkapnya, melaporkannya ke exception handler, lalu melanjutkan menit berikutnya. Definisikan task yang sengaja gagal:

php
Schedule::call(function () {
    throw new RuntimeException('API penyedia data laporan sedang tidak merespons.');
})->everyMinute()->name('sinkronisasi-laporan');

Output yang Dihasilkan (console):

text
  2026-10-13 20:26:14 Running [sinkronisasi-laporan] ............. 6.39ms DONE

Sementara di storage/logs/laravel.log, kegagalan terekam lengkap:

text
[2026-10-13 20:26:14] local.ERROR: API penyedia data laporan sedang tidak merespons. {"exception":"[object] (RuntimeException(code: 0): API penyedia data laporan sedang tidak merespons. at routes/console.php:12)
[stacktrace]
...

Heartbeat: Memantau Cron itu Sendiri #

Semua pemantauan di atas tidak berguna bila schedule:run sendiri tidak pernah terpanggil — misalnya crontab terhapus atau server overload. Solusinya heartbeat: sebuah task yang menulis stempel waktu ke cache setiap menit, dan pemeriksa yang bertanya “kapan terakhir scheduler terdengar?”. Task heartbeat-nya ringkas:

php
Schedule::call(fn () => Cache::put('scheduler:heartbeat', time(), 300))
    ->everyMinute()
    ->name('scheduler-heartbeat');

Sedangkan pemeriksa tinggal membandingkan usia stempel dengan batas wajar — bila cron sehat, usianya tidak akan pernah jauh melebihi dua menit:

php
namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;

class HeartbeatStatus extends Command
{
    protected $signature = 'schedule:heartbeat-status {--max-detik=120 : Batas usia heartbeat terakhir}';

    protected $description = 'Periksa kesehatan scheduler dari cache heartbeat';

    public function handle(): int
    {
        $terakhir = Cache::get('scheduler:heartbeat');

        if ($terakhir === null) {
            $this->error('TIDAK SEHAT: scheduler belum pernah menulis heartbeat.');

            return self::FAILURE;
        }

        $usia = time() - (int) $terakhir;
        $batas = (int) $this->option('max-detik');

        if ($usia > $batas) {
            $this->warn("TIDAK SEHAT: heartbeat terakhir {$usia} detik lalu (batas {$batas} detik).");

            return self::FAILURE;
        }

        $this->info("SEHAT: heartbeat terakhir {$usia} detik lalu.");

        return self::SUCCESS;
    }
}

Tiga skenario pengujian nyata memperlihatkan seluruh cabangnya — scheduler belum pernah jalan, sehat, dan heartbeat basi:

text
== 1. belum ada heartbeat:
TIDAK SEHAT: scheduler belum pernah menulis heartbeat.
exit: 1
== 2. setelah scheduler jalan sekali:
SEHAT: heartbeat terakhir 1 detik lalu.
exit: 0
== 3. heartbeat basi (10 menit lalu):
TIDAK SEHAT: heartbeat terakhir 600 detik lalu (batas 120 detik).
exit: 1

Command ini mengembalikan exit code 1 saat tidak sehat — properti yang membuatnya siap dipakai monitoring eksternal: cron di mesin monitoring bisa menjalankan schedule:heartbeat-status setiap lima menit via SSH dan memicu alert ketika gagal. Prinsip yang sama berlaku untuk pemeriksaan dari sisi lain: alat uptime-checking bisa memanggil command ini lewat HTTP endpoint tipis yang hanya membungkus panggilan artisan.

Satu instrumen kecil yang jarang dipakai namun menyelamatkan saat insiden: schedule:pause menghentikan seluruh eksekusi task tanpa menyentuh crontab, dan schedule:resume mengaktifkannya kembali — berguna ketika database sedang dimigrasikan dan kita tidak ingin task menabrak skema yang setengah jadi. Task kritis bisa menandai evenWhenPaused() agar tetap berjalan di tengah jeda, misalnya pengingat pembayaran yang tidak boleh ikut berhenti.

Untuk tumpukan observability yang lebih dalam, paket seperti Laravel Pulse merekam eksekusi job dan query lambat, sementara Laravel Horizon memberikan dashboard khusus bila antrean Anda berjalan di Redis — dokumentasinya bisa dibuka di dokumentasi Queues . Keduanya melengkapi — bukan menggantikan — pola heartbeat di atas karena beda sasaran pemantauannya.

Dengan scheduler yang terpantau, sisa satu pertanyaan metodologis: bagaimana menguji semua ini dalam rangkaian CI tanpa menunggu menit demi menit? Chapter Pengujian Scheduled Tasks menjawabnya lewat Carbon::setTestNow dan feature test. Rujukan resmi ada di dokumentasi Task Scheduling .