Di sebuah proyek WordPress, saya bangun panel setting kecil untuk sebuah fitur: beberapa field angka, satu checkbox, dan logika yang saya kira aman. Kalau user mengosongkan sebuah field, panel jatuh balik ke nilai default yang masuk akal. Sederhana. Sampai suatu hari ada laporan: user set salah satu field ke 0, klik simpan, dan angkanya balik sendiri ke default. Bukan 0, tapi angka default yang sama sekali beda.
Refleks pertama saya seperti biasa: cek datanya langsung, jangan percaya UI. Saya buka opsi yang tersimpan di database untuk memastikan bukan salah render.
$saved = get_option( 'my_feature_settings' );
var_dump( $saved );Dan di situ menariknya. Field yang user set ke 0 memang tidak ada sama sekali di array tersimpan. Bukan tersimpan sebagai 0 lalu salah tampil, tapi benar-benar hilang dari data. Sementara field lain yang user isi dengan angka non-nol tersimpan rapi. Jadi masalahnya bukan di form, bukan di render. Ada sesuatu di jalur penyimpanan yang menelan angka nol sebelum sampai ke database.
Investigasi
Saya telusuri getter yang menggabungkan opsi tersimpan di atas array default. Idenya klasik: ambil default, timpa dengan apa pun yang user simpan, tapi buang dulu nilai kosong supaya field yang dikosongkan jatuh balik ke default alih-alih menimpa dengan string kosong. Kira-kira begini bentuknya:
public function get_settings() {
$defaults = $this->defaults();
$saved = get_option( 'my_feature_settings', array() );
// Buang nilai kosong supaya field kosong jatuh ke default.
$saved = array_filter( $saved );
return array_merge( $defaults, $saved );
}Di kertas ini terlihat benar. array_filter( $saved ) membuang entri kosong, array_merge menaruh default di bawah, dan field yang benar-benar diisi menang. Tapi begitu saya baca ulang baris array_filter itu dengan mata curiga, saya langsung tahu ini pelakunya.
Untuk mengonfirmasi, saya isolasi perilakunya di potongan kecil:
$saved = array(
'timeout' => '0',
'retries' => '3',
'label' => '',
);
var_dump( array_filter( $saved ) );
// array(1) { ["retries"] => string(1) "3" }timeout dengan nilai '0' ikut terbuang, persis seperti string kosong label. Padahal user memang sengaja set 0. Di situlah bug-nya berdiri terang-terangan.
Akar masalah
array_filter() tanpa callback memakai callback default: dia mempertahankan setiap elemen yang truthy dan membuang yang falsy. Dan di PHP, '0' itu falsy. Sama falsy-nya dengan string kosong, null, false, dan 0. Jadi array_filter tidak bisa membedakan "field yang sengaja diisi nol" dari "field yang dikosongkan user". Keduanya falsy, keduanya dibuang, dan array_merge dengan patuh mengisi lubang itu dengan default.
Niat saya adalah membuang hanya field yang benar-benar kosong. Tapi yang saya tulis adalah membuang semua yang falsy, dan 0 yang sah adalah korban tak bersalah dari asumsi itu.
Ada satu lapis lagi yang muncul saat saya cek checkbox-nya, dan ini jebakan yang beda sama sekali. Checkbox HTML yang tidak dicentang tidak mengirim 0. Dia tidak mengirim apa pun. Key-nya sama sekali tidak muncul di $_POST. Jadi "user meng-uncheck kotak" dan "user tidak pernah menyentuh kotak" terlihat identik di sisi server: dua-duanya key yang hilang. Tanpa penanganan eksplisit, tidak ada cara membedakannya, dan checkbox saya berperilaku beda lagi dari field angka yang tadi.
Perbaikannya
Perbaikan untuk field angka sederhana: berhenti pakai callback default, dan filter secara eksplisit hanya yang benar-benar ingin saya buang, yaitu string kosong dan null. Bukan "semua yang falsy".
public function get_settings() {
$defaults = $this->defaults();
$saved = get_option( 'my_feature_settings', array() );
// Buang HANYA string kosong dan null. '0' harus selamat.
$saved = array_filter(
$saved,
static fn( $v ) => $v !== '' && $v !== null
);
return array_merge( $defaults, $saved );
}Dengan callback eksplisit itu, '0' lolos karena dia bukan string kosong dan bukan null. Field yang benar-benar dikosongkan tetap terbuang dan jatuh ke default. Persis perilaku yang saya maksudkan sejak awal.
Untuk checkbox, obatnya beda karena masalahnya beda: key yang hilang, bukan nilai falsy. Saya normalisasi kehadiran atau ketiadaan key itu di callback sanitasi sebelum apa pun sampai ke merge, jadi checkbox yang tidak dicentang menjadi '0' yang eksplisit alih-alih menghilang:
public function sanitize( $input ) {
// Checkbox uncheck = key hilang, bukan '0'. Jadikan eksplisit.
$input['enabled'] = isset( $input['enabled'] ) ? '1' : '0';
return $input;
}Sekarang enabled selalu ada, selalu '1' atau '0' yang eksplisit, jadi checkbox yang di-uncheck tersimpan sebagai '0' yang jujur dan tidak pernah bingung dengan "belum pernah disentuh". Alternatifnya, tanam hidden input dengan value 0 sebelum checkbox di markup form, biar browser sendiri yang mengirim default itu.
Checklist
- Jangan pakai
array_filter( $arr )tanpa callback kalau0,'0', ataufalseadalah nilai yang sah. Callback default membuang semua yang falsy. - Filter secara eksplisit:
array_filter( $arr, fn( $v ) => $v !== '' && $v !== null )supaya hanya string kosong dannullyang dibuang. - Ingat bahwa checkbox HTML yang tidak dicentang tidak mengirim
0, dia menghilangkan key-nya. Normalisasi jadi'0'/'1'di sanitasi, atau tanam hidden input default. - Verifikasi lewat data tersimpan, bukan UI.
var_dumpopsi di database menunjukkan apakah nilai benar-benar tersimpan atau menguap sebelum sampai. - Waspadai longgarnya truthiness PHP setiap kali kamu memfilter, membandingkan dengan
==, atau memakaiempty()pada nilai yang nol atau kosong itu bermakna.
Pelajaran yang saya bawa: array_filter tanpa callback itu bukan "buang yang kosong", tapi "buang yang falsy", dan di dunia setting, 0 adalah jawaban yang sah persis seperti angka lainnya. Sejak itu, setiap kali saya menyaring nilai sebelum merge, saya berhenti dan tanya dulu: apakah nol punya arti di sini? Kalau iya, saya tulis callback-nya eksplisit, dan saya berhenti membiarkan PHP menebak niat saya.
