On a WordPress project, I built a small settings panel for a feature: a few number fields, one checkbox, and logic I thought was safe. If a user emptied a field, the panel fell back to a sensible default. Simple. Until a report came in one day: a user set one of the fields to 0, hit save, and the number snapped back to its default on its own. Not 0, but the default value, which was something else entirely.
My first instinct, as usual, was to check the data directly and not trust the UI. I dumped the saved option straight from the database to rule out a render bug.
$saved = get_option( 'my_feature_settings' );
var_dump( $saved );And there was the interesting part. The field the user had set to 0 was not in the saved array at all. Not stored as 0 and mis-rendered, but genuinely gone from the data. Meanwhile the other fields, filled with non-zero numbers, were stored cleanly. So the problem was not the form and not the render. Something in the save path was swallowing the zero before it ever reached the database.
The investigation
I traced the getter that merges the saved option over a defaults array. The idea is classic: take the defaults, overlay whatever the user saved, but strip empty values first so that an emptied field falls back to its default instead of overwriting it with a blank string. It looked roughly like this:
public function get_settings() {
$defaults = $this->defaults();
$saved = get_option( 'my_feature_settings', array() );
// Strip empty values so emptied fields fall back to defaults.
$saved = array_filter( $saved );
return array_merge( $defaults, $saved );
}On paper this reads correctly. array_filter( $saved ) drops empty entries, array_merge lays the defaults underneath, and genuinely filled fields win. But the moment I re-read that array_filter line with a suspicious eye, I knew it was the culprit.
To confirm, I isolated the behavior in a tiny snippet:
$saved = array(
'timeout' => '0',
'retries' => '3',
'label' => '',
);
var_dump( array_filter( $saved ) );
// array(1) { ["retries"] => string(1) "3" }timeout with a value of '0' got dropped, exactly like the empty string label did. Even though the user deliberately set it to 0. There the bug stood in plain sight.
The root cause
array_filter() with no callback uses its default callback: it keeps every truthy element and drops every falsy one. And in PHP, '0' is falsy. Just as falsy as an empty string, null, false, and 0. So array_filter cannot tell "a field intentionally set to zero" apart from "a field the user emptied." Both are falsy, both get dropped, and array_merge dutifully fills the hole with the default.
My intent was to strip only the fields that were genuinely empty. But what I wrote was to strip everything falsy, and a legitimate 0 was the innocent casualty of that assumption.
There was one more layer that surfaced when I checked the checkbox, and it is a completely different trap. An unchecked HTML checkbox does not submit 0. It submits nothing at all. The key simply does not appear in $_POST. So "the user unchecked the box" and "the user never touched the box" look identical on the server side: both are a missing key. Without explicit handling there is no way to distinguish them, and my checkbox behaved differently again from the number fields.
The fix
The fix for the number fields is simple: stop using the default callback, and filter explicitly for only what I actually want to drop, which is empty strings and null. Not "anything falsy."
public function get_settings() {
$defaults = $this->defaults();
$saved = get_option( 'my_feature_settings', array() );
// Strip ONLY empty strings and null. '0' must survive.
$saved = array_filter(
$saved,
static fn( $v ) => $v !== '' && $v !== null
);
return array_merge( $defaults, $saved );
}With that explicit callback, '0' survives because it is neither an empty string nor null. A genuinely emptied field still gets dropped and falls back to its default. Exactly the behavior I meant from the start.
For the checkbox the remedy is different because the problem is different: a missing key, not a falsy value. I normalize the presence or absence of that key in the sanitize callback before anything reaches the merge, so an unchecked box becomes an explicit '0' instead of vanishing:
public function sanitize( $input ) {
// Unchecked checkbox = missing key, not '0'. Make it explicit.
$input['enabled'] = isset( $input['enabled'] ) ? '1' : '0';
return $input;
}Now enabled is always present, always an explicit '1' or '0', so an unchecked box is stored as an honest '0' and never confused with "never touched." Alternatively, seed a hidden input with a value of 0 before the checkbox in the form markup, and let the browser submit that default itself.
The checklist
- Do not use
array_filter( $arr )with no callback if0,'0', orfalseis a valid value. The default callback drops everything falsy. - Filter explicitly:
array_filter( $arr, fn( $v ) => $v !== '' && $v !== null )so only empty strings andnullare removed. - Remember that an unchecked HTML checkbox does not submit
0, it omits the key. Normalize it to'0'/'1'in sanitize, or seed a hidden default input. - Verify through the saved data, not the UI. A
var_dumpof the option in the database shows whether the value was truly stored or evaporated on the way in. - Watch PHP's loose truthiness any time you filter, compare with
==, or useempty()on a value where zero or empty is meaningful.
The lesson I took away: array_filter with no callback does not mean "drop the empty ones," it means "drop the falsy ones," and in the world of settings, 0 is a valid answer just like any other number. Since then, whenever I strip values before a merge, I stop and ask first: does zero mean something here? If it does, I write the callback explicitly, and I stop letting PHP guess my intent.
