PHP considers the string "0" to be empty. That single language quirk produced twelve distinct production bugs in Project Spark, a product page titled “0” losing its SEO warnings, a free item flagged as having no price, a keyword silently deleted, and hunting them down became a masterclass in systematic defect-class auditing.
Key takeaway: In PHP,
empty("0")is true,"0" ?: $fallbackreturns the fallback, andarray_filter()deletes"0"from arrays. Any code applying these idioms to user-supplied values, titles, prices, keywords, silently destroys legitimate data. The fix is one canonical pattern: compare against the empty string explicitly.
A thirty-second history of the quirk
To understand why this bug family exists at all, you need one piece of history. PHP was designed in the 1990s as a template language for web forms, where every incoming value is a string. To make numeric comparisons convenient, the language adopted loose type casting: the string "0" casts to the number 0, the number 0 casts to boolean false, and therefore the string "0" is treated as false, and as “empty”, everywhere the language checks truthiness.
That decision made 1990s form handling pleasant. It also created a permanent trap: three of PHP’s most popular convenience idioms silently equate “this value is the single character zero” with “this value does not exist.” In a codebase of roughly 41,000 lines, we eventually found twelve places where that equation destroyed real data. None of them had ever produced an error message. All of them were wrong.
Where “0” is legitimate data
It sounds academic until you meet real content: a tutorial section titled “0”, a product priced at 0 (free downloads are schema.org-legal and Google-recommended), a keyword like “0” in “0% financing,” a SKU starting with zero. Rare, which is exactly why these bugs survive: they’re silent for years, then wrong for the one customer who hits them.
The rarity is the trap. A bug that fires on every request gets caught in the first week. A bug that fires only when an editor prices something at zero, or titles a section “0”, can ship through hundreds of releases without a single report, because the affected users see a confusing warning, assume they did something wrong, and quietly work around it. Silent bugs do not generate tickets. They generate workarounds, and workarounds generate distrust.
The twelve-bug family album
Each of these shipped, worked “fine,” and was found by auditing for the pattern, not by user reports:
- The schema validator warned “Product offer missing price” for
price = "0", free items got false alarms. The consequence was worse than an annoying message: at least one operator response to a false “missing price” warning is to type the word “Free” into the price field, which produces structured data that machine consumers genuinely cannot parse. A false warning that provokes a real data error is a bug with interest. - Four content-audit checks treated a post titled “0” as having no title, breaking coverage statistics and nagging editors about a non-problem. The dashboard would report “24 of 25 posts have SEO titles” on a site where all 25 were set, a metric silently drifting from reality, which is the most corrosive kind of dashboard failure.
- Five schema builders used the shorthand ternary (
$description ?: $fallback), so a description of “0” fell through to auto-generated text, overriding the editor’s explicit choice. Five separate structured-data generators, for five different content types, all carried the identical fallback expression, and all five silently replaced a human decision with machine output. - Two keyword pipelines used
array_filter()with no callback, which strips every falsy element, the keyword “0” vanished between typing and saving. The editor types three keywords, saves, and the counter says two. No error. No explanation. - Bonus fatality: fixing one of these unmasked a PHP 8 crash behind it, an array key of “0” coerced to integer zero, then fed to a string-typed function. Defensive code masked by the upstream bug, exposed the moment the upstream bug was fixed.
That last one deserves a closer look, because it teaches something the other eleven do not.
The crash hiding behind the bug
PHP has a second quirk stacked on the first: when you use the string "0" as an array key, the language silently converts it to the integer 0. For years, one of our duplicate-detection routines never processed a "0" entry, the upstream empty() check filtered it out before it arrived. The moment we fixed that upstream check, "0" values finally flowed into the routine, arrived as integer keys, and were passed to a function whose signature demanded a string. Under PHP 8’s stricter typing, that is a fatal error, a crash that had been installed for years and reachable for minutes.
The lesson: defensive code that has never executed is not tested code, it is a rumour. When you fix a data-filtering bug, everything downstream of the filter receives inputs it has never seen. Fixing a bug is also an act of publishing new inputs to old code, and the sweep that follows a fix must therefore extend downstream, not just sideways.
The sister-concern audit method
The discovery process matters more than the bugs. After the first fix, we ran five successive codebase-wide sweeps, each targeting one falsy idiom as a distinct audit dimension:
empty()on array values → 1 bugempty()on metadata reads → 4 bugs- Shorthand ternaries on metadata → 5 bugs
- Bare
array_filter()→ 2 bugs - Negated-empty ternaries and skip-continues → 4 more, plus the crash
Notice the shape of that list. The first sweep found one bug. If we had stopped there, fixed the reported instance, closed the ticket, eleven defects would still be live. Each sweep also refined the search itself: the pattern that finds empty() calls will never match a shorthand ternary, and the pattern that finds ternaries will never match a bare array_filter(). Every spelling of the mistake needs its own search expression, which means the audit is not one task but a small research programme: enumerate the idioms, sweep per idiom, and treat a clean sweep as evidence about one spelling only.
Two lessons: one bug is never one bug, it’s one instance of a class; and each idiom variant needs its own sweep, the sweep that catches empty() won’t catch ?:. Every fix site now carries the canonical replacement (explicit trimmed comparison against the empty string) and a test pinning both the fix and the pattern, so the idiom can’t quietly return.
How the fixes were made permanent
Finding twelve bugs is worth little if the thirteenth arrives in next month’s code. Three mechanisms keep the family extinct:
The canonical pattern. Every fixed site now asks the precise question, “is this value, trimmed and cast to string, identical to the empty string?”, instead of the vague one (“is this falsy?”). The precise question has exactly one honest answer for "0": no, it is not empty. Standardizing on a single replacement pattern also means reviewers can spot deviations instantly; there is one right spelling, and everything else invites a comment.
Regression tests in both directions. For each fix, an automated test asserts the behaviour, a product priced “0” is not flagged, a keyword “0” survives saving, and a second test asserts the pattern, verifying that the dangerous idiom does not reappear at that site. A future developer who reverts the fix, however innocently, fails the build before any user is affected. The project’s test suite crossed 3,800 PHP tests partly on the strength of pins like these.
Negative pins against over-correction. The subtle risk in fixing “0 is treated as empty” is over-correcting into “nothing is ever treated as empty.” A genuinely blank title should trigger the audit warning; a whitespace-only description should fall through to the fallback. Every sweep therefore shipped tests in both directions, legitimate zeros preserved, genuine emptiness still handled, because a fix that breaks the opposite case has merely relocated the bug.
Run this audit on your own codebase
The whole exercise transfers to any PHP project in an afternoon. The checklist:
- Search for
empty(applied to values a user can type, titles, prices, names, keywords, descriptions. Ignore uses on arrays and booleans, where the semantics are usually intended. - Search for the shorthand ternary
?:on the same class of values, especially in fallback chains that substitute generated content for human content. - Search for
array_filter(with no second argument anywhere the array contains user-supplied scalars. The no-callback form deletes every falsy element, including"0". - Search for
if (!$value)guards on user-supplied strings, boolean coercion has the same blind spot. - For each hit, ask one question: is the single character zero a legitimate value here? For prices, titles, keywords, and identifiers, the answer is almost always yes.
- Fix with the explicit comparison, then test both directions, the zero survives, the genuinely-empty case still behaves.
Budget a day. In our experience the search itself takes an hour; understanding each hit takes the rest, and the understanding is where the value lives.
The transferable rule
Whenever a language’s convenience idiom conflates “no value” with “a value that happens to be falsy,” user data will eventually land in the gap. Audit for the idiom family, not the incident, in PHP that’s empty(), ?:, bare array_filter(), and boolean coercion in if (!$x); every language has its own list. JavaScript’s loose equality and its falsy "" and 0, Python’s truthiness on empty containers, SQL’s three-valued NULL logic, each ecosystem has a place where its convenience shorthand and its users’ legitimate data overlap. The defect class is universal; only the spelling is local.
Next: Part 16, Performance at 10,000 Posts
FAQ
Why does PHP treat the string “0” as empty?
empty() mirrors PHP’s loose boolean casting, and the string “0” casts to false by historical design. empty("0") returning true is documented behavior, and a perpetual bug source.
What values are falsy in PHP?
false, 0, 0.0, "", "0", null, empty arrays, and unset variables. The string "0" is the one that most often collides with legitimate user data.
How do I safely check if a PHP string is empty?
Compare explicitly: trim the value, cast to string, and test identity against ''. Avoid empty(), shorthand ternaries, and callback-less array_filter() on user-supplied scalars.
What is a defect-class audit?
After fixing a bug, systematically searching the codebase for every other instance of the same pattern, sweeping per idiom variant, since each variant needs its own search.
Can PHP array keys change type by themselves?
Yes, string keys that look like integers, including “0”, are silently converted to integer keys. Code that later expects a string key can crash under PHP 8’s stricter typing.
How long does a falsy-value audit take?
For a mid-sized codebase, plan one working day: roughly an hour of searching across the four idioms, then several hours reviewing each match and writing tests in both directions.





