PONYλM2Modula-2

JavaScript.CodeCompared.To/PHP

An interactive executable cheatsheet comparing JavaScript and PHP

JavaScript (ES2025) PHP 8.3.12 (Wandbox), 8.3.11 (Judge0) or 8.5.8 (in-browser)
The Request Lifecycle
Hello, World
Two things to notice before anything else. A PHP file starts in HTML mode and only becomes code after <?php, which is the tell that this language began as a template system. And echo writes exactly what you give it — no newline is added, so you write one.
console.log("Hello, World!");
<?php echo "Hello, World!\n";
A file that is nothing but PHP conventionally omits the closing ?>, because any whitespace after it is sent to the browser and will break a later header() call. echo is a language construct rather than a function, so echo "a", "b"; is legal; print is the expression-valued cousin, and var_dump is the debugging one that shows types.
One process forever, against one process per request
This is the row the whole page hangs on, and it is the one thing a single runnable example cannot show you: both columns print visit 1 then visit 2, because both calls happen inside one process. What differs is what happens next.
let visitCount = 0; function handleRequest() { visitCount += 1; return `visit ${visitCount}`; } console.log(handleRequest()); console.log(handleRequest()); // the module is still loaded
<?php function handle_request(): string { static $visitCount = 0; $visitCount += 1; return "visit $visitCount"; } echo handle_request(), "\n"; echo handle_request(), "\n"; // still the SAME request
Your Node process stays up between requests, so visitCount keeps climbing until you redeploy — which is why a leak matters, why module-level caches work, and why one bad request can take down every other. A PHP request starts a fresh interpreter, runs your file, sends the output and throws everything away: the next request sees visit 1 again. Anything that must survive goes to a session, a cache like Redis, or the database. That single fact explains most of what follows.
Blocking is fine here
Every instinct Node gave you about synchronous I/O — that it is a crime, that it must become a promise, that readFileSync is for startup only — comes from a single fact: your process is shared by every user at once. PHP's is not.
const { writeFileSync, readFileSync } = require("node:fs"); const logPath = "/tmp/javascript-php-blocking-anchor.txt"; writeFileSync(logPath, "one\ntwo\nthree\n"); // Node: this blocks the ONE thread every user shares const contents = readFileSync(logPath, "utf8"); console.log(`read ${contents.trim().split("\n").length} lines synchronously`);
<?php $logPath = "/tmp/javascript-php-blocking-target.txt"; file_put_contents($logPath, "one\ntwo\nthree\n"); // PHP: this blocks nobody but the one visitor waiting for it $contents = file_get_contents($logPath); echo "read ", count(explode("\n", trim($contents))), " lines synchronously\n";
There is exactly one visitor in a PHP process, so a synchronous read delays that visitor and nobody else. This is why file_get_contents, PDO queries and curl_exec are all blocking calls with no promise in sight, and why PHP code reads top to bottom with no callbacks, no await, and no function colouring. The cost is paid elsewhere: concurrency comes from running many processes, so the tuning knob is a worker-pool size in PHP-FPM instead of an event loop.
The request is ambient, not a parameter
In Node the request is an object handed to your function, which is what makes middleware, testing and multiple concurrent requests possible. In PHP the request is the environment: it is already there, in superglobals, before your first line runs.
function handler(request, response) { const name = new URL(request.url, "http://x").searchParams.get("name") ?? "stranger"; response.end(`hello ${name}`); } handler({ url: "/greet?name=Ada" }, { end: (body) => console.log(body) });
<?php // The web server fills $_GET in; this line stands in for it so the example runs. $_GET = ['name' => 'Ada']; $name = $_GET['name'] ?? 'stranger'; echo "hello $name\n";
$_GET, $_POST, $_SERVER, $_COOKIE, $_FILES and $_SESSION are visible in every scope without being passed or imported, because there is only ever one request in the process to confuse them with. Modern frameworks wrap them in a Request object precisely so code can be tested, and PSR-7 is the standard shape for that. Treat every value in them as attacker-controlled text: there is no schema and no type.
Output & Syntax
String interpolation
PHP interpolates inside ordinary double-quoted strings — no backtick, no dollar-brace. Single quotes never interpolate, which is the switch you use to say "leave this alone".
const name = "Ada"; const age = 36; console.log(`${name} is ${age}`); console.log(name + " is " + age);
<?php $name = "Ada"; $age = 36; echo "$name is $age\n"; echo $name . " is " . $age . "\n";
A bare $name works; anything more complex needs braces, as in "{$user['name']}" or "{$order->total}". The concatenation operator is ., not + — using + on two strings is a TypeError in PHP 8, which is a small mercy after JavaScript's "1" + 1. And note the trailing \n: it only works in double quotes, because single-quoted strings do not process escapes either.
Formatted output
PHP inherited C's printf family, which JavaScript never had.
const total = 1234.5; console.log(`Total: ${total.toFixed(2)}`); console.log(`Total: ${total.toLocaleString("en-US", { minimumFractionDigits: 2 })}`);
<?php $total = 1234.5; printf("Total: %.2f\n", $total); echo "Total: ", number_format($total, 2), "\n";
printf writes, sprintf returns the string, and vsprintf takes the arguments as an array. The specifiers are C's: %s, %d, %.2f, %05d, and %'*10s for padding with a chosen character. number_format is the money-shaped one, with thousands separators and a fixed number of decimals — the closest thing to toLocaleString, without the locale.
Multi-line strings
A heredoc is PHP's template literal: it interpolates, spans lines, and needs no escaping of quotes. Since PHP 7.3 the closing marker may be indented, and its indentation is stripped from every line.
const user = "Ada"; const message = `Dear ${user}, Your order has shipped. Thanks.`; console.log(message);
<?php $user = "Ada"; $message = <<<TEXT Dear $user, Your order has shipped. Thanks. TEXT; echo $message, "\n";
That de-indenting rule is the one thing a template literal cannot do — a JavaScript multi-line string carries whatever leading spaces the source had, which is why dedent libraries exist. Use <<<'TEXT' with quotes around the marker for the nowdoc form, which is the single-quoted variant: no interpolation, no escapes.
Variables & Types
Every variable wears a dollar sign
There is no let, no const for a local, and no declaration at all — assigning to a name creates it. The $ is part of the variable, not a decoration.
let count = 1; const label = "items"; count += 1; console.log(count, label);
<?php $count = 1; const LABEL = "items"; $count += 1; echo $count, " ", LABEL, "\n";
Because there is no declaration keyword, a typo creates a new variable rather than an error; PHP 8 at least emits a warning when you read one that was never set. The scoping is the bigger surprise: PHP has no block scope and no closure over enclosing locals. A variable set inside an if is visible after it, and a function cannot see the surrounding function's variables unless you pass them in — which is what use in the closures section is for. const at file level declares a genuine constant, and it takes no dollar sign.
Type declarations that are checked at run time
PHP's type declarations are not TypeScript. Nothing is checked when the file is parsed; the check happens at the moment of the call, and it throws.
function double(value) { return value * 2; } console.log(double(21)); console.log(double("21")); // "21" is coerced to a number
<?php declare(strict_types=1); function double(int $value): int { return $value * 2; } echo double(21), "\n"; try { echo double("21"), "\n"; } catch (TypeError $error) { echo "TypeError: ", preg_replace('/, called in.*/s', '', $error->getMessage()), "\n"; }
That makes them closer to a runtime assertion than to a type system, and it means they are worth adding even to code nobody type-checks — a wrong argument fails at the boundary rather than three frames deeper. declare(strict_types=1) at the top of every file is the setting you want: without it PHP coerces "21" to 21 and the call succeeds silently. Static analysers (PHPStan, Psalm) read the same declarations plus docblock generics and give you the compile-time half.
One empty value, not two
JavaScript has null and undefined, and half its bugs live in the gap. PHP has only null, and isset() answers "set and not null" in one call.
const config = { host: null }; console.log(config.host ?? "localhost"); console.log(config.missing ?? "default"); console.log(config.missing?.length);
<?php $config = ['host' => null]; echo $config['host'] ?? "localhost", "\n"; echo $config['missing'] ?? "default", "\n"; echo var_export($config['deep']->length ?? null, true), "\n";
?? works the same way in both languages — it falls back only on null (and, in PHP, on a missing key), never on 0 or "", which is what makes it better than || and or. ?-> is the nullsafe operator, PHP's ?.: $order?->customer?->name stops at the first null instead of raising. empty() is the trap to avoid — it is true for 0, "0", "" and [] as well as null.
Integers and floats are different types
JavaScript has one number type — a double — and a separate BigInt. PHP has a real int and a real float, and division is the place you notice.
console.log(7 / 2); console.log(Math.trunc(7 / 2)); console.log(0.1 + 0.2); console.log(Number.MAX_SAFE_INTEGER + 2);
<?php echo 7 / 2, "\n"; echo intdiv(7, 2), "\n"; echo var_export(0.1 + 0.2, true), "\n"; echo var_export(PHP_INT_MAX + 2, true), "\n";
7 / 2 gives 3.5 in both, because PHP promotes to float rather than truncating; intdiv is the integer division, and % is integer-only (use fmod for floats). The overflow behaviour differs interestingly: past PHP_INT_MAX an integer silently becomes a float and starts losing precision, where JavaScript was imprecise above MAX_SAFE_INTEGER all along. Both print 0.30000000000000004 for the classic, because both are IEEE 754 underneath — but only because this example uses var_export: a plain echo 0.1 + 0.2 prints 0.3, since echo rounds to precision (14 digits by default) while var_export and json_encode use serialize_precision and show the truth.
Equality & Coercion
Loose equality, and the scar tissue you already have
Both languages shipped a == that compares across types, both regret it, and both fixed it the same way: a second operator that does not convert. Your existing instinct — reach for the triple — is the correct instinct here too.
console.log(0 == ""); console.log("1" == 1); console.log(null == 0); console.log([] == false); console.log("1" === 1);
<?php echo var_export(0 == "", true), "\n"; echo var_export("1" == 1, true), "\n"; echo var_export(null == 0, true), "\n"; echo var_export([] == false, true), "\n"; echo var_export("1" === 1, true), "\n";
The tables differ in the details, so do not port the trivia across. 0 == "" is true in JavaScript and false in PHP — it was true in PHP too before 8.0, and "abc" == 0 is false now as well, because PHP 8 compares a number to a non-numeric string as strings. null == 0 goes the other way: false in JavaScript, where null only equals undefined, and true in PHP, where null converts to 0. That was the single most-mocked wart in the language and it is gone. === compares type and value, and for objects it means the same instance, exactly as in JavaScript.
Comparing arrays and objects
Here PHP is straightforwardly nicer, and it is worth un-learning the JavaScript reflex of serialising two things to compare them.
const first = [1, 2, 3]; const second = [1, 2, 3]; console.log(first === second); console.log(JSON.stringify(first) === JSON.stringify(second));
<?php $first = [1, 2, 3]; $second = [1, 2, 3]; echo var_export($first === $second, true), "\n"; echo var_export($first == $second, true), "\n";
PHP arrays compare by contents: === is true when the same key/value pairs appear in the same order with the same types, and == ignores the order. JavaScript has no structural comparison at all for arrays or objects, which is why JSON.stringify gets abused for it. For objects PHP keeps the JavaScript rule — == is same class with equal properties, === is the same instance.
What counts as false
The falsy lists nearly match, and the two places they part company are both places PHP will surprise you.
for (const value of [0, "", "0", [], {}, null]) { console.log(JSON.stringify(value), Boolean(value)); }
<?php foreach ([0, "", "0", [], new stdClass(), null] as $value) { echo json_encode($value), " ", var_export((bool) $value, true), "\n"; }
The string "0" is falsy in PHP and truthy in JavaScript — a real source of bugs when a form field or a database column arrives as text. And an empty array is falsy in PHP where an empty array is truthy in JavaScript, which is the one that catches JavaScript developers most often (if ($rows) is an idiomatic emptiness test here). Objects are always truthy in both.
The One Array Type
Arrays and objects are the same thing
PHP has one container where JavaScript has two. An array is an ordered map: keys may be integers or strings, insertion order is preserved, and a plain list is just the case where the keys happen to be 0, 1, 2.
const numbers = [10, 20, 30]; const person = { name: "Ada", age: 36 }; console.log(numbers[1], person.name); console.log(numbers.length, Object.keys(person).length);
<?php $numbers = [10, 20, 30]; $person = ['name' => 'Ada', 'age' => 36]; echo $numbers[1], " ", $person['name'], "\n"; echo count($numbers), " ", count($person), "\n";
That is why count() works on both and why every array function takes both. The cost is that "is this a list or a dictionary?" has no type-level answer — array_is_list() (PHP 8.1) is the run-time check, and it matters because json_encode emits […] for a list and {…} for anything else, so deleting one element can change the shape of your API response.
An array is a value; an object is a reference
This is the single difference most likely to cost you an afternoon. In JavaScript every array and object is a reference. In PHP an array is a value: assigning it, or passing it to a function, copies it.
const first = [1, 2, 3]; const second = first; // the same array second.push(4); console.log(first.length);
<?php $first = [1, 2, 3]; $second = $first; // a COPY $second[] = 4; echo count($first), "\n";
So a function that appends to an array parameter changes nothing the caller can see, unless the parameter is declared &$items to pass by reference. The copy is lazy underneath — PHP copies on write, so the cost is only paid if you modify it — but the semantics are a full copy. Objects go the other way and behave exactly like JavaScript: $b = $a on an object gives you two names for one instance, and clone is how you get a copy (a shallow one).
map, filter and reduce are functions, not methods
The same three operations exist, as free functions rather than methods — so there is no chaining, and the intermediate steps get names.
const numbers = [1, 2, 3, 4, 5, 6]; const result = numbers .filter((number) => number % 2 === 0) .map((number) => number * number) .reduce((running, number) => running + number, 0); console.log(result);
<?php $numbers = [1, 2, 3, 4, 5, 6]; $evens = array_filter($numbers, fn($number) => $number % 2 === 0); $squares = array_map(fn($number) => $number * $number, $evens); $result = array_reduce($squares, fn($running, $number) => $running + $number, 0); echo $result, "\n";
Watch the argument order, which is genuinely inconsistent: array_map(callback, array) puts the callback first, while array_filter(array, callback) and array_reduce(array, callback, initial) put the array first. And array_filter preserves keys, so filtering a list can leave you with keys 1, 3, 5 and a json_encode that suddenly emits an object — wrap it in array_values() when you want a list back. This is the row where PHP is plainly worse than JavaScript, and knowing it in advance saves the surprise.
Destructuring and spread
PHP destructures lists and keyed arrays, and spreads them into calls and literals — but the pieces do not line up one for one with JavaScript, and this row shows the one form that does not exist.
const [first, ...rest] = [1, 2, 3, 4]; console.log(first, rest.join(",")); const { name, age } = { name: "Ada", age: 36 }; console.log(name, age); const merged = { ...{ a: 1 }, ...{ b: 2 } }; console.log(JSON.stringify(merged));
<?php [$first, ...$rest] = [1, 2, 3, 4]; // not valid PHP — see the note echo $first, "\n";
There is no rest element in a destructuring assignment: write [$first] = $numbers; and then array_slice($numbers, 1). Everything else is there — [$a, $b] = $pair;, keyed destructuring as ['name' => $name] = $person;, nested patterns, and foreach ($pairs as [$left, $right]). Spread works in array literals ([...$first, ...$second], with string keys allowed since 8.1) and in calls (sum(...$numbers)), and ... in a parameter list is the variadic form.
Finding things in an array
The vocabulary changes completely, and one argument is worth memorising: the third parameter of in_array and array_search is $strict, and without it the comparison is ==.
const names = ["ada", "grace", "alan"]; console.log(names.includes("grace")); console.log(names.indexOf("alan")); console.log(names.find((name) => name.startsWith("a"))); const ages = { ada: 36, grace: 45 }; console.log("ada" in ages);
<?php $names = ["ada", "grace", "alan"]; echo var_export(in_array("grace", $names, true), true), "\n"; echo var_export(array_search("alan", $names, true), true), "\n"; $firstA = array_values(array_filter($names, fn($name) => str_starts_with($name, "a")))[0]; echo $firstA, "\n"; $ages = ['ada' => 36, 'grace' => 45]; echo var_export(array_key_exists('ada', $ages), true), "\n";
Leaving $strict off is how in_array("abc", [0]) used to return true — PHP 8 fixed that particular case, but pass true anyway and mean it. array_search returns the key, or false when nothing matched, so compare with !== false rather than truthiness, since key 0 is falsy. There is no find: filter and take the first, or write the foreach. And note in: JavaScript's in tests keys, and PHP's in_array tests values — the false friend on this row.
Sorting sorts in place
Both languages sort in place and return nothing useful, so both need a copy first — and in PHP the copy is free to write, because assignment already copies.
const numbers = [10, 9, 100]; const sorted = [...numbers].sort((left, right) => left - right); console.log(sorted.join(","), numbers.join(","));
<?php $numbers = [10, 9, 100]; $sorted = $numbers; // arrays copy, so this is a snapshot usort($sorted, fn($left, $right) => $left <=> $right); echo implode(",", $sorted), " ", implode(",", $numbers), "\n";
The comparator returns a negative number, zero or a positive one in both, and PHP's <=> spaceship operator produces exactly that for any two comparable values. The family is large and the names encode the axis: sort discards keys, asort keeps them, ksort sorts by key, usort/uasort/uksort take your comparator. One thing PHP gets right that JavaScript does not: sort([10, 9, 100]) compares numbers as numbers, where JavaScript's default comparator stringifies and gives you 10, 100, 9.
Strings
Functions with an argument order to learn
There are no string methods: every operation is a global function, so a chain of four becomes four nested calls read inside-out.
const title = " Hello, World "; console.log(title.trim()); console.log(title.trim().toUpperCase()); console.log(title.replace("World", "PHP").trim()); console.log(title.includes("World"));
<?php $title = " Hello, World "; echo trim($title), "\n"; echo strtoupper(trim($title)), "\n"; echo trim(str_replace("World", "PHP", $title)), "\n"; echo var_export(str_contains($title, "World"), true), "\n";
The argument order is the famous part. str_replace($search, $replace, $subject) puts the subject last, while strpos($haystack, $needle) puts it first — the inconsistency is historical (the string functions borrowed C's order, the array functions did not) and it is not going away. The modern additions are consistent and worth preferring: str_contains, str_starts_with and str_ends_with (all PHP 8.0) take the haystack first and return real booleans, replacing the old strpos(...) !== false dance.
Splitting and joining
Two names with no obvious mnemonic: explode splits and implode joins.
const line = "alice,bob,carol"; const people = line.split(","); console.log(people.length); console.log(people.join(" | ")); console.log("abc".split("").join("|"));
<?php $line = "alice,bob,carol"; $people = explode(",", $line); echo count($people), "\n"; echo implode(" | ", $people), "\n"; echo implode("|", str_split("abc")), "\n";
explode refuses an empty separator — it throws a ValueError rather than splitting into characters, which is what str_split is for (or mb_str_split when the text is not ASCII). implode accepts its two arguments in either order for historical reasons, but write implode($glue, $pieces). join exists as an alias of implode, which is a small kindness to anyone arriving from JavaScript.
Strings are bytes unless you say otherwise
A PHP string is a byte array. Every function without an mb_ prefix counts and slices bytes, so anything outside ASCII gives an answer JavaScript would never give.
const word = "naïve"; console.log(word.length); console.log(word.toUpperCase()); console.log(word.slice(0, 3));
<?php $word = "naïve"; echo strlen($word), " bytes vs ", mb_strlen($word), " characters\n"; echo strtoupper($word), " vs ", mb_strtoupper($word), "\n"; echo substr($word, 0, 3), " vs ", mb_substr($word, 0, 3), "\n";
JavaScript strings are UTF-16 code units, which has its own emoji-shaped problems, but at least "naïve".length is 5. In PHP strlen says 6, and substr($word, 0, 3) can cut a character in half and produce invalid UTF-8. The rule is simple: for user-facing text, use the mb_* family (mb_strlen, mb_substr, mb_strtoupper) and set mb_internal_encoding('UTF-8'). Byte functions are correct only when you genuinely mean bytes. mb_* lives in the mbstring extension, which is not compiled into every PHP build — extension_loaded('mbstring') is worth checking before you rely on it.
Regular expressions come as strings
PHP has no regex literal. The pattern is an ordinary string that must carry its own delimiters — conventionally slashes — with any flags after the closing one.
const text = "order 42 shipped"; const match = text.match(/order (\d+)/); console.log(match[1]); console.log(text.replace(/\d+/g, "N"));
<?php $text = "order 42 shipped"; preg_match('/order (\d+)/', $text, $match); echo $match[1], "\n"; echo preg_replace('/\d+/', "N", $text), "\n";
So /order (\d+)/i becomes the string '/order (\d+)/i', single-quoted so the backslashes survive. Matches come back through a by-reference third parameter rather than a return value, and the return value is the count (or false on a malformed pattern). Replacement is global by default, the opposite of JavaScript, where g is opt-in. The engine is PCRE, so the syntax itself is the one you know, with a few extras JavaScript lacks such as possessive quantifiers.
Control Flow
Looping over a list and a map
One loop covers every case, because there is one container type. The optional $key => is what entries() and Object.entries() are for in JavaScript.
const colours = ["red", "green"]; for (const colour of colours) console.log(colour); for (const [index, colour] of colours.entries()) console.log(index, colour); const ages = { ada: 36, grace: 45 }; for (const [name, age] of Object.entries(ages)) console.log(name, age);
<?php $colours = ["red", "green"]; foreach ($colours as $colour) echo $colour, "\n"; foreach ($colours as $index => $colour) echo $index, " ", $colour, "\n"; $ages = ['ada' => 36, 'grace' => 45]; foreach ($ages as $name => $age) echo $name, " ", $age, "\n";
Since arrays are values, foreach iterates over a copy and modifying the array inside the loop is safe — the loop sees the array as it was. Writing foreach ($items as &$item) gives you a reference so you can modify each element in place, and it carries a famous trap: $item is still a reference to the last element after the loop, so a second foreach reusing that name corrupts the array. unset($item) after the loop, or avoid the ampersand.
match is the switch you wanted
PHP 8 added match, and it fixes the three things everyone dislikes about switch — in both languages.
function describe(code) { switch (code) { case 200: case 201: return "ok"; case 404: return "missing"; default: return "unknown"; } } console.log(describe(201), describe(404), describe(500));
<?php function describe(int $code): string { return match ($code) { 200, 201 => "ok", 404 => "missing", default => "unknown", }; } echo describe(201), " ", describe(404), " ", describe(500), "\n";
It is an expression, so it returns a value and needs no return per branch; there is no fall-through, so no break and no accidental cascade; and it compares with === rather than ==, so match("1") does not hit the 1 arm. An unmatched value with no default throws UnhandledMatchError instead of silently doing nothing. A condition-shaped form exists too: match(true) { $age >= 18 => ..., default => ... }, which is the idiomatic replacement for an if/else ladder.
Ternaries and the shortcuts
The conditional operator is identical. The two shortcuts beside it are the ones to keep straight, because they answer different questions.
const name = ""; console.log(name || "anonymous"); console.log(name ?? "anonymous"); const items = []; console.log(items.length > 0 ? "has items" : "empty");
<?php $name = ""; echo $name ?: "anonymous", "\n"; echo $name ?? "anonymous", "\n"; $items = []; echo count($items) > 0 ? "has items" : "empty", "\n";
?: — the "Elvis" operator — is PHP's ||: it falls back on anything falsy, so an empty string or 0 triggers it. ?? falls back only on null or a missing key, exactly as in JavaScript, and unlike ?: it does not warn about an undefined index. Reach for ?? by default and ?: only when you genuinely mean "or anything empty". ??= exists too.
Functions & Closures
A closure closes over nothing by default
This is the row where PHP's scoping rule bites hardest. A PHP function body cannot see the variables of the function that created it — you have to list what it may capture, in a use clause.
function makeCounter() { let count = 0; return () => ++count; } const counter = makeCounter(); console.log(counter(), counter(), counter());
<?php function makeCounter(): callable { $count = 0; return function () use (&$count) { return ++$count; }; } $counter = makeCounter(); echo $counter(), " ", $counter(), " ", $counter(), "\n";
By default use ($count) captures the value at the moment the closure is created, so the counter would return 1 forever; use (&$count) captures the variable by reference, which is what a JavaScript closure always does. Arrow functions (PHP 7.4's fn() => ...) skip the ceremony — they capture by value automatically — but they are limited to a single expression, so a multi-statement closure still needs the long form.
Arrow functions, and what they do not carry
The short form looks the same and even captures the surrounding variables the way you expect.
const numbers = [1, 2, 3]; const factor = 10; console.log(numbers.map((number) => number * factor).join(","));
<?php $numbers = [1, 2, 3]; $factor = 10; echo implode(",", array_map(fn($number) => $number * $factor, $numbers)), "\n";
The differences are worth knowing. A PHP arrow function is one expression only — no braces, no statements — and it captures by value, so assigning to a captured variable inside it changes nothing outside. And it does not exist to fix this, because PHP's $this was never re-bound by calling convention; a closure inside a method sees $this automatically. The whole class of JavaScript bugs that bind, self = this and arrow functions were invented to solve simply does not arise.
Named arguments and defaults
The options-object pattern exists in JavaScript because there is no way to skip a positional argument. PHP 8 added named arguments, so the pattern is unnecessary here.
function connect({ host, port = 5432, timeout = 30 }) { console.log(`${host}:${port} timeout=${timeout}`); } connect({ host: "db.example.com" }); connect({ host: "db.example.com", timeout: 5 });
<?php function connect(string $host, int $port = 5432, int $timeout = 30): void { echo "$host:$port timeout=$timeout\n"; } connect("db.example.com"); connect("db.example.com", timeout: 5);
Any parameter can be passed by name, in any order after the positional ones, and the name is part of the public signature — renaming a parameter is a breaking change, which is the one new obligation this brings. Defaults work as you would expect and must follow the required parameters. Variadics use ...$rest in the signature, and ...$array at a call site spreads, including spreading a keyed array into named arguments.
Passing a function around
Functions are not values in PHP the way they are in JavaScript — a named function's name is not a variable — so there are several spellings for "the function called X".
const names = ["ada", "grace"]; console.log(names.map((name) => name.toUpperCase()).join(",")); const shout = (text) => text.toUpperCase() + "!"; const apply = (fn, value) => fn(value); console.log(apply(shout, "hello"));
<?php $names = ["ada", "grace"]; echo implode(",", array_map('strtoupper', $names)), "\n"; $shout = fn(string $text): string => strtoupper($text) . "!"; $apply = fn(callable $function, string $value): string => $function($value); echo $apply($shout, "hello"), "\n";
A plain string names a global function ('strtoupper'), an array names a method ([$object, 'method'] or [Klass::class, 'staticMethod']), and PHP 8.1's first-class callable syntax makes a real closure out of any of them: strtoupper(...), $object->method(...). Prefer that last form — it is checked when it is written, where a string typo fails only when called. The callable type declaration accepts all of them.
Classes & Objects
A class, with the constructor doing the declaring
PHP 8 constructor promotion collapses the declare-then-assign ritual: a parameter marked with a visibility keyword becomes a property, assigned for you.
class Account { #balance = 0; constructor(owner, balance = 0) { this.owner = owner; this.#balance = balance; } deposit(amount) { this.#balance += amount; } toString() { return `${this.owner}: ${this.#balance}`; } } const account = new Account("Ada"); account.deposit(100); console.log(String(account));
<?php class Account { public function __construct( public readonly string $owner, private int $balance = 0, ) {} public function deposit(int $amount): void { $this->balance += $amount; } public function __toString(): string { return "$this->owner: $this->balance"; } } $account = new Account("Ada"); $account->deposit(100); echo $account, "\n";
Three notes for a JavaScript reader. $this-> is the arrow, not a dot, and $this is mandatory — a bare $balance inside a method is a local variable, not the property. readonly (PHP 8.1) makes a property assignable exactly once, from inside the class, which is stronger than #private plus a getter and much shorter. And visibility is real: private is enforced by the runtime, as JavaScript's # fields now are, while the older PHP convention of a leading underscore never was.
Static members and the double colon
Anything reached through the class rather than an instance uses ::, and that operator has no JavaScript counterpart.
class Counter { static created = 0; static LABEL = "counter"; constructor() { Counter.created += 1; } } new Counter(); new Counter(); console.log(Counter.created, Counter.LABEL);
<?php class Counter { public static int $created = 0; public const LABEL = "counter"; public function __construct() { self::$created += 1; } } new Counter(); new Counter(); echo Counter::$created, " ", Counter::LABEL, "\n";
Inside the class, self:: refers to the class the code was written in and static:: to the class actually being called (late static binding — the difference matters the moment you subclass). parent::method() is super.method(). Note that a static property keeps its $ after the colons, while a const does not — a small inconsistency you will mistype at least once.
Interfaces and traits, in place of mixins
JavaScript shares behaviour by copying onto a prototype or by composing objects. PHP has no multiple inheritance either, and offers two named tools instead: an interface for the contract, a trait for the implementation.
const timestamps = { touch() { this.updatedAt = "2026-08-18"; }, }; class Post {} Object.assign(Post.prototype, timestamps); const post = new Post(); post.touch(); console.log(post.updatedAt);
<?php interface Timestamped { public function touch(): void; } trait HasTimestamps { public ?string $updatedAt = null; public function touch(): void { $this->updatedAt = "2026-08-18"; } } class Post implements Timestamped { use HasTimestamps; } $post = new Post(); $post->touch(); echo $post->updatedAt, "\n";
A trait is compiler-level copy-and-paste — its methods and properties are inserted into the using class at compile time, so there is no extra object in the chain and no runtime lookup. Conflicts between two traits are an error you must resolve explicitly with insteadof, which is the part Object.assign silently gets wrong. Interfaces may declare constants and, unlike traits, take part in type declarations — so type your parameters against the interface and get the behaviour from the trait.
There is no prototype to reach into
Every JavaScript object is open: add a property to an instance, add a method to a prototype, monkey-patch a library at run time. PHP classes are closed, and PHP 8.2 began deprecating the one loophole.
class Duck { quack() { return "Quack"; } } const duck = new Duck(); Duck.prototype.honk = () => "Honk"; // a new method on EVERY duck console.log(duck.quack(), duck.honk()); duck.extra = "added at run time"; // a new property on this instance console.log(duck.extra);
<?php class Duck { public function quack(): string { return "Quack"; } } $duck = new Duck(); $honk = fn(): string => "Honk"; // a closure — Duck gains nothing echo $duck->quack(), " ", $honk(), "\n"; $loose = new stdClass(); // the one type that stays open $loose->extra = "added at run time"; echo $loose->extra, "\n";
Both columns print the same two lines and got there by opposite means: JavaScript added a real method to every Duck ever created, while PHP made a closure that Duck knows nothing about, and the property landed on a stdClass rather than on the duck. Assigning to an undeclared property on a normal class is deprecated in 8.2 and an Error in 9; add #[\AllowDynamicProperties] above the class if you truly need the old behaviour, or use stdClass, which is the anonymous-object type that json_decode returns and where dynamic properties remain normal. The magic methods __get, __set and __call are the supported way to fake a dynamic surface — that is how Laravel's Eloquent models work. Nothing patches a class from outside; dependency injection is the escape hatch instead.
Enums & match
Real enums, not frozen objects
The frozen-object idiom is what you reach for in JavaScript because there is nothing better. PHP 8.1 enums are a real type: a value of type Status can only ever be one of the listed cases.
const Status = Object.freeze({ Active: "active", Retired: "retired" }); function label(status) { return status === Status.Active ? "still here" : "gone"; } console.log(label(Status.Active), Status.Retired);
<?php enum Status: string { case Active = 'active'; case Retired = 'retired'; public function label(): string { return match ($this) { Status::Active => "still here", Status::Retired => "gone", }; } } echo Status::Active->label(), " ", Status::Retired->value, "\n";
That means a parameter typed Status cannot receive a typo, and match ($this) over every case needs no default. A backed enum (the : string above) carries a scalar for the database or JSON, with Status::from('active') to convert in and ->value to convert out; tryFrom returns null instead of throwing. Enums may hold methods, implement interfaces and have constants — but not properties, since each case is a singleton.
Errors & Exceptions
try / catch / finally
The structure is identical, with one addition: PHP's catch is typed, and a clause only fires for exceptions of that class.
try { JSON.parse("{ not json"); } catch (error) { console.log(error.constructor.name + ":", error.message); } finally { console.log("always runs"); }
<?php try { json_decode("{ not json", flags: JSON_THROW_ON_ERROR); } catch (JsonException $error) { echo get_class($error), ": ", $error->getMessage(), "\n"; } finally { echo "always runs\n"; }
So catch (JsonException $error) catches only that, and several catch blocks may follow one try — the JavaScript idiom of one catch with an if (error instanceof ...) ladder is unnecessary. Catch \Throwable for genuinely everything, or \Exception for the ordinary half. Note the named argument flags:, and note that json_decode without JSON_THROW_ON_ERROR returns null on bad input rather than throwing, which is the older PHP style you will meet everywhere.
Error and Exception are siblings
JavaScript has one root, Error. PHP has two branches that do not inherit from each other: Exception for things your code is expected to handle, and Error for the language's own failures such as TypeError and DivisionByZeroError.
class InsufficientFunds extends Error { constructor(shortfall) { super(`short by ${shortfall}`); this.shortfall = shortfall; } } try { throw new InsufficientFunds(25); } catch (error) { console.log(error.message, error.shortfall); }
<?php class InsufficientFunds extends RuntimeException { public function __construct(public readonly int $shortfall) { parent::__construct("short by $shortfall"); } } try { throw new InsufficientFunds(25); } catch (InsufficientFunds $error) { echo $error->getMessage(), " ", $error->shortfall, "\n"; }
Both implement Throwable, which is what a truly catch-everything block names. Extend RuntimeException or LogicException rather than Exception directly — the SPL hierarchy is shallow and conventional, and the split (a bug in the program versus a bad input at run time) is a useful one. The message is read with getMessage(), not a property, and getPrevious() carries the chained cause, the equivalent of the cause option.
Warnings: the failure that does not stop anything
PHP has a second, older channel for problems: a diagnostic is emitted, execution continues, and in a badly configured environment it is printed into the middle of your HTML.
const numbers = [1, 2, 3]; console.log(numbers[10]); // undefined, no complaint console.log(numbers[10] + 1); // NaN, still no complaint
<?php $numbers = [1, 2, 3]; echo var_export(@$numbers[10], true), "\n"; // @ silences the warning echo var_export(@$numbers[10] + 1, true), "\n"; // null becomes 0, so this is 1
Reading a missing array key is a warning (it was a notice before PHP 8), and the value you get is null. JavaScript is quieter still — undefined with no message at all — so the shape of the bug is familiar even if the noise is not. The second line shows where the two diverge dangerously: adding one to the missing value gives NaN in JavaScript, which poisons every later calculation and is at least visible, but 1 in PHP, because null becomes 0 and the wrong answer looks exactly like a right one. Two habits: use ?? to read anything that might be absent, and configure the application to turn warnings into exceptions with a set_error_handler that throws ErrorException, which most frameworks do for you. The @ operator above suppresses diagnostics for one expression and is almost always the wrong tool.
Async, or the Lack of It
The call just returns
There is no promise to unwrap, no async to declare, and nothing to await. A function that does I/O blocks until it has an answer and then returns it.
(async () => { const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const fetchTotal = async () => { await wait(1); return 42; }; const total = await fetchTotal(); console.log("total", total); })();
<?php function fetchTotal(): int { usleep(1000); // a blocking wait, and that is fine return 42; } $total = fetchTotal(); echo "total $total\n";
This removes the entire "function colouring" problem: any function may call any other, and a library that does I/O needs no async variant. The whole shape of a PHP codebase follows from it — no callbacks, no .then chains, no unhandled rejections, and a stack trace that reaches all the way back to the entry point. What you lose is the ability to do two slow things at once inside one request, which the next two rows are about.
What Promise.all becomes
The direct translation of Promise.all is a loop, and it is genuinely sequential: three requests take three round trips.
(async () => { const fetchOne = async (id) => ({ id, ok: true }); const results = await Promise.all([1, 2, 3].map(fetchOne)); console.log(results.map((result) => result.id).join(",")); })();
<?php function fetchOne(int $id): array { return ['id' => $id, 'ok' => true]; } $results = array_map('fetchOne', [1, 2, 3]); // one after another echo implode(",", array_column($results, 'id')), "\n";
When that is too slow there are three real answers, in order of how often they are the right one. curl_multi_exec runs many HTTP requests in parallel inside one process and is what Guzzle's concurrency uses. A queue (Laravel Horizon, Symfony Messenger) pushes the work to separate worker processes and is the standard answer for anything slow. And an event-loop runtime — ReactPHP or Amp, both built on Fibers — gives you the Node model at the cost of running your app differently. Most PHP applications reach for the queue.
Fibers: suspend without colouring the function
PHP 8.1 added Fibers: a block of code that can suspend itself and be resumed later, which is the machinery an event loop needs.
(async () => { async function counter() { for (const step of [1, 2, 3]) { await new Promise((resolve) => setImmediate(resolve)); console.log("step", step); } } await counter(); })();
<?php $fiber = new Fiber(function (): void { foreach ([1, 2, 3] as $step) { Fiber::suspend($step); } }); $step = $fiber->start(); while (!$fiber->isTerminated()) { echo "step $step\n"; $step = $fiber->resume(); }
The crucial difference from async/await is that the suspension is invisible to the caller — an ordinary function deep inside a fiber may suspend, so a library needs no async version of itself. That is why Fibers were added: to let ReactPHP and Amp drive ordinary-looking code. You will almost certainly never write new Fiber yourself; it is plumbing for frameworks, and a normal PHP application never suspends at all.
Generators, which do exist
Generators are one place the two languages agree completely: a function containing yield returns a lazy iterator, and nothing in the body runs until it is consumed.
function* countdown(start) { while (start > 0) yield start--; } console.log([...countdown(3)].join(","));
<?php function countdown(int $start): Generator { while ($start > 0) { yield $start--; } } echo implode(",", iterator_to_array(countdown(3))), "\n";
The syntax has no * — the presence of yield is what makes it a generator, as in Python. yield $key => $value produces keys, yield from delegates to another generator, and a generator can receive values back through $generator->send(), exactly like JavaScript's. Spreading needs iterator_to_array rather than [...], and generators are the standard way to stream a large database result or a big file without holding it all in memory.
Templating & the Web
The file is a template that happens to contain code
This is where the language came from, and the closest thing you have met is JSX. Everything outside <?php ?> is output verbatim, so a PHP file is HTML with holes rather than code that builds a string.
const items = ["ada", "grace"]; const html = `<ul> ${items.map((item) => ` <li>${item}</li>`).join("\n")} </ul>`; console.log(html);
<?php $items = ["ada", "grace"]; ?> <ul> <?php foreach ($items as $item): ?> <li><?= htmlspecialchars($item) ?></li> <?php endforeach; ?> </ul>
<?= $value ?> is shorthand for <?php echo $value; ?>. The alternative syntax — foreach (...):endforeach; — exists precisely for templates, because a closing brace lost among HTML is unreadable. 🚨 Nothing is escaped for you. There is no JSX-style automatic escaping and no textContent: htmlspecialchars() on every interpolated value is the difference between a template and a cross-site-scripting hole. Template engines (Blade, Twig) escape by default, which is much of why they exist.
Returning JSON
There is no response object to call a method on. You set headers with a function and write the body by printing it.
const payload = { ok: true, items: ["ada"] }; // Express: response.json(payload) console.log(JSON.stringify(payload));
<?php $payload = ['ok' => true, 'items' => ['ada']]; // In a request: header('Content-Type: application/json'); echo json_encode($payload), "\n";
header() must be called before any output — including a stray blank line after a ?> in an included file, which is the classic "headers already sent" error. json_encode returns false on failure unless you pass JSON_THROW_ON_ERROR, and JSON_PRETTY_PRINT and JSON_UNESCAPED_SLASHES are the flags you will want. Remember the list-versus-map rule: an array with non-sequential keys encodes as a JSON object, so array_values() before encoding anything that must stay an array.
Files, Namespaces & Composer
require runs a file; it does not export anything
PHP's require predates modules entirely: it executes another file in the current scope. Nothing is exported and nothing is imported — the file simply runs, and whatever functions and classes it declares now exist globally.
// helpers.js: module.exports = { slugify }; // main.js: // const { slugify } = require("./helpers"); const slugify = (text) => text.toLowerCase().replaceAll(" ", "-"); console.log(slugify("Hello World"));
<?php // helpers.php: <?php function slugify(string $text): string { ... } // main.php: require_once __DIR__ . '/helpers.php'; function slugify(string $text): string { return str_replace(' ', '-', strtolower($text)); } echo slugify("Hello World"), "\n";
So there is no module.exports, no named exports, and no per-module scope for functions or classes; variables at the top level of the required file land in the requiring scope, which is why includes usually declare only functions and classes. require fails hard when the file is missing, include only warns, and the _once variants are what you want for definitions. In practice modern code never writes these at all: Composer's autoloader maps a class name to a file and requires it the first time it is used.
Namespaces are names, not files
A namespace is declared inside the file, not derived from its path, and use imports a name — it loads nothing and runs nothing.
// import { Client } from "@acme/http"; // import fs from "node:fs"; // import { Client as HttpClient } from "@acme/http"; class Client { send() { return "sent"; } } console.log(new Client().send()); console.log(Client.name);
<?php namespace App\Http; use InvalidArgumentException as BadArgument; class Client { public function send(): string { return "sent"; } } echo (new Client())->send(), "\n"; echo BadArgument::class, "\n";
That is the opposite of an ES import: use App\Http\Client; only says "when I write Client, I mean that one", and the file is found later by the autoloader. The separator is a backslash, which is why example code is full of them and why they must be escaped in double-quoted strings. A leading backslash means the global namespace — \strlen(), \Throwable — which is why library code is dotted with them. PSR-4 is the convention that ties App\Http\Client to src/Http/Client.php.
Composer against npm
The mechanics rhyme — a manifest, a lockfile, a directory of dependencies — and the culture around them does not.
// npm install express // npm ci → node_modules/, thousands of packages // package.json + package-lock.json // import express from "express"; console.log("node_modules is the deepest directory on the disk");
<?php // composer require guzzlehttp/guzzle // composer install → vendor/, tens of packages // composer.json + composer.lock // require __DIR__ . '/vendor/autoload.php'; echo "vendor/ plus one autoloader require\n";
The big practical difference is one line of ceremony: PHP has no module loader, so every entry point starts with require __DIR__ . '/vendor/autoload.php';, after which every installed class is available by name with no further imports. Trees are far smaller — a Laravel install pulls tens of packages where a React app pulls thousands — because PHP's standard library covers strings, arrays, dates, hashing, HTTP and databases without help. composer.lock is committed, composer install honours it, and composer update is the one that changes it.
Standard Library Highlights
JSON, and what decode gives you back
The second argument is the one to remember: without it, json_decode hands back stdClass objects rather than arrays.
const text = '{"name":"Ada","tags":["x","y"]}'; const parsed = JSON.parse(text); console.log(parsed.name, parsed.tags.length); console.log(JSON.stringify(parsed));
<?php $text = '{"name":"Ada","tags":["x","y"]}'; $parsed = json_decode($text, associative: true); echo $parsed['name'], " ", count($parsed['tags']), "\n"; echo json_encode($parsed), "\n";
So $parsed->name works by default and $parsed['name'] works with associative: true — and mixing the two up is the most common PHP JSON bug. Nested JSON objects become nested arrays either way. Pass JSON_THROW_ON_ERROR in the flags so malformed input throws a JsonException instead of quietly returning null, which is indistinguishable from a literal null in the document.
Dates, without the historical embarrassment
PHP's date handling is one of the places it is plainly ahead: a proper immutable type, a real interval type, timezone support that works, and no need for a library.
const when = new Date("2026-08-18T12:00:00Z"); console.log(when.toISOString().slice(0, 10)); const later = new Date(when); later.setDate(later.getDate() + 30); console.log(later.toISOString().slice(0, 10));
<?php $when = new DateTimeImmutable("2026-08-18T12:00:00Z"); echo $when->format("Y-m-d"), "\n"; $later = $when->add(new DateInterval("P30D")); echo $later->format("Y-m-d"), "\n";
Always reach for DateTimeImmutable rather than the mutable DateTime — the mutable one has exactly the aliasing bug JavaScript Date has, where handing it to a function lets that function change your value. format() takes the single-letter codes (Y-m-d H:i:s), diff() returns a DateInterval, and modify("+1 month") accepts the same relative English that strtotime does. This is where Temporal is trying to take JavaScript.
Reading and writing files
Two functions cover most file work, and there is no callback, no promise and no stream to open first.
const { writeFileSync, readFileSync, existsSync } = require("node:fs"); const path = "/tmp/javascript-php-demo.txt"; writeFileSync(path, "first line\nsecond line\n"); console.log(readFileSync(path, "utf8").trim().split("\n").length); console.log(existsSync(path));
<?php $path = "/tmp/javascript-php-demo-target.txt"; file_put_contents($path, "first line\nsecond line\n"); echo count(file($path, FILE_IGNORE_NEW_LINES)), "\n"; echo var_export(file_exists($path), true), "\n";
file_get_contents and file_put_contents are the read-it-all and write-it-all pair, and file() reads straight into an array of lines. Both accept URLs as well as paths when allow_url_fopen is on, which is how a great deal of quick-and-dirty PHP fetches HTTP — convenient, and a security consideration when the path comes from a user. For anything large, fopen/fgets/fclose give you the streaming form, and SplFileObject wraps it as an iterator.
Passwords and hashing, in the standard library
The single most useful thing PHP ships that Node does not: a password API that is correct by default.
const { createHash } = require("node:crypto"); // Passwords need bcrypt or argon2 from npm — there is nothing built in. console.log(createHash("sha256").update("hello").digest("hex").slice(0, 16)); console.log("password hashing: pick a package and hope");
<?php echo substr(hash('sha256', 'hello'), 0, 16), "\n"; $stored = password_hash('correct horse', PASSWORD_DEFAULT); echo var_export(password_verify('correct horse', $stored), true), "\n";
password_hash picks a modern algorithm (bcrypt today, argon2 available), generates the salt, and encodes the algorithm and cost into the stored string; password_verify reads them back and compares in constant time. There is no salt to manage and no library to choose, which removes the most common way an application gets authentication wrong. PASSWORD_DEFAULT deliberately changes between PHP versions — that is what password_needs_rehash is for.