The PHP Language

Lesson 1 of 5

Types, Juggling and Comparison

"The reference of 'evening star' would be the same as that of 'morning star', but not the sense." — Gottlob Frege, On Sense and Reference (1892)

Frege's distinction is the one PHP asks you to make every time you compare two things. Are these the same value, or merely two names that arrive at the same place? === asks the first question and == asks the second, and almost every comparison bug in PHP is someone asking one when they meant the other.

This chapter is also the point where a warning is needed about the material itself. PHP's comparison rules are the most-quoted thing about the language, they are quoted almost entirely from memory, and PHP 8 changed them. A great deal of what you will find written about this — including some of what you already believe — describes a language that stopped existing in 2020. Everything below was run on the PHP in your browser rather than recalled.

The famous ones

Start with the comparisons that made PHP a punchline, and run them:

PHP — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

0 == "foo" is false. So is "abc" == 0, and so is 0 == "". The single most-cited example of PHP's brokenness has not been true for years.

The change, formalised in the Saner string to number comparisons RFC, is easy to state: when a number meets a string, PHP now asks whether the string is numeric. If it is, they are compared as numbers — so 100 == "1e2" is still true, because "1e2" genuinely is a number written in a string. If it is not, the number is converted to a string instead, and they are compared as text. 0 becomes "0", which is not "foo", so the comparison is false.

That is not a special case bolted on; it is a reversal of which operand gives way, and it is almost always the answer you wanted. Note the one survivor in that list: null == "" is still true, because null is not a number and the comparison falls to a different rule entirely.

An analogy

Imagine a shop where the till accepts both cash and vouchers, and the old rule was that anything unrecognisable counted as zero naira. Hand over a library card and the till would cheerfully ring up nothing, and — crucially — agree that your library card and an empty wallet were worth the same. That is what the old comparison did: it insisted on turning everything into a number first, and anything it could not read became zero, so all unreadable things became equal to each other and to nothing. The new rule is that the till first checks whether the thing you handed over is the kind of thing that has a value. A voucher does, so it is counted. A library card does not, so the till stops converting and simply compares what it is holding. The library card is still not money — the comparison still fails — but it no longer fails by being silently declared equal to zero, which was the part that cost people real bugs.

What counts as a number

The whole of the new behaviour rests on "is this string numeric", so it is worth knowing exactly where that line falls:

PHP — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Three results worth keeping. Hexadecimal strings are not numeric — "0x1A" is just text, a deliberate decision made in PHP 7 because accepting it made "0x1A" == "26" true and nobody wanted that. Trailing whitespace is allowed as of PHP 8, so "1 " is numeric where it previously was not; leading whitespace always was. And a leading-numeric string like "1abc" still converts for arithmetic, giving 2, but now raises a warning while doing it — which is the pattern for most of PHP 8's type changes: the behaviour is kept for compatibility and made loud.

Turning coercion off at the boundary

Everything so far has been about what PHP does when you do not tell it what you meant. Type declarations are how you tell it, and declare(strict_types=1) decides how literally that instruction is taken.

By default PHP runs in coercive mode: a function declared function f(int $n) accepts the string "5" and quietly converts it, accepts 5.0 because the conversion is lossless, and throws a TypeError only for something genuinely unconvertible like "abc". That is the same ergonomic instinct as loose comparison, applied at the function boundary, and it has the same drawback — the conversion happens silently, so a bug that passes a string where an integer was intended never surfaces.

With declare(strict_types=1) as the first statement in a file, the conversions stop. Measured: takesInt("5") throws a TypeError, and so does takesInt(5.0) even though five-point-zero is exactly five. Return types are checked the same way, so a function declaring : int and returning "5" throws rather than coercing. Internal functions are included too — strlen(123) is a TypeError in strict mode, where coercive mode would have accepted it happily.

There is exactly one exception, and it is deliberate: passing an int where a float is declared still works, because every integer has an exact float representation and refusing it would force casts through arithmetic code for no benefit. takesFloat(5) returns 5.0. That is widening, not coercion, and it is the only conversion strict mode permits.

Two scoping details are worth being precise about, because both are commonly misunderstood.

The first is that strict_types is per file, and it governs the calls made from that file — not the file where the function was declared. A strict file calling a library that never heard of strict types still gets strict checking on its own calls; a non-strict file calling your carefully strict code gets coercion. You cannot impose it on your callers, and they cannot impose it on you. That is what makes it adoptable incrementally, and it is why every modern PHP library opens its files with the declaration: it is the only way to get the guarantee for the code you actually wrote.

The second is what it does not touch, and the list is longer than people expect. Strict types govern function arguments and return values. They do not affect ==, which still coerces exactly as this chapter has described. They do not affect arithmetic, so "5" + 5 is still 10. And they do not affect explicit casts, which is the point — (int) $x is you saying you meant it, and the language does not second-guess an instruction. Strict mode is about the boundaries between functions, not about the operators inside them.

That division is the useful way to think about all of this. Type declarations plus strict_types give you a hard edge where data enters your code; comparison rules govern what happens once it is inside. Getting the edge right is the higher-leverage half, because a value that was validated on the way in does not need defensive comparison everywhere afterwards — and a value that was coerced silently on the way in will produce a surprise somewhere far from the place it went wrong.

Truthiness, which did not change

The comparison rules moved; the truthiness rules did not, and they contain the trap people actually hit:

PHP — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

'0' being falsy is the well-known one, and it exists because HTML forms submit everything as strings: a checkbox worth zero would otherwise be truthy. What surprises people is that the exemption is for exactly that one string. '00' is truthy. '0.0' is truthy. ' ' — a single space — is truthy. So a validation check written as if (!$input) treats "0" as missing while treating "00" as present, which is the kind of inconsistency that produces a bug report nobody can reproduce.

The habit that avoids all of it is to compare against what you actually mean: if ($input === '') for empty, if ($input === null) for absent, if (!isset($x)) for never-set. empty() collapses all three into one question, which is convenient exactly until it is wrong.

The distinction between those three is worth holding clearly, because PHP gives you three constructs that look interchangeable and are not. isset() asks whether a variable exists and is not null — it is the only one of the three that is safe on something that may not exist at all, and it is the reason isset($row['maybe']) is the idiomatic array check. array_key_exists() asks a narrower question, whether the key is present regardless of whether its value is null, which matters whenever null is a meaningful value rather than an absence; a settings array where ['timeout' => null] means "explicitly no timeout" is a case where isset() gives the wrong answer and array_key_exists() the right one. And empty() asks the truthiness question from the table above, with all of that table's sharp edges, while also being safe on undefined variables — a combination that makes it the most convenient and the least precise of the three.

The null-coalescing operator ?? is worth mentioning in the same breath, because it is isset() in operator form: it returns the right-hand side when the left is null or undefined, and it does not emit a warning for the missing case. That makes $config['timeout'] ?? 30 the correct idiom for a default, and $config['timeout'] ?: 30 a subtly different one — the latter is truthiness-based, so it replaces a configured 0 with 30, which is almost never what anybody wanted.

A little history

The comparison table is the single most reproduced image in PHP's reputation — a grid of == results with a scattering of counter-intuitive trues, usually posted without a version number. It earned its fame honestly: for most of PHP's life, comparing a number with a string converted the string to a number, and any string that did not start with a digit became zero, so 0 == "foo", 0 == "bar" and "foo" == "bar" produced a small nest of surprising equalities. What is less well known is how deliberately it was fixed. The Saner string to number comparisons RFC passed in 2020 for PHP 8.0 with a large majority, and it is a careful piece of work: rather than making == strict, which would have broken an enormous amount of working code that compares form input with integers, it reversed the direction of conversion only when the string is non-numeric. The result is that almost all the famous examples stopped being true while "5" == 5 — the case people actually rely on — still works. That the fix took twenty years is a fair criticism. That the fix is well designed is the part the folklore never caught up with.

Floats, and the limits of integers

Numeric comparison has a second category of surprise that is not PHP's fault at all, and it catches people who have forgotten the hardware underneath:

PHP — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

0.1 + 0.2 is 0.30000000000000004 in PHP exactly as it is in JavaScript, Python, Java and C, because none of them can represent a tenth in binary any more than you can write a third exactly in decimal. The fix is never to compare floats with ==; compare the difference against a tolerance, and for money use integers of the smallest unit.

The integer results are the ones people meet less often and misdiagnose more badly. PHP does not wrap on overflow — PHP_INT_MAX + 1 silently becomes a float, losing precision rather than becoming negative. And a cast saturates: (int) "9223372036854775808" gives PHP_INT_MAX rather than an error or a wrap. If you are parsing IDs from an external system that uses 64-bit unsigned integers, that saturation will quietly map several distinct IDs onto the same value.

The big question

Given how much trouble it has caused, why does PHP have loose comparison at all — why not do what Python does and refuse to compare a string with an integer? Because PHP's original job was to receive HTML form data, and in a form everything is a string. A checkbox sends "1", a number input sends "42", an ID in a URL is "1337", and requiring an explicit cast at every one of those boundaries would have made the language substantially more tedious for the task it was built for. Loose comparison was the ergonomic answer, and for the common case — "5" == 5 — it is still the right one. What the twenty years since taught is that the coercion direction mattered enormously and the original choice was backwards: converting the string to a number meant unreadable input became zero and silently equal to zero, which is the most dangerous possible failure because it succeeds. PHP 8's fix keeps the ergonomics and removes the danger, which is the best available outcome for a language that cannot break its own web. The deeper lesson is one this Library keeps arriving at: a default is not just a convenience, it is a decision about what happens when someone is not paying attention, and the cost of getting that wrong compounds for decades.

Arrays, in_array and the comparison you forgot you made

The loose/strict distinction reaches further than ==, into functions that do comparison on your behalf:

PHP — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

in_array(0, ['a', 'b']) returning false is the same PHP 8 fix arriving somewhere less obvious, and it is worth dwelling on because the old behaviour was a genuine security problem: a permission check written as in_array($userInput, $allowedRoles) would match any allowed role when the input was 0. That bug shipped in real applications.

The array rules are their own small lesson. == compares key–value pairs regardless of order; === additionally requires the same order and the same types. So two arrays built by different code paths can be == and not ===, and a test asserting === on an array assembled from a database result is asserting something about ordering that it probably did not mean to.

And the last pair is the practical takeaway of the whole chapter: switch is always loose, match is always strict. match was added in PHP 8 partly for expressions and partly for exactly this — there is no way to make switch strict, so the language grew a construct that never needed to be.

From my notebook

I had to rewrite my own notes while writing this. My working memory of PHP comparison was formed around 2016 and contained, as settled fact, that 0 == "foo" is true — I would have said so confidently in an interview and been wrong for five years running. Running the table took under a minute and contradicted four separate things I believed. What makes this worth recording is not the embarrassment but the mechanism: this is knowledge I acquired correctly, from good sources, and then never revisited, because nothing about holding a stale fact feels different from holding a current one. Comparison semantics are an unusually dangerous category for that, because the folklore is repeated so often that hearing it again functions as confirmation. The countermeasure I have landed on is specific rather than general — for anything version-dependent, I now write the probe before the prose, even when I am certain, because certainty is precisely the state in which I do not check.

In the real world

The comparison bug that reaches production is almost never an exotic one from the table. It is a loose comparison against form input, and it takes two recurring shapes. The first is identity checks — if ($user->id == $request->input('id')) — where one side is an integer from the database and the other is a string from the wire, which works perfectly until someone sends something that coerces surprisingly. The second, and the one worth being genuinely careful about, is anything comparing tokens or hashes: == on two strings that both look numeric compares them as numbers, so "1e3" == "1000" is true, and a token comparison written loosely can be satisfied by a value that is not the token. This is why every authentication library uses hash_equals(), which is both strict and constant-time. The habit I would recommend adopting wholesale is simply to default to === and treat every == as a decision you made on purpose — it costs one keystroke, it removes the entire category, and in a code review a lone == then stands out as something to ask about rather than something to skim past.

Common Mistakes

  1. Repeating the old comparison table. Measured on 8.3: 0 == "foo" is false, and so are "abc" == 0 and 0 == "".

  2. Assuming in_array is strict. It is loose by default, and the loose version was a real permission-check vulnerability.

  3. Using == on tokens or hashes. Two numeric-looking strings compare as numbers; use hash_equals().

  4. Believing '0' behaviour extends to '00'. Only the exact string '0' is falsy; '00' and '0.0' are truthy.

  5. Using empty() to mean "absent". It collapses null, empty string, zero and empty array into one answer.

  6. Comparing floats with ==. 0.1 + 0.2 is 0.30000000000000004 in every language with binary floats.

  7. Storing money in floats. Use integers of the smallest unit; the rounding errors are not recoverable.

  8. Expecting integer overflow to wrap. PHP silently converts to float, and casts saturate at PHP_INT_MAX.

  9. Testing NAN == NAN. It is false by specification; is_nan() is the only correct test.

  10. Using switch when you mean strict. switch is always loose and cannot be made strict; match always is.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
What is the difference between == and ===?
Model answer — === requires the same type and the same value; == allows coercion first. For arrays, == compares key–value pairs in any order while === also requires the same order and types. I default to === and treat a loose comparison as a deliberate decision — it removes a whole category of bug for one keystroke, and it makes any remaining == visible in review.
Intermediate
Is 0 == "foo" true?
Model answer — Not since PHP 8 — it is false. The old rule converted the string to a number, and any non-numeric string became zero, which is where the famous comparison table came from. PHP 8 reversed the direction: if the string is not numeric, the number is converted to a string and they are compared as text. So 100 == "1e2" is still true, because that string really is a number, while 0 == "foo" is not. The same fix quietly repaired in_array(0, ["a","b"]), which used to be true and was a genuine permission-check vulnerability.
Advanced
Why does PHP have loose comparison at all, rather than refusing like Python?
Model answer — Because its original job was HTML form data, where everything arrives as a string — a checkbox sends "1", an ID sends "1337" — and demanding an explicit cast at every boundary would have made the language tedious for the thing it was built for. "5" == 5 is the case people rely on and it is still right. What was wrong was the coercion direction: converting unreadable input to zero meant it silently equalled zero, and silent success is the most dangerous failure mode there is. PHP 8 kept the ergonomics and reversed the direction. The general lesson is that a default decides what happens when nobody is paying attention, and getting that wrong compounds for twenty years.
micro1Practical: default to ===, and know what empty() hides.GoogleDeepest — coercion direction as a design decision.MetaThe PHP 8 change and where it reaches, including in_array.AmazonSecurity: loose comparison on tokens, and hash_equals.

Hands-on

Hands-on

Model the rules this chapter measured. Implement looseEquals, PHP 8's comparison rule; isNumericString, where the numeric line falls; isFalsy, the fixed list; floatEquals, the tolerance comparison; and safeCompare, choosing an operator for a purpose. A stub scores zero, so a passing run means the model is real.

PHP — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Summary

  • 0 == "foo" is false on PHP 8. The most-quoted fact about PHP comparison has been wrong for years.
  • The rule reversed direction: a number against a non-numeric string is now compared as a string, not converted to zero.
  • Numeric strings still compare as numbers, so 100 == "1e2" remains true — the useful case was preserved.
  • Hex strings are not numeric, and trailing whitespace is allowed as of PHP 8.
  • in_array is loose by default, and its old behaviour made in_array(0, $roles) match any role — a real vulnerability.
  • Only the exact string '0' is falsy. '00', '0.0' and ' ' are all truthy.
  • == on arrays ignores key order; === requires order and types too.
  • switch is always loose and cannot be made strict; match always is, which is part of why it exists.
  • Floats are IEEE 754, not PHP — compare within a tolerance, and keep money in integers of the smallest unit.
  • Integer overflow becomes a float and casts saturate, so a 64-bit unsigned ID can silently collapse to PHP_INT_MAX.

Next: Arrays Are Ordered Hash Maps — the one data structure PHP gives you, what it actually is underneath, and the performance consequences of that choice.

Editorial reflection

There is something uncomfortable in writing a chapter whose main finding is that the popular criticism is out of date, because the obvious reading is that I am defending the language, and I am not especially interested in doing that. PHP earned its reputation; the comparison behaviour really was bad, and it stayed bad for a very long time after everyone knew. What interests me is the asymmetry in how reputations update. The bug took twenty years to fix and about thirty seconds to verify as fixed, and yet the folklore has outlived the behaviour by half a decade and will outlive it further, because repeating a criticism costs nothing and checking one requires opening a terminal. I notice I am not immune — I held the stale version myself until this chapter forced the issue. So the transferable habit is not "be fairer to PHP". It is that any belief you hold about a fast-moving system which you acquired more than a few years ago and have not re-run is, statistically, a belief about software that no longer exists.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.