Tidak semua pekerjaan latar belakang dipicu oleh aksi pengguna. Backup database harus berjalan setiap dini hari, laporan penjualan dirangkum setiap Senin pagi, dan berkas sementara dibersihkan setiap lima menit — semuanya dipicu oleh jam, bukan oleh request. Kebutuhan ini sudah lama diselesaikan oleh cron, penjadwal bawaan sistem operasi mirip UNIX, dan tetap menjadi fondasi hingga hari ini. Pada chapter ini kita pelajari cara menjalankan script PHP lewat cron, lalu membangun alternatifnya: satu runner PHP dengan jadwal internal yang bisa diuji — termasuk pencocok ekspresi cron yang kita tulis sendiri dan kunci berkas yang mencegah tugas bertumpang tindih dengan dirinya sendiri.

Menjadwalkan Tugas dengan Cron #

Cron membaca jadwal dari crontab — daftar baris yang masing-masing memuat lima kolom waktu diikuti perintah shell. Lima kolom tersebut berurutan menit (0-59), jam (0-23), tanggal (1-31), bulan (1-12), dan hari dalam seminggu (0-7, di mana 0 dan 7 sama-sama berarti Minggu). Tugas dijalankan ketika kelima kolomnya cocok dengan waktu saat ini. Ekspresi yang paling sering dipakai dirangkum berikut:

EkspresiArti
*/5 * * * *Setiap 5 menit
0 * * * *Setiap jam, tepat pada menit ke-0
0 8 * * *Setiap hari pukul 08:00
30 2 * * 1Setiap Senin pukul 02:30
0 9 * * 1-5Hari kerja (Senin sampai Jumat) pukul 09:00
0 0 1 * *Tanggal 1 setiap bulan, tengah malam

Tanda * berarti “nilai apa pun”, */5 berarti “setiap 5 satuan”, rentang 1-5 berarti “1 sampai 5”, dan daftar dipisah koma. Untuk menyunting jadwal milik user yang sedang login, kita memakai perintah crontab:

bash
# Sunting jadwal untuk user aktif (editor default akan terbuka)
crontab -e

# Tampilkan jadwal yang sedang berjalan
crontab -l

Berkas crontab milik pengguna tidak perlu menyebut nama pemilik — semua baris dijalankan sebagai user tersebut. Setiap baris dijalankan pada waktunya masing-masing secara independen, sehingga dua jadwal yang kebetulan bertabrakan pada menit yang sama akan berjalan bersamaan.

Menjalankan Script PHP dari Cron #

Cara paling langsung menjadwalkan tugas PHP adalah mendaftarkan script CLI ke crontab. Ada dua hal yang wajib diperhatikan: gunakan path interpreter PHP secara absolut, dan arahkan keluaran script ke berkas log agar pesan error tidak menguap:

bash
# Cari path interpreter PHP yang dipakai di terminal
which php
# contoh keluaran: /usr/bin/php
bash
# Setiap hari pukul 02:15: backup database
15 2 * * * /usr/bin/php /var/www/app/scripts/backup-database.php >> /var/log/app/backup.log 2>&1

# Setiap 5 menit: bersihkan berkas sementara
*/5 * * * * /usr/bin/php /var/www/app/scripts/bersihkan-log.php >> /var/log/app/cleanup.log 2>&1

Pengalihan >> berkas.log 2>&1 menampung keluaran normal maupun pesan error ke berkas log yang sama. Tanpa pengalahan ini, cron mengirimkan setiap byte keluaran lewat email lokal user — mekanisme yang hampir selalu berakhir di folder yang tidak pernah dibaca siapa pun. Script yang dijadwalkan juga sebaiknya bersuara hanya saat ada hal penting: tulis log ringkas per eksekusi, tetapi kirim notifikasi (misalnya lewat job queue) hanya saat terjadi kegagalan.

Runner dengan Jadwal Internal #

Cron satu baris per tugas cepat menjadi sulit dikelola: jadwal tersebar di banyak server, tidak ikut tersimpan di repositori kode, dan tidak bisa diuji. Solusinya adalah membalik arsitektur — biarkan cron hanya menjalankan satu runner per menit, lalu simpan seluruh jadwal di dalam kode PHP. Runner seperti ini membutuhkan satu kemampuan inti: menentukan apakah sebuah ekspresi cron cocok dengan waktu sekarang. Mari kita bangun pencocok versi sederhana:

php
<?php
function parseField(string $field, int $min, int $max): array
{
    $values = [];
    foreach (explode(",", $field) as $part) {
        if ($part === "*") {
            for ($v = $min; $v <= $max; $v++) {
                $values[] = $v;
            }
        } elseif (str_starts_with($part, "*/")) {
            $step = (int) substr($part, 2);
            for ($v = $min; $v <= $max; $v += $step) {
                $values[] = $v;
            }
        } else {
            $values[] = (int) $part;
        }
    }

    return array_values(array_unique($values));
}

function shouldRun(string $cron, DateTimeImmutable $now): bool
{
    [$menit, $jam, $tanggal, $bulan, $hari] = preg_split("/\s+/", trim($cron));

    // cron memakai angka 0 atau 7 untuk hari Minggu; format('w') memakai 0-6
    $hariValues = parseField($hari, 0, 7);
    if (in_array(7, $hariValues, true)) {
        $hariValues[] = 0;
    }

    return in_array((int) $now->format("i"), parseField($menit, 0, 59), true)
        && in_array((int) $now->format("H"), parseField($jam, 0, 23), true)
        && in_array((int) $now->format("d"), parseField($tanggal, 1, 31), true)
        && in_array((int) $now->format("n"), parseField($bulan, 1, 12), true)
        && in_array((int) $now->format("w"), $hariValues, true);
}

$hariIndonesia = ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"];

$jadwal = [
    "*/5 * * * *" => "setiap 5 menit",
    "0 8 * * *"   => "setiap hari pukul 08:00",
    "30 2 * * 1"  => "Senin pukul 02:30",
];

$waktuUji = [
    "2026-01-15 08:05:00",
    "2026-01-15 08:07:00",
    "2026-01-19 02:30:00",
];

foreach ($waktuUji as $waktu) {
    $now = new DateTimeImmutable($waktu, new DateTimeZone("Asia/Jakarta"));
    $hari = $hariIndonesia[(int) $now->format("w")];
    echo $now->format("d-m-Y H:i") . " ({$hari}):\n";
    foreach ($jadwal as $cron => $label) {
        $status = shouldRun($cron, $now) ? "JALANKAN" : "lewati";
        echo "  {$cron} [{$label}] -> {$status}\n";
    }
}

Output yang Dihasilkan:

text
15-01-2026 08:05 (Kamis):
  */5 * * * * [setiap 5 menit] -> JALANKAN
  0 8 * * * [setiap hari pukul 08:00] -> lewati
  30 2 * * 1 [Senin pukul 02:30] -> lewati
15-01-2026 08:07 (Kamis):
  */5 * * * * [setiap 5 menit] -> lewati
  0 8 * * * [setiap hari pukul 08:00] -> lewati
  30 2 * * 1 [Senin pukul 02:30] -> lewati
19-01-2026 02:30 (Senin):
  */5 * * * * [setiap 5 menit] -> JALANKAN
  0 8 * * * [setiap hari pukul 08:00] -> lewati
  30 2 * * 1 [Senin pukul 02:30] -> JALANKAN

Mari kita bedah cara kerjanya:

  1. parseField() menerjemahkan satu kolom cron menjadi daftar angka: * menjadi seluruh rentang, */5 menjadi 0, 5, 10, dan seterusnya, sedangkan angka biasa menjadi satu elemen. Daftar dipisah koma juga didukung.
  2. shouldRun() memecah ekspresi menjadi lima kolom, lalu memeriksa apakah waktu $now berada di kelima daftar sekaligus — operator && memastikan kelima kolom harus cocok, persis semantik cron.
  3. Normalisasi hari Minggu menangani keunikan cron yang menerima 0 maupun 7, sementara format("w") milik PHP hanya menghasilkan 0-6.
  4. Waktu uji dibekukan (2026-01-15 adalah Kamis, 2026-01-19 adalah Senin) sehingga tabel keluaran stabil. Perhatikan baris terakhir: pada Senin 02:30, dua jadwal aktif sekaligus — */5 memang menyala setiap lima menit termasuk menit ke-30, dan itu perilaku cron yang sesungguhnya.

Dengan pencocok di tangan, runner menjadi sebuah loop yang mengecek jadwal setiap beberapa detik:

php
<?php
// Pola dasar scheduler daemon (membutuhkan definisi shouldRun() dari contoh sebelumnya)

$jadwal = [
    "backup-database"    => "0 2 * * *",
    "kirim-digest-harian" => "0 8 * * *",
    "bersihkan-log"      => "*/30 * * * *",
];

while (true) {
    $now = new DateTimeImmutable("now", new DateTimeZone("Asia/Jakarta"));

    foreach ($jadwal as $namaTugas => $cron) {
        if (shouldRun($cron, $now)) {
            echo "[" . $now->format("H:i") . "] menjalankan tugas {$namaTugas}\n";
        }
    }

    sleep(30); // tidur sampai siklus pengecekan berikutnya
}

Loop di atas sengaja tidak dieksekusi di lingkungan belajar karena while (true) tidak pernah berhenti. Dua detail praktis membedakan loop mainan dari yang siap produksi: interval sleep(30) membuat pengecekan terjadi paling lambat 30 detik setelah menit berganti (nilai lebih kecil berarti lebih tepat waktu namun lebih boros CPU), dan penanganan sinyal pcntl_signal() dibutuhkan agar runner bisa berhenti dengan anggun saat server dimatikan. Laravel memakai pola yang persis sama melalui satu baris cron * * * * * php artisan schedule:run yang memanggil runner tiap menit, dengan seluruh jadwal dideklarasikan di dalam kode.

Mencegah Tumpang Tindih dengan Kunci Berkas #

Cron tidak peduli apakah eksekusi sebelumnya sudah selesai. Tugas bersihkan berkas yang dijadwalkan tiap 5 menit akan dijalankan lagi pada menit berikutnya meskipun eksekusi sebelumnya masih berjalan — dan dua proses yang saling menghapus berkas yang sama adalah resep kejanggalan yang sulit di-debug. Pencegahnya adalah kunci eksklusif: proses pertama yang mengunci berkas lock berhak bekerja, proses yang datang belakangan langsung mundur. PHP menyediakan flock() untuk persis keperluan ini:

php
<?php
$lockFile = tempnam(sys_get_temp_dir(), "a93_lock_");

// Worker A memulai siklus eksekusi dan mengunci berkas lock
$workerA = fopen($lockFile, "w");
$kunciA = flock($workerA, LOCK_EX | LOCK_NB);
echo "Worker A mengunci berkas lock: " . ($kunciA ? "berhasil" : "gagal") . "\n";

// Worker B menyala di tengah eksekusi Worker A dan mencoba kunci yang sama
$workerB = fopen($lockFile, "w");
$kunciB = flock($workerB, LOCK_EX | LOCK_NB);
echo "Worker B mencoba mengunci    : " . ($kunciB ? "berhasil" : "gagal") . "\n";

if ($kunciB === false) {
    echo "Worker B langsung berhenti: tugas sedang dijalankan Worker A\n";
}

flock($workerA, LOCK_UN);
fclose($workerA);
fclose($workerB);
unlink($lockFile);

Output yang Dihasilkan:

text
Worker A mengunci berkas lock: berhasil
Worker B mencoba mengunci    : gagal
Worker B langsung berhenti: tugas sedang dijalankan Worker A

Mari kita bedah mekanismenya:

  1. tempnam() membuat berkas sementara dengan nama unik yang menjadi objek kunci; berkas ini tidak menyimpan data apa pun, hanya menjadi objek yang bisa dikunci kernel.
  2. flock($workerA, LOCK_EX | LOCK_NB) meminta kunci eksklusif (LOCK_EX) tanpa menunggu (LOCK_NB, non-blocking). Dua handle terbuka secara terpisah dianggap dua pemegang kunci berbeda oleh sistem operasi, meski berada dalam proses yang sama — itulah yang membuat simulasi ini jujur.
  3. Percobaan kedua mengembalikan false seketika, dan skrip kita menanggapi dengan langsung berhenti alih-alih menunggu — sikap yang tepat untuk tugas terjadwal: lewati siklus ini, siklus berikutnya akan datang lima menit lagi.
  4. Kunci dilepas otomatis saat proses mati, sehingga worker yang crash tidak mengunci selamanya — bandingkan dengan visibility timeout pada tabel jobs yang harus memakai masa berlaku karena tidak ada kernel yang bisa dipercaya antar-proses database.

Untuk tugas sederhana, shell juga menyediakan pembungkusnya sendiri sehingga logika kunci tidak perlu masuk ke dalam PHP:

bash
# flock(1) menolak menjalankan perintah bila kunci masih dipegang eksekusi sebelumnya
*/5 * * * * flock -n /tmp/bersihkan-log.lock /usr/bin/php /var/www/app/scripts/bersihkan-log.php

systemd Timers sebagai Alternatif Cron #

Distribusi Linux modern menyediakan penjadwal lain: systemd timer. Keunggulannya yang paling praktis adalah opsi Persistent=true — tugas yang terlewat karena server mati akan dikejar saat server menyala kembali, sesuatu yang cron tidak pernah lakukan. Timer dideklarasikan berpasangan dengan service:

ini
# /etc/systemd/system/kirim-digest.timer
[Unit]
Description=Jadwal pengiriman digest harian

[Timer]
OnCalendar=*-*-* 08:00:00
Persistent=true

[Install]
WantedBy=timers.target

Berkas timer mendeklarasikan kapan berjalan, sedangkan berkas service (.service dengan nama yang sama) mendeklarasikan apa yang dijalankan — pada kasus kita, perintah php /var/www/app/scripts/kirim-digest.php. Sintaks OnCalendar=*-*-* 08:00:00 setara dengan 0 8 * * * pada cron. Active-kan dengan systemctl enable --now kirim-digest.timer, lalu pantau eksekusi terakhir dan berikutnya lewat systemctl list-timers. Untuk tim yang sudah mengelola server lewat systemd, timer menggantikan cron dengan log jurnal terpadu dan dependensi antar-unit.

Ringkasan #

Tugas terjadwal berdiri di atas satu kontrak sederhana: lima kolom waktu cron yang menentukan kapan perintah shell — termasuk script PHP — dijalankan. Kita mempraktikkan jalur klasik (crontab dengan path absolut dan log terarah) lalu membangun jalur modern: satu runner dengan jadwal internal, didukung pencocok ekspresi cron yang bisa diuji dengan waktu beku dan kunci flock() yang mencegah eksekusi bertumpuk. systemd timer menambahkan kemampuan mengejar jadwal yang terlewat. Satu topik yang belum kita bahas: tugas terjadwal maupun job queue sama-sama bisa gagal ketika layanan eksternal sedang tidak sehat — dan menyerah pada kegagalan pertama adalah pemborosan. Chapter berikutnya membahas retry dengan backoff yang bertanggung jawab.

Referensi #