in_array wants the needle first and strpos wants the haystack first, and PHP has shipped both that way for twenty-five years. This is about why nobody ever fixed it, which comes down to a flipped argument order failing silently rather than loudly. PHP 8.0 shipped six weeks ago and went around the problem instead of through it, freezing a much larger surface on the way past.
12 January 2021·9 min read·languages
Two of the most used functions in PHP ask nearly the same question and disagree about how to ask it. in_array($needle, $haystack) wants the thing you are looking for first. strpos($haystack, $needle) wants the thing you are looking in first. There is no rule that tells you which is which, no pattern that predicts it, and no way to be certain without checking. Every PHP developer has checked.
It gets better. strpos returns the offset where the match begins, and false when there is no match at all. The offset can be zero. In a language where a loose comparison treats false and 0 as the same thing, the only correct test is strpos($haystack, $needle) !== false, with the strict operator, and a generation of tutorials has opened with an apology for it. Get it wrong and the bug is silent: the string is there, at the front, and your code says it is not.
The names are their own layer. Substring functions are str_replace and strpos and strlen and substr and nl2br, which is five conventions inside one namespace. Array functions are almost all array_something, except when they are sort or count or in_array. And array_map takes the callback first while array_filter takes the array first, two functions written to be used within a line of each other.
This is the standard example of a badly designed language, and I want to argue it is nothing of the sort. It is a design decision, made repeatedly, on purpose, by people who understood precisely what they were choosing. They were choosing not to break your code. The mess is what that choice looks like after twenty-five years.
It began as thin wrappers over C. PHP's early string functions map almost directly onto the C standard library, which is where the argument order came from. strpos reads like strstr: the buffer you are searching, then the thing you are searching for. in_array had no C ancestor, so whoever wrote it made it read like English instead. Neither author was wrong. They were following two different sources of authority, and nobody was standing above them reconciling the two.
Nobody owned the naming, because nobody owned anything. The functions arrived as contributions, extension by extension, over years, from people scratching a particular itch on a particular afternoon. There was no style guide, no review gate for consistency, and no single person whose job was to say no to a signature. Consistency in a standard library is not a thing that happens. It is a cost somebody pays, in advance, on every commit, forever, and in the 1990s there was nobody to pay it.
There is a reported explanation for the early names. The most repeated origin story is that when PHP had under a hundred functions its symbol table hashed names by strlen, so names were chosen to spread evenly across length buckets. Rasmus Lerdorf gave that account himself, on the internals list in 2013, explaining htmlspecialchars. I would take it for what it is: a recollection of 1994 offered as a mailing list aside nineteen years later, not documented design. It is usually repeated in a far stronger form than he told it.
And then the web arrived on top of it. Somewhere in the late 1990s PHP stopped being a personal tool and became the thing a very large share of the internet ran on. Every quirk in place by then stopped being a quirk and became a contract. The window in which any of this was cheap to fix closed before most of the people who complain about it had written their first line of code.
Renaming strpos, or flipping its arguments to match in_array, is a small change in the engine. It is not a small change anywhere else. It is a change to every script on every server that calls it, and by the middle of the 2000s that was a population nobody could count, largely unmaintained, often running on hosting the original author no longer had credentials for, and frequently written by someone who had left the industry.
The failure mode is the part that decides it. A renamed function fails loudly: the call errors, you find it, you fix it. Flipped arguments fail quietly. strpos with the operands the other way round does not crash. It searches the needle for the haystack, finds nothing, returns false, and your permission check sails through. That is not an upgrade problem. That is a security incident distributed across every site that upgrades.
Here is the whole thing in one sentence: the owner of a standard library can have consistency or backward compatibility and cannot have both, because consistency is achieved by changing things and compatibility is the promise not to.
Which reframes the question everyone actually asks. The interesting property of strpos is not that it is ugly. Ugliness is close to free; you learn the order once and you are done with it. The interesting property is that it survived five major versions and two engine rewrites, because every time someone proposed the fix, the cost landed on people who had done nothing wrong and the benefit went to people who had not yet written their code. The axis is not ugly against clean. It is who pays, and when.
register_globals took ten years, and it was a security hole. The directive that injected request parameters straight into variable scope was switched off by default in PHP 4.2.0 in April 2002, deprecated in 5.3.0 in 2009, and finally removed in 5.4.0 in 2012. A decade, for a feature whose entire reputation was that it let strangers set your variables. If that is the removal schedule for a known vulnerability, nothing is ever getting removed for being untidy.
PHP 6 was the coordinated fix, and it never shipped. Begun in 2005 to make strings natively Unicode by moving the internals to UTF-16, it ran five years, absorbed the attention of the people who might otherwise have been fixing smaller things, and was abandoned in March 2010. The salvageable non-Unicode parts shipped as PHP 5.4 and the version number was skipped forever. The lesson, correctly drawn, is that the coordinated rewrite looks cheapest at the start and costs the most.
The removal that worked had its replacement already in hand. The old mysql_ extension was deprecated in PHP 5.5 in 2013 and removed in PHP 7.0 in December 2015. Two and a half years is fast by these standards, and it worked because mysqli and PDO already existed, were documented, and were what anyone paying attention had been using. Deprecation without a migration target is not a plan. It is a complaint with a version number attached.
The compatibility argument has one serious flaw, and it is that the argument never expires. Every year the cost of fixing this rose, because every year there was more code calling it. A promise you renew indefinitely is not a decision, it is a decision you have stopped making. The reasoning that correctly protected a million scripts in 2003 was protecting vastly more in 2018, and nobody reopened the question of whether the trade still held.
Other languages made the other choice and lived. Python broke its string handling in version 3, released at the end of 2008, and spent roughly a decade in pain before the old version reached its end of life last January. It is a better language now, with a community that spends no energy at all on the thing that was broken. Perl went the other way, and the successor became effectively a separate language, which is the failure case. PHP ran neither experiment.
And the practical defense of the status quo is weaker than it sounds. "You learn it once" is true for me and false for whoever is hired next year, and false again in every code review where somebody reads the line at speed rather than parsing it. The cost was never borne by the language. It was borne in small amounts, by everyone, continuously, which is exactly the kind of cost that never gets totaled and therefore never gets set against the cost of a fix.
I do not think this wins, but it is closer than the internals list has ever treated it. What tips it for me is the failure mode again. Python's break was loud, and your program did not run. A PHP argument-order break is silent and lands in string comparison, which is where authentication lives. Loud breakage is a migration you can schedule. Silent breakage is a decade of bugs attributed to something else.
It landed on the twenty-sixth of November and it did not fix strpos. It went around it. str_contains arrived, along with str_starts_with and str_ends_with, and the RFC is refreshingly plain about the reason: the existing idiom is not intuitive for a reader and is easy to get wrong, especially the !== comparison. Twenty-five years after the fact, the remedy was a new function that leaves the old one entirely undisturbed.
The other half is named arguments, first proposed in 2013 and finally passed in 2020. You can now attach parameter names at the call site and pass them in whatever order reads best, which means the ordering question stops mattering exactly where it used to bite. That is the right shape for a fix to an ordering problem you are forbidden from changing. It makes the old signature legible instead of making it different, and nothing that already worked stops working.
And here is the part I have not seen said out loud. Before 8.0, the name of a parameter was a private detail. The caller could not see it, nothing could depend on it, and you could rename it in a refactor without a thought. After 8.0 it is part of the public signature, and renaming $haystack to something clearer is a breaking change to anyone who called it by name. The RFC says so directly: greater care is now needed choosing parameter names, because they are part of the API contract.
So the relief for one frozen surface froze a much larger one. Every parameter of every function in the standard library, and in every package anyone has ever published, was promoted into the public API by a language release, retroactively, without its author doing anything or being asked. Names chosen in an afternoon by someone who assumed nobody would ever read them are load-bearing now. That is the shape of this entire subject: the relief is real, and it is paid for with an obligation nobody signed up for.
Your naming decisions become load-bearing faster than they feel. The window in which an interface is cheap to change is the window before anything depends on it, and it is shorter than anyone expects. An internal API with four consumers is negotiable. The same API with forty is a treaty. The person settling a parameter order in an afternoon should know they are probably settling it permanently, and acting on that costs nothing while the window is open.
Deprecation without a replacement is not deprecation. Every removal that completed had a migration target that was already better and already documented. The ones that ran a decade were announcements. Before marking anything deprecated, the honest test is whether you could migrate a consuming team's code yourself, this week, with only what exists today. If you could not, you have written a warning message rather than a plan, and warning messages are what people filter out of their logs.
The additive fix is usually right and always costs you. Adding the good version next to the bad one is the low-risk move and I would take it most times. Be clear what you are buying: two ways to do everything, permanently, a documentation burden that never lifts, and the certainty that new code will keep appearing that uses the old one, because the old code around it does. There is no version of this where you eventually maintain only the new one.
Silent failure is what decides whether you can break it. This is the question worth asking before any compatibility argument and it is almost never the one asked. If the change makes dependent code stop working, it is a migration, and migrations can be scheduled. If it makes dependent code keep working differently, you cannot ship it at any notice period, because the people affected will never connect the symptom to your release.
The big-bang cleanup is the most expensive option on the table. It is also the one that gets proposed, because it is the only one that produces the clean thing at the end. PHP 6 spent five years and shipped nothing. Every organization has its version of that project, and the reliable tell is that all its value sits in the final state and none arrives along the way. If you cannot ship a third of it and be better off, you are not planning a migration.
I should be careful not to let this turn into a defense of the thing itself. The argument order is bad. It was bad the day it was written, it has cost an enormous number of hours in aggregate, and the people making those calls in the mid-1990s were not weighing a twenty-five year compatibility obligation. They were writing a personal tool. Correct at every step is not the same as correct, and a chain of locally reasonable decisions is exactly how a system ends up somewhere nobody would have chosen.
What stays with me is the asymmetry in the clock. The decision took an afternoon and the consequence is measured in decades, and there is no mechanism anywhere in software that makes those two durations speak to each other. PHP is only the loudest case because it succeeded early enough and widely enough that its afternoon is still visible. Every API your organization owns has the same structure, quieter, and the needle is already on the wrong side of the comma in something you shipped last quarter.