The Worst Programming Language

PHPvsJS

Two prosecution briefs. 28 arguments against PHP, 41 against JavaScript. Every one executed or sourced.

October 26, 2026

Card reading PHP vs JavaScript: which is the worst programming language, with 28 arguments against PHP and 3 surviving rebuttal, 41 against JavaScript and 9 surviving, verdict JavaScript

Why this debate

"Worst programming language" is a pub argument, and it normally runs on vibes. Someone quotes the Wat talk, someone links the fractal of bad design essay, nobody is convinced of anything.

I write PHP for a living and JavaScript because there is no choice, so I ran both prosecutions for real. Each brief below presents its strongest arguments with the code that produced them: the PHP side ran on PHP 5.6.40, 7.4.33 and 8.5.11, the JavaScript side on Node.js v26.10.0, and every number links to a named source.

PHP argues purple, JavaScript argues yellow. Then the two cases collide, and I give a verdict.

◆

The case against PHP

The strongest arguments from the PHP brief

P

Opening statement

For the prosecution

PHP runs most of the server-side web, and nobody here disputes that. The case is narrower: its design produced the most harm per line of code ever shipped. Equality that still isn't transitive in 2026, a standard library with no consistent naming or argument order, two parallel error systems, and a security record so bad that whole features - register_globals, magic_quotes, mysql_*, safe_mode - had to be torn out after they had already shipped to millions of servers.

The defence says "PHP 8 fixed it." Three answers. Much of it isn't fixed. The fixes are silent semantic changes, so the same line returns different results on 5.6, 7.4 and 8.5. And the web never got the fixes: about 48% of PHP websites run a branch that gets no security updates.

1. == is still broken in PHP 8.5: not transitive, and two different password hashes can compare equal

The loose equality operator is the one beginners reach for first, and it still can't be trusted for strings. Identical output on PHP 5.6, 7.4 and 8.5:

var_dump(null == 0);      // true
var_dump(0 == "0");       // true
var_dump("0" == null);    // false   <- so == is not transitive

var_dump(md5("240610708") == md5("QNKCDZO")); // true
var_dump("1" == "01");    // true   (string vs string!)
var_dump("10" == "1e1");  // true

Any two strings of the form 0e<digits> are parsed as zero times ten to the n, so they compare equal. These are the well-known magic hashes. A login check written as if (md5($pass) == $storedHash) accepts a wrong password whenever both hashes happen to start with 0e followed by digits. The fix exists (=== or hash_equals()), but the dangerous form is the shorter and more obvious one. Eevee documented the non-transitivity in 2012. Fourteen years and four major versions later, the null/0/"0" triangle above still holds.

Source: PHP manual, comparison operators - which advises using === "in most cases". When the manual tells you not to use the equality operator, the operator has failed.

2. "abc" == 0 was true for about 25 years - and a third of PHP sites still evaluate it that way

Until PHP 8.0 (November 2020), comparing any non-numeric string to 0 returned true:

var_dump("abc" == 0);              // true   [8.5: false]
var_dump("1abc" == 1);             // true   [8.5: false]
var_dump(in_array("abc", [0]));    // true   [8.5: false]
switch ("abc") { case 0: echo "matched 0"; }   // matched 0   [8.5: default]

The fixing RFC calls the old behaviour "blatantly wrong in some cases" and says it "can easily lead to bugs or even security issues". It passed 44 to 1 and landed in PHP 8.0.

The catch is deployment. W3Techs (26 Oct 2026): of websites using PHP, 26.7% run PHP 7, 7.7% run PHP 5 and 0.1% run PHP 4, so 34.5% of PHP sites still evaluate "abc" == 0 as true, today.

Sources: RFC: Saner string to number comparisons, W3Techs PHP version share

3. The same line of code means different things on different PHP versions

PHP's fixes are often silent semantic changes, not errors. A program that passes its tests on 7.4 can return different values on 8.x without a single warning:

ExpressionPHP 5.6PHP 7.4PHP 8.5
max('apple', 10)1010'apple'
substr('abc', 5)falsefalse''
sort(["10","9a","1e1",9])[9,"1e1","10","9a"]["10","1e1","9a",9][9,"10","1e1","9a"]
1/0false + warningINF + warningDivisionByZeroError
"abc" == 0truetruefalse

The sort() row is the worst: the same four-element array sorts into three different orders on three versions, because the mixed-type comparison it relies on isn't a total order. Upgrading PHP is a re-audit of every comparison in the codebase, which helps explain why half the web never upgrades.

4. register_globals let any URL parameter overwrite any variable - for a decade

PHP shipped a core feature that turned every query-string, POST and cookie key into a global variable. It was on by default until PHP 4.2.0 (2002) and not removed until PHP 5.4 (2012). The classic shape:

if (check_login()) { $authorized = true; }
if ($authorized) { show_admin(); }
// request ?authorized=1 -> $authorized is set before the check runs

And it wasn't alone. PHP 5.4's UPGRADING file lists nine security directives deleted in one release: register_globals, magic_quotes_gpc, magic_quotes_runtime, magic_quotes_sybase, safe_mode and friends. magic_quotes backslash-escaped all input, which corrupted data while giving false confidence against SQL injection. safe_mode tried to sandbox shared hosts inside the interpreter and was bypassed repeatedly. The manual pages that documented them now return 404 on php.net. The same pattern survives today in extract(), still in PHP 8.5, still overwriting existing variables by default.

Sources: PHP 4.2.0 release notes, PHP 5.4 UPGRADING file

5. The interpreter itself is in CISA's exploited-vulnerabilities catalog three times

Not PHP apps - the PHP runtime has repeatedly been the remote-code-execution vector:

CVEComponentCVSSRansomware use
CVE-2012-1823php-cgi argument injection → RCE9.8unknown
CVE-2019-11043PHP-FPM buffer underflow → RCE8.7known
CVE-2024-4577php-cgi argument injection on Windows → RCE9.8known

CVE-2024-4577 is explicitly a bypass of CVE-2012-1823: a 12-year-old fix coming back through Windows "Best-Fit" character conversion, with all XAMPP installs on Windows vulnerable by default. Imperva saw it weaponised by the TellYouThePass ransomware two days after disclosure. For comparison, the same KEV catalog has zero entries for the Node.js server runtime.

Sources: DEVCORE, CVE-2024-4577 analysis, Imperva on TellYouThePass, CISA KEV catalog

6. For a decade, every file function was an unserialize() gadget

From PHP 5.3 until the PHP 8.0 fix, any filesystem function given a phar:// path would unserialize the archive's metadata, running __destruct and __wakeup on attacker-chosen classes. Not just unserialize(): file_exists(), is_dir(), getimagesize(), all of them. Demonstrated on PHP 7.4:

var_dump(file_exists('phar://.../evil.phar/x.txt'));
// bool(true)
// end of script
//   [Gadget::__destruct ran, cmd=touch pwned]

The destructor fired purely from a file_exists() call. Sam Thomas presented the technique at Black Hat USA 2018 with real case studies in Typo3 and WordPress - the WordPress one reported in February 2017 and still unfixed on the slide. "PHP Object Injection to RCE" is on Patchstack's 2025 top-10 exploited list, and every site still on PHP 7.x - 26.7% of PHP sites - has this behaviour.

Sources: Black Hat USA 2018 slides, RFC: stop auto-unserializing Phar metadata, Patchstack, State of WordPress Security 2026

7. The standard library has no naming convention and no argument order

2,143 internal functions, counted by Reflection on PHP 8.5. Underscore use is random: str_replace, str_contains and str_starts_with have them; strlen, strpos, strtolower don't. And needle/haystack order reverses between functions doing the same conceptual thing:

strpos           ($haystack, $needle, $offset)
str_contains     ($haystack, $needle)
in_array         ($needle, $haystack, $strict)   <- needle first!
array_search     ($needle, $haystack, $strict)   <- needle first!

array_map   ($callback, $array, ...$arrays)      <- callback first
array_filter($array, $callback, $mode)           <- array first

You can't guess a PHP function's name or signature from another one; you look each up. Passing needle and haystack in the wrong order fails silently.

8. Strings are byte arrays - the defaults mangle non-ASCII text

PHP has no Unicode string type. strlen, strtoupper, substr, strrev operate on bytes; correct results need the optional mbstring extension and the mb_* variant, every time. On "Zażółć" (valid UTF-8), PHP 8.5:

strlen:        10        mb_strlen:      6
strtoupper:    ZAżółć    mb_strtoupper:  ZAŻÓŁĆ
substr($s,0,3):    not valid UTF-8 (cuts a character in half)
str_word_count('zażółć gęślą jaźń'):  8   (should be 3)

Non-English text is the common case for most of the world, and PHP's defaults silently corrupt it. JavaScript's single string type gets length and case right with no extra library. I write PHP every day and I still get bitten by this one.

9. A grab-bag of small, silent traps

// foreach-by-reference leaves a dangling reference that corrupts the NEXT loop
$arr=[1,2,3]; foreach($arr as &$v){} foreach($arr as $v){}
// $arr is now [1,2,2]

if (strpos('abc','a')) {...}   // match at position 0 -> falsy -> wrong branch

var_export(empty('0'));        // true - '0' is the only falsy string literal
var_export((bool)'0.0');       // true - but (bool)'0' is false

// array keys silently coerce: "1", 1.7 and true all collapse to int 1
$a = ["1"=>"a", 1.7=>"c", true=>"d"];  // => [1=>"d"]

strtotime('2021-02-30');       // => 2021-03-02 (rolls forward, no error)

None of these is exotic; strpos-as-boolean and foreach-by-reference are interview questions precisely because they bite in production. Each is a place PHP chose silent coercion over an error, and "it ran without error" tells you very little.

10. Still insecure by default in PHP 8.5

Compiled-in defaults from php -i on a fresh 8.5.11:

expose_php              => On    # sends "X-Powered-By: PHP/8.5.11" to every client
allow_url_fopen         => On    # file functions fetch remote URLs - SSRF surface
session.cookie_httponly => Off   # session cookie readable by JavaScript
session.cookie_secure   => Off   # session cookie sent over plain HTTP

And that is the pattern across the whole case: the safe choice is always opt-in. === not ==, declare(strict_types=1), mb_* not str*, hash_equals() not ==, prepared statements not mysql_*, cookie_httponly not off. Every default is the unsafe one.

◆

The case against JavaScript

The strongest arguments from the JavaScript brief

J

Opening statement

For the prosecution

Most bad languages are bad in private. JavaScript is bad in public, on every device on earth, forever.

JavaScript runs the web, so it obviously works. The case is narrower: its original design mistakes are the most widely deployed and the least fixable. A PHP shop can upgrade PHP and drop register_globals, magic quotes and mysql_*. JavaScript cannot remove anything, because the browser has to keep running every page written since 1995. So typeof null is still "object", == still has its coercion table, and document.all is still written into the spec as an object that pretends to be undefined.

Every fix is additive: === next to ==, let next to var, Number.isNaN next to isNaN, Temporal next to Date, TypeScript on top of all of it. The defence says "use TypeScript, use a linter, use ===". That is the confession: a language whose recommended usage is "a different language, plus a tool that bans half of this one" is a language that failed.

1. It was designed in a ten-day sprint, and the mistakes were frozen within two years

The history paper co-written by JavaScript's own creator calls it a language "created at Netscape in a ten-day hack" and has a section titled "Oddities and Bugs". One of them: JavaScript 1.0 treated = as == inside if predicates, so if (a = 0) and if (a == 0) were documented as equivalent.

In 1997 Brendan Eich tried to fix == by removing most of its automatic coercions - and was overruled, because the web already depended on the broken version. He reverted to the original semantics in JavaScript 1.3, twenty-four months after writing the bug. Nobody has been allowed to fix it since.

Source: Wirfs-Brock & Eich, JavaScript: The First 20 Years (HOPL 2020)

2. == is a coercion table nobody can hold in their head

"" == 0        // true
"0" == 0       // true
"" == "0"      // false   <- not transitive
[] == ![]      // true    <- an array equals its own negation
[0] == false   // true, yet !![0] === true   (it is truthy)
null == 0      // false
null >= 0      // true
null > 0       // false   <- >= 0 but neither > 0 nor == 0
[1,2,3] == "1,2,3" // true

The spec's IsLooselyEqual is a 14-step algorithm that recurses through ToPrimitive and ToNumber, and >= runs a different algorithm entirely - which is how null can be >= 0 while being neither > 0 nor == 0. The HOPL paper's own verdict: "the coercions have proven to be a significant source of confusion and coding bugs. This is particularly true for the == operator."

=== exists since ES3 (1999) and ESLint's eqeqeq bans ==, but the bad operator is the short one every newcomer reaches for first.

Source: ECMA-262, IsLooselyEqual

3. + means two different things depending on types you can't see

"5" + 2        // "52"
"5" - 2        // 3
"2" + "2" - "2"// 20
[] + []        // ""
[] + {}        // "[object Object]"
{} + []        // 0        (a block, then unary +[])
[1,2] + [3]    // "1,23"
"b"+"a"+ +"a"+"a" // "baNaNa"

A form field value, a query param, a localStorage read and a JSON field can all be strings that look like numbers. total + item.price produces "1020" instead of 30 with no exception and a plausible-looking output. The failure lands in data, not in a stack trace. Gary Bernhardt made these exact lines famous in his "Wat" talk in 2012; every one still behaves the same.

Sources: ECMA-262, ApplyStringOrNumericBinaryOperator, Bernhardt, "Wat" (CodeMash 2012)

4. typeof null === "object" - a known bug, documented by the inventor, permanently in the spec

typeof null   // "object"
typeof []     // "object"
typeof NaN    // "number"

Eich's recollection in the HOPL paper: null was encoded with the same internal tag as object values in the original implementation - a leaky abstraction he calls "a great annoyance to JavaScript programmers". The fix was considered for ES5 strict mode and rejected because it "would not be subtractive". So the canonical "is this an object?" check is wrong for null, and every codebase writes x !== null && typeof x === "object" forever.

Source: HOPL 2020, §3.2 and §20.1.1

5. sort() sorts numbers as strings

[10, 9, 1, 2].sort()           // [1, 10, 2, 9]
[-1, -2, 3].sort()             // [-1, -2, 3]   ("-1" < "-2" as strings)
[undefined, 3, null, 1].sort() // [1, 3, null, undefined]  (null sorts as "null")

MDN: without a comparator, elements are "sorted by converting them to strings and comparing strings in UTF-16 code units order". The zero-argument form is the obvious one to write and it's wrong for the most common case. Same input elsewhere: Python returns [1, 2, 9, 10]. Ruby returns [1, 2, 9, 10]. Even PHP 8.5 returns [1, 2, 9, 10]. JavaScript is the outlier, permanently.

Source: MDN, Array.prototype.sort

6. Every number is a float - JSON.parse silently corrupts 64-bit IDs

0.1 + 0.2                                // 0.30000000000000004
9007199254740992 === 9007199254740993    // true
JSON.parse('{"id":9007199254740993}').id // 9007199254740992  <- wrong ID
JSON.stringify({ n: 10n })               // TypeError
1n + 1                                   // TypeError

0.1 + 0.2 is IEEE 754 and identical in every language with doubles - conceded. The JavaScript-specific failure is that there was no integer type at all for 25 years (BigInt arrived in ES2020) and the default number type can't hold a 64-bit database ID. X had to add a string id_str field to every API object because JavaScript clients corrupted tweet IDs; their docs still say "always use id_str". And BigInt, the fix, doesn't interoperate with JSON or with ordinary numbers.

A wrong ID doesn't throw. It quietly points at a different user's record.

Source: X developer docs, X IDs

7. Prototype pollution: a vulnerability class that exists because of how JS objects work

const merge = (t, s) => { for (const k in s) {
  if (typeof s[k] === 'object' && s[k] !== null) { t[k] = t[k] || {}; merge(t[k], s[k]); }
  else t[k] = s[k]; } return t; };
merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
({}).isAdmin   // true   <- every object in the program is now an admin

Every plain object inherits from a single mutable Object.prototype, and __proto__ is a magic key, so a naive deep-merge of untrusted JSON rewrites the behaviour of every object in the process. The GitHub Advisory Database has 620 "prototype pollution" advisories in npm. lodash's CVE-2019-10744 is CVSS 9.1 Critical. A USENIX Security 2023 paper found 11 universal gadgets in core Node.js APIs leading to code execution, and hand-exploited eight RCEs in the NPM CLI, Parse Server and Rocket.Chat.

Languages without a runtime-mutable root prototype - PHP, Java, C#, Go - don't have this bug class at all.

Sources: GitHub Advisory Database, GHSA-jf85-cpcp-j695 (lodash), Silent Spring, USENIX Security 2023

8. The language cannot remove anything - "don't break the web" makes every mistake permanent

ECMA-262's Annex B says it in writing, about its own legacy features: they "have one or more undesirable characteristics and in the absence of legacy usage would be removed from this specification. However, the usage of these features by large numbers of existing web pages means that web browsers must continue to support them." The standards body formally documents features it considers bad and is not allowed to delete.

The spec even carves out document.all: a real object that behaves like undefined in typeof, ToBoolean and ==, to keep 1990s IE-sniffing scripts working. In every browser today typeof document.all === "undefined", !!document.all === false, and document.all.length works.

The same rule renamed standard methods: flatten became flat after SmooshGate broke MooTools sites in 2018, and contains became includes in 2014 for the same reason - a library from 2006 still dictates method names in a 2026 language. ES4 was abandoned in 2008. There will never be a breaking version.

Sources: ECMA-262, Annex B, Chrome for Developers, SmooshGate, Pony Foo on contains → includes

9. npm is the industry's largest malware channel - and install scripts run by default

Sonatype's State of the Software Supply Chain 2026: over 454,600 malicious packages found in 2025, and "over 99% of open source malware occurred on npm." CISA's September 2025 alert covers Shai-Hulud, the first self-replicating npm worm, which compromised over 500 packages and used each victim's own credentials to infect theirs. That same month, one phished maintainer took down chalk (299 million downloads a week) and debug (47 million a week) in a single attack.

Why it happens: the language shipped without a standard library, so trivial functions became packages. isarray - a polyfill for a function added in ES5 in 2009 - still gets 166 million downloads a week. Installing an average npm package means trusting 79 third-party packages and 39 maintainers, and npm's default is to run every package's preinstall/postinstall scripts - the exact mechanism ua-parser-js and Shai-Hulud used to execute.

"That's npm, not the language" is the standard defence. npm ships with Node, the micro-dependency culture exists because the runtime had no stdlib, and install-time scripts are a default. When 99% of open-source malware targets one ecosystem, the ecosystem is the attack surface.

Sources: Sonatype, State of the Software Supply Chain 2026, CISA alert, Sept 2025, Semgrep on chalk/debug, npm docs, ignore-scripts, USENIX 2019, Small World with High Risks

10. The industry already voted - TypeScript is the confession

GitHub's Octoverse 2025: "August 2025 marks the first time TypeScript emerged as the most used language on GitHub" - 2,636,006 monthly contributors, up 66.6% year over year, overtaking both Python and JavaScript. In State of JS 2024 the largest single group of respondents writes 100% TypeScript.

A Microsoft Research / UCL study (ICSE 2017) found that static types alone detect 15% of real bugs that had already survived testing and review in public JS projects - and the authors call that conservative.

TypeScript is excellent. It also can't fix the runtime: every TS program still executes with ==, this, typeof null, float-only numbers, Date and prototype pollution. Types are erased at compile time; JSON.parse doesn't care about your interface.

Sources: GitHub Octoverse 2025, Gao, Bird, Barr, To Type or Not to Type (ICSE 2017), State of JS 2024

11. And the smaller stuff still bites daily

function asi() {
  return
  { ok: true };
}
asi()                    // undefined - ASI inserted "return;"

["1","7","11"].map(parseInt)  // [1, NaN, 3]   (index passed as radix)

"😀".length        // 2   - UTF-16 code units
"😀".split("")     // two lone surrogates - broken text

And for every broken built-in there is now a second, correct API sitting next to it: == and ===, var and let, isNaN and Number.isNaN, sort() and toSorted(), Date and Temporal - which reached Stage 4 in March 2026, thirty-one years after Date. A newcomer has to learn both, plus which one the codebase they inherited uses. "Cognitive overload" is the #2 language pain point in State of JS 2025, behind only the missing type system.

Sources: MDN, automatic semicolon insertion, MDN, String.length, Igalia, Temporal reaches Stage 4, State of JS 2025

◆

Where the two cases collide

The strongest point each side lands on the other

"PHP fixed the same class of mistakes" - the JavaScript brief's closer

The JS brief ends with a head-to-head, the same expressions run on PHP 8.5.11 and Node v26.10.0 on the same day:

ExpressionPHP 8.5.11Node 26
0 == "a" / 0 == ""falsetrue
[] + 1TypeError"1"
"abc" * 1TypeErrorNaN (silently)
type of null"NULL""object"
largest native int92233720368547758079007199254740991
decode {"id":9007199254740993}exactoff by one
sort([10,9,1,2])[1,2,9,10][1,10,2,9]
removed legacy featuresmysql_connect is gonenothing has ever been removed

Its conclusion: PHP's famous coercion warts were real, but PHP broke compatibility to fix them - the saner-comparisons RFC passed 44 to 1. JavaScript tried the same fix to == in 1997 and had to revert. A language that can repair itself, the JS brief argues, is not the worst language. A language whose governing principle forbids repair is.

"Fixed but not deployed" - the PHP brief's counter

The PHP brief's answer is that a fix nobody receives isn't a fix. The same W3Techs numbers that give "abc" == 0 a 34.5% survivor rate also put about 48% of PHP websites on a branch with no security support at all, rising to about 66% when PHP 8.2 drops off at the end of December 2026. Only about 3.1% of PHP sites run the current release. Zend's own 2022 landscape report found 61.9% of teams running end-of-life PHP.

And even inside the spec, the fix is partial. Magic-hash equality, non-transitive ==, extract(), the needle/haystack order - all still behave the same on PHP 8.5.

So both sides are right. PHP can repair and mostly didn't reach its users. JavaScript can't repair at all. The question is which of those facts you find worse.

Sources: W3Techs, php.net supported versions, DevClass on the Zend 2022 report, the saner-comparisons RFC

◆

The verdict

PHP's past is the scarier read. register_globals made ?authorized=1 an admin login for a decade. The runtime sits in CISA's actively-exploited catalog three times. Half the deployed base still runs branches nobody patches. I won't pretend any of that is fine.

But I count, and the count isn't close. The JavaScript brief made 41 arguments against PHP's 28. I put both briefs through a rebuttal round, each side defending itself with the other's ammunition. Three PHP arguments survived untouched. Nine JavaScript arguments did. Weakened-but-alive is 25 for PHP and 34 for JavaScript.

The gap matters because of what PHP did with its mistakes: it removed them. register_globals, magic quotes and mysql_* are gone, and PHP 8 made "abc" == 0 false. JavaScript's spec, in Annex B, lists its own bad features and keeps them because old pages depend on them. Eich tried to fix == in 1997 and had to revert it. Every one of those nine survivors is permanent, and more than 99% of 2025's open-source malware shipped through npm on top of them.

PHP is a language with a bad history that keeps fixing itself. JavaScript is a language with more flaws than PHP and a rule that it may never fix them. My verdict is JavaScript as the worst language.

Method: two adversarial prosecution briefs, 69 arguments in total. Every code block was executed - PHP 5.6.40 and 7.4.33 in Docker, PHP 8.5.11 and Node.js v26.10.0 locally - and every number traced to the linked source.

Published October 26, 2026 · More articles