Kesalahan penanganan waktu termasuk yang paling mahal akibatnya: rapat yang tampil satu jam lebih awal, token yang tidak pernah kedaluwarsa, atau laporan harian yang terhitung dua kali. Akar masalahnya hampir selalu sama — zona yang tidak dibawa secara eksplisit, objek yang berubah diam-diam, input yang tidak divalidasi, dan matematika tanggal yang dikerjakan di atas string. Chapter penutup ini merangkum praktik aman yang kita bangun sepanjang section menjadi satu rangkaian prinsip yang bisa langsung diterapkan. Seluruh contoh memakai nilai beku sehingga Anda dapat mereproduksi hasilnya secara persis.
Simpan UTC, Tampilkan di Zona Pengguna #
Prinsip pertama sekaligus fondasi seluruh arsitektur: satu kebenaran waktu disimpan dalam UTC, sedangkan zona hanya bekerja di lapisan tampilan. Input yang datang dari zona mana pun dinormalisasi ke UTC saat disimpan, dan dikonversi ke zona pengguna hanya saat ditampilkan. Dua fungsi kecil berikut membungkus kedua arah konversi tersebut:
<?php
function simpanSebagaiUtc(string $waktuLokal, string $zonaPengirim): DateTimeImmutable
{
return (new DateTimeImmutable($waktuLokal, new DateTimeZone($zonaPengirim)))
->setTimezone(new DateTimeZone("UTC"));
}
function tampilkanDiZona(DateTimeImmutable $waktuUtc, string $zonaPengguna): string
{
return $waktuUtc->setTimezone(new DateTimeZone($zonaPengguna))->format("Y-m-d H:i:s T");
}
$acara = simpanSebagaiUtc("2026-01-15 08:00:00", "Asia/Jakarta");
echo "Tersimpan : " . $acara->format("Y-m-d H:i:s") . " UTC\n";
echo "Jakarta : " . tampilkanDiZona($acara, "Asia/Jakarta") . "\n";
echo "Jayapura : " . tampilkanDiZona($acara, "Asia/Jayapura") . "\n";Output yang Dihasilkan:
Tersimpan : 2026-01-15 01:00:00 UTC
Jakarta : 2026-01-15 08:00:00 WIB
Jayapura : 2026-01-15 10:00:00 WITFungsi simpanSebagaiUtc() menerima teks jam dinding beserta zona asalnya, lalu mengonversi hasil parsing ke UTC. Waktu 08:00 dari Jakarta tersimpan sebagai 01:00 UTC — nilai inilah satu-satunya yang masuk database. Fungsi tampilkanDiZona() bekerja ke arah sebaliknya: nilai UTC dikonversi ke zona pembaca, sehingga pengguna Jakarta melihat 08:00 WIB dan pengguna Jayapura melihat 10:00 WIT dari baris data yang sama. Tidak ada data yang diduplikasi, dan tidak ada zona yang bocor ke penyimpanan.
Gunakan Objek Immutable untuk Nilai Waktu #
Kelas DateTime bersifat mutable: metode seperti modify() mengubah objek di tempat. Ketika dua variabel menunjuk objek yang sama, perubahan pada salah satunya diam-diam mengubah keduanya. Perbandingan berikut memperlihatkan jebakan itu beserta penawarnya:
<?php
$rapat = new DateTime("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta"));
$pengingat = $rapat->modify("+2 hours");
echo "Rapat (mutable) : " . $rapat->format("H:i") . "\n";
echo "Pengingat (mutable) : " . $pengingat->format("H:i") . "\n";
$rapatAman = new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta"));
$pengingatAman = $rapatAman->modify("+2 hours");
echo "Rapat (immutable) : " . $rapatAman->format("H:i") . "\n";
echo "Pengingat (immutable): " . $pengingatAman->format("H:i") . "\n";Output yang Dihasilkan:
Rapat (mutable) : 10:00
Pengingat (mutable) : 10:00
Rapat (immutable) : 08:00
Pengingat (immutable): 10:00Pada versi mutable, modify("+2 hours") menggeser objek $rapat itu sendiri menjadi 10:00, sehingga kedua variabel menunjuk nilai yang sudah berubah — jadwal rapat ikut rusak. Pada versi DateTimeImmutable, modify() mengembalikan objek baru sehingga $rapatAman tetap 08:00 dan $pengingatAman menjadi 10:00. Nilai waktu pada dasarnya adalah fakta yang tidak berubah; menjatuhkannya ke tipe yang bisa berubah adalah ajakan bug.
Validasi Input Tanggal secara Ketat #
Input pengguna tidak boleh dipercaya. String "31/02/2026" terlihat seperti tanggal, tetapi 31 Februari tidak pernah ada. Validasi yang benar memakai createFromFormat() dengan format eksplisit, memeriksa getLastErrors() untuk kejanggalan parsing, lalu mengunci dengan checkdate():
<?php
function tanggalValid(string $input, string $format): bool
{
$tanggal = DateTimeImmutable::createFromFormat($format, $input);
if ($tanggal === false) {
return false;
}
$kesalahan = DateTimeImmutable::getLastErrors();
if ($kesalahan !== false && ($kesalahan["error_count"] + $kesalahan["warning_count"]) > 0) {
return false;
}
return checkdate((int) $tanggal->format("n"), (int) $tanggal->format("j"), (int) $tanggal->format("Y"));
}
var_dump(tanggalValid("15/01/2026", "d/m/Y"));
var_dump(tanggalValid("31/02/2026", "d/m/Y"));
var_dump(tanggalValid("15-01-2026", "d/m/Y"));
var_dump(tanggalValid("29/02/2028", "d/m/Y"));
var_dump(checkdate(2, 29, 2026));
var_dump(checkdate(2, 29, 2028));Output yang Dihasilkan:
bool(true)
bool(false)
bool(false)
bool(true)
bool(false)
bool(true)Mari kita bedah hasilnya baris demi baris:
"15/01/2026"lolos karena format dan nilainya sah."31/02/2026"ditolak — inilah gabungangetLastErrors()(parsing menghasilkan peringatan) dancheckdate()(31 Februari tidak ada)."15-01-2026"ditolak karena pemisahnya tidak cocok dengan formatd/m/YsehinggacreateFromFormat()mengembalikanfalse."29/02/2028"lolos karena 2028 adalah tahun kabisat — dua barischeckdate()terakhir menegaskan perbedaannya dengan 2026.
Validasi string yang sama juga berlaku pada konstruktor, dengan mekanisme kegagalan yang berbeda: konstruktor melempar Exception untuk teks yang tidak dapat dipahami sama sekali. Bungkus pemanggilan yang menerima teks mentah dengan try-catch agar kegagalan menjadi respons yang terkendali, bukan halaman error:
<?php
try {
new DateTimeImmutable("tanggal-acak");
} catch (Exception $e) {
echo "Gagal parsing: " . $e->getMessage() . "\n";
}Output yang Dihasilkan:
Gagal parsing: Failed to parse time string (tanggal-acak) at position 0 (t): The timezone could not be found in the databaseKonstruktor menolak "tanggal-acak" karena string tersebut bahkan tidak menyerupai tanggal, dan catch menangkap Exception untuk menampilkan pesan galatnya. Pesan tersebut berasal dari parser PHP dan menjelaskan posisi kegagalan secara teknis — cukup untuk log, tetapi untuk pengguna akhir sebaiknya diganti pesan yang lebih ramah.
Perbandingan yang Sadar Timezone #
Dua objek yang menunjuk momen yang sama harus dianggap sama, apa pun zona yang dipakai membacanya. Operator perbandingan pada objek date/time membandingkan momen sesungguhnya, bukan teks jam dindingnya:
<?php
$versiJakarta = new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta"));
$versiMakassar = new DateTimeImmutable("2026-01-15 09:00:00", new DateTimeZone("Asia/Makassar"));
var_dump($versiJakarta == $versiMakassar);
var_dump($versiJakarta->format("Y-m-d H:i:s") === $versiMakassar->format("Y-m-d H:i:s"));
var_dump($versiJakarta < $versiMakassar->modify("+1 second"));Output yang Dihasilkan:
bool(true)
bool(false)
bool(true)08:00 WIB dan 09:00 WITA adalah momen yang identik, sehingga perbandingan == menghasilkan true. Sebaliknya, membandingkan hasil format() sebagai string menghasilkan false — "08:00:00" dan "09:00:00" berbeda sebagai teks meskipun sama sebagai momen. Baris terakhir menunjukkan operator < juga bekerja pada momen: objek Jakarta tepat satu detik lebih awal daripada modify("+1 second") dari objek Makassar. Kesimpulannya: bandingkan selalu objek atau timestamp-nya, jangan pernah string hasil format.
Jebakan Daylight Saving Time #
Aritmetika modify() bekerja pada jam dinding lokal, sehingga hasilnya berbeda dari hitungan detik sungguhan ketika zona mengalami pergeseran DST. Pada 8 Maret 2026, jam di New York dimajukan dari 02:00 menjadi 03:00 — satu jam hilang dari hari itu:
<?php
date_default_timezone_set("America/New_York");
$awal = new DateTimeImmutable("2026-03-08 01:00:00"); // sebelum jam dimajukan
echo $awal->format("Y-m-d H:i:s T P") . "\n";
echo $awal->modify("+24 hours")->format("Y-m-d H:i:s T P") . "\n";
echo $awal->setTimestamp($awal->getTimestamp() + 86400)->format("Y-m-d H:i:s T P") . "\n";
echo "Durasi nyata modify() : " . ($awal->modify("+24 hours")->getTimestamp() - $awal->getTimestamp()) . " detik\n";
echo "Durasi nyata timestamp : " . ($awal->setTimestamp($awal->getTimestamp() + 86400)->getTimestamp() - $awal->getTimestamp()) . " detik\n";Output yang Dihasilkan:
2026-03-08 01:00:00 EST -05:00
2026-03-09 01:00:00 EDT -04:00
2026-03-09 02:00:00 EDT -04:00
Durasi nyata modify() : 82800 detik
Durasi nyata timestamp : 86400 detikEkspresi modify("+24 hours") mendarat di 01:00 keesokan harinya — jam dinding sama, padahal hanya 82800 detik (23 jam) yang benar-benar berlalu karena satu jam hilang saat pergeseran. Sebaliknya, menjumlah 86400 detik pada level timestamp menghasilkan durasi nyata 24 jam yang mendarat di 02:00 EDT. Tidak ada yang salah dari keduanya; keduanya menjawab pertanyaan berbeda. Untuk “besok jam segini” gunakan aritmetika jam dinding, untuk “tepat 24 jam ke depan” seperti batas token gunakan timestamp.
Jangan Berhitung Tanggal dengan String #
Pemula kerap memanipulasi tanggal sebagai string: memecah dengan explode(), menambah angka, lalu merakit kembali dengan sprintf(). Pendekatan ini meledak tepat di batas periode — akhir bulan, akhir tahun, tahun kabisat:
<?php
$tanggalString = "2026-01-31";
[$tahun, $bulan, $hari] = array_map("intval", explode("-", $tanggalString));
$tanggalBuatan = sprintf("%04d-%02d-%02d", $tahun, $bulan, $hari + 1);
echo $tanggalBuatan . "\n";
var_dump(checkdate($bulan, $hari + 1, $tahun));
$benar = (new DateTimeImmutable($tanggalString))->modify("+1 day");
echo $benar->format("Y-m-d") . "\n";Output yang Dihasilkan:
2026-01-32
bool(false)
2026-02-01Menambah satu pada tanggal 31 menghasilkan 2026-01-32 — tanggal yang tidak pernah ada, langsung terbongkar oleh checkdate(). Baris terakhir menunjukkan cara yang benar: parsing menjadi objek, modify("+1 day"), lalu format() kembali, menghasilkan 2026-02-01 tanpa perlu menulis satu pun aturan kalender. Seluruh aturan kalender — jumlah hari tiap bulan, kabisat, bahkan sejarah zona — sudah ada di dalam API date/time; tugas kita cukup mempercayainya.
Checklist Penanganan Date/Time Aman #
Tabel berikut merangkum seluruh prinsip chapter ini menjadi daftar periksa yang bisa dijadikan rujukan cepat saat meninjau kode:
| Situasi | Tindakan aman |
|---|---|
| Menyimpan waktu | Simpan UTC (atau zona tetap lain) plus zona pengguna di profil |
| Objek nilai waktu | DateTimeImmutable; hindari DateTime yang mutable |
| Input tanggal pengguna | createFromFormat() ketat + getLastErrors() + checkdate() |
| Teks mentah dari luar | Bungkus konstruktor dengan try-catch |
| Membandingkan waktu | Bandingkan objek atau timestamp, bukan string format() |
| Durasi presisi | Hitung lewat timestamp; sadari pergeseran DST |
| Menambah/mengurang waktu | modify() atau add(), jangan matematika string |
| Menampilkan waktu | Konversi ke zona pengguna lalu format locale-aware |
Ringkasan #
Penanganan date/time yang aman berdiri di atas beberapa keputusan sederhana yang diterapkan secara konsisten: simpan UTC dan bawa zona secara eksplisit, gunakan objek immutable untuk nilai, validasi input dengan kombinasi createFromFormat() dan checkdate(), bandingkan momen lewat objek dan bukan string, serta pilih sadar antara aritmetika jam dinding dan durasi nyata saat DST ikut bermain. Section ini menutup bahasan date/time secara utuh; keterampilan tersebut akan terus muncul di sepanjang sisa course ini, mulai dari kedaluwarsa sesi hingga penjadwalan tugas latar belakang.