PONYλM2Modula-2

TypeScript.CodeCompared.To/PHP

An interactive executable cheatsheet comparing TypeScript and PHP

TypeScript 6.0.3 PHP 8.3.12 (Wandbox), 8.3.11 (Judge0) or 8.5.8 (in-browser)
Output & Running
Hello, World
A PHP file starts in HTML mode and only becomes code after <?php — the tell that the language began as a template system. And echo writes exactly what you give it, with no newline added.
console.log("Hello, World!");
<?php echo "Hello, World!\n";
A file that is nothing but PHP omits the closing ?> by convention, because whitespace after it is sent to the browser and breaks a later header() call. There is no build step: the engine parses and runs the file each request, so where you would run tsc and ship JavaScript, you ship the source itself.
Interpolation
PHP interpolates inside ordinary double-quoted strings — no backtick, no dollar-brace. Single quotes never interpolate, which is how you say "leave this alone".
const name = "Ada"; const age = 36; console.log(`${name} is ${age}`);
<?php $name = "Ada"; $age = 36; echo "$name is $age\n";
A bare $name works; anything more complex needs braces, as in "{$user['name']}" or "{$order->total}". Concatenation is . rather than +, and using + on two strings is a TypeError in PHP 8 — a small mercy after JavaScript's "1" + 1. The trailing \n only works in double quotes, since single-quoted strings do not process escapes either.
Inspecting a value
Three tools rather than one: print_r for a readable dump, var_dump for a dump that includes types and lengths, and var_export for output that is valid PHP source.
const order = { id: 1, tags: ["new"] }; console.log(JSON.stringify(order)); console.log(typeof order, Array.isArray(order.tags));
<?php $order = ['id' => 1, 'tags' => ['new']]; echo json_encode($order), "\n"; echo gettype($order), " ", var_export(array_is_list($order['tags']), true), "\n";
var_export($value, true) returns the text instead of printing it, which makes it the right choice for putting a value inside a sentence — and the reason to prefer it over var_dump when the output sits beside another language's. gettype is typeof, and array_is_list (PHP 8.1) is Array.isArray's nearest relative, because a PHP array is a list only when its keys happen to be 0, 1, 2 — the arrays section returns to this.
Types That Survive to Run Time
The same wrong value, twice
This is the page in one screen, so it comes first. The same wrong value — a string that should have been a number — reaches the same function in both languages.
function double(value: number): number { return value * 2; } const fromJson = JSON.parse('"21 items"'); // a string, typed as any console.log(double(fromJson)); // compiles; prints NaN
<?php declare(strict_types=1); function double(int $value): int { return $value * 2; } $fromJson = json_decode('"21 items"'); // a string try { echo double($fromJson), "\n"; } catch (TypeError $error) { echo "TypeError: ", preg_replace('/, called in.*/s', '', $error->getMessage()), "\n"; }
TypeScript checked double's signature when you compiled it and then erased it, so at run time there is nothing left to check: JSON.parse returns any, the call compiles, and you get NaN propagating into the rest of the program. PHP checks the declared parameter type on every call, and with declare(strict_types=1) it refuses even a string that looks like a number. The bug surfaces at the boundary, named, with a stack trace — rather than three functions later as a NaN.
strict_types is the flag that matters
PHP has two type modes, chosen per file, and the default is the lenient one. declare(strict_types=1) must be the very first statement in the file.
// tsconfig.json: { "strict": true } // Affects only compilation. There is no runtime setting. function addOne(value: number): number { return value + 1; } console.log(addOne(41));
<?php // Without declare(strict_types=1) PHP COERCES: "41" would become 41. declare(strict_types=1); function addOne(int $value): int { return $value + 1; } echo addOne(41), "\n"; try { echo addOne("41"), "\n"; } catch (TypeError $error) { echo "refused a numeric string\n"; }
In coercive mode — the default — "41" is silently converted to 41 and a float is truncated to an int, which is the behaviour that gave PHP's type declarations their weak reputation. In strict mode only an exact type is accepted, with one exception: an int is still accepted where a float is declared. Put the declaration at the top of every file you write; it is the closest thing PHP has to "strict": true, and unlike tsconfig it is per-file rather than per-project.
Return types are checked too
A return type is a promise in both languages. Only one of them is kept.
function loadName(): string { const raw: any = 42; return raw; // compiles: any defeats the return type } console.log(typeof loadName());
<?php declare(strict_types=1); function loadName(): string { $raw = 42; return $raw; // TypeError at the return, not at the caller } try { echo gettype(loadName()), "\n"; } catch (TypeError $error) { echo "TypeError on return\n"; }
The TypeScript column prints number from a function whose signature says string, because a single any anywhere in the chain disables the check — which is exactly how a wrong type from an API response spreads through a codebase. PHP checks the value on its way out of the function, so the error names the function that lied rather than the caller that trusted it. The available return types include void, never, static and mixed, all of which a TypeScript reader will recognise.
There is no `as`
A type assertion is a promise nobody checks: as User changes what the compiler believes and emits no code at all.
type User = { id: number; name: string }; const payload = JSON.parse('{"id":"one"}') as User; console.log(payload.id, typeof payload.id); // "one", string
<?php declare(strict_types=1); final class User { public function __construct( public readonly int $id, public readonly string $name, ) {} } $payload = json_decode('{"id":"one","name":"Ada"}', associative: true); try { $user = new User($payload['id'], $payload['name']); } catch (TypeError $error) { echo "refused at construction\n"; }
PHP has no equivalent, and it needs none — the way to say "this data is a User" is to construct one, and the constructor's parameter types do the checking. That is the shape to adopt: parse into an array at the edge, build typed objects immediately, and let a TypeError reject bad input at the boundary. The TypeScript ecosystem reaches the same place with Zod or Valibot, which exist precisely because as does not check; PHP's version is the language.
What replaces the build step
The workflow around the code changes more than the code does, and the two halves of what tsc does end up in different places.
// tsc --noEmit → type errors, before anything runs // tsc / esbuild / vite → emits JavaScript, which is what ships // The types are gone from the artifact entirely. console.log("compile, then ship the output");
<?php // php -l file.php → syntax check only, no type checking // vendor/bin/phpstan → the type checker, run separately (see PHPStan section) // The source IS the artifact; there is nothing to emit. echo "ship the source\n";
Type checking moves to PHPStan or Psalm, run in CI rather than as a build step. Type enforcement moves into the engine, at run time. And emitting is gone: the .php file you write is the file the server executes, so there is no source map, no build cache and no "did I forget to rebuild". Opcache keeps compiled bytecode in memory between requests, which is a deployment concern rather than a development one.
Variables & Declarations
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 nothing is declared, a typo creates a new variable; PHP 8 at least warns 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 list them in a use clause. File-level const declares a genuine constant and takes no dollar sign.
There is no inference to lean on
Local variables cannot carry a type declaration at all — only parameters, properties and return types can. So a local's type is whatever the analyser can work out, and a docblock is how you tell it.
const numbers = [1, 2, 3]; // number[] const first = numbers[0]; // number const doubled = numbers.map((value) => value * 2); // number[] console.log(doubled.join(","));
<?php declare(strict_types=1); /** @var list<int> $numbers */ $numbers = [1, 2, 3]; $first = $numbers[0]; $doubled = array_map(fn(int $value): int => $value * 2, $numbers); echo implode(",", $doubled), "\n";
That @var comment is invisible to the engine and load-bearing for PHPStan and Psalm. The consequence for a TypeScript reader is that the type information lives at function boundaries rather than flowing through every expression: annotate the signature well and the analyser infers the rest, exactly as tsc does — but a wrong @var is a lie nothing at run time will catch.
int and float are different types
TypeScript has one number and a separate bigint. PHP has a real int and a real float, and they are distinct types in a signature.
console.log(7 / 2); console.log(Math.trunc(7 / 2)); console.log(0.1 + 0.2); console.log(typeof 7, typeof 7.5);
<?php echo 7 / 2, "\n"; echo intdiv(7, 2), "\n"; echo var_export(0.1 + 0.2, true), "\n"; echo gettype(7), " ", gettype(7.5), "\n";
So function half(int $value): float is a meaningful declaration, and in strict mode passing 7.5 where int is declared throws. 7 / 2 still gives 3.5, because division promotes to float; intdiv is the integer division and % is integer-only. Past PHP_INT_MAX an integer silently becomes a float and starts losing precision — the same class of quiet failure as JavaScript above Number.MAX_SAFE_INTEGER. Note the printing: plain echo rounds to 14 digits and hides the classic 0.1 + 0.2, while var_export shows it.
Unions & Enums
Union types, enforced at run time
Union types arrived in PHP 8 and are spelled identically. The difference is that PHP's are checked on every call.
function describe(value: number | string): string { return typeof value === "number" ? `number ${value}` : `string ${value}`; } console.log(describe(42), describe("hi"));
<?php declare(strict_types=1); function describe(int|string $value): string { return is_int($value) ? "number $value" : "string $value"; } echo describe(42), " ", describe("hi"), "\n";
Narrowing works the same way and by the same means: is_int, is_string and instanceof are typeof and instanceof, and PHPStan understands them exactly as tsc understands a type guard. What PHP unions cannot contain is literal types — there is no "active" | "retired", which is the TypeScript idiom this reader uses most. That role belongs to an enum, two rows down. Intersection types (Countable&Iterator) exist too, for interfaces only.
Enums are objects, not a union of literals
The union-of-string-literals idiom has no PHP equivalent. PHP 8.1 enums are a real type whose cases are singleton objects, so they are closer to a class than to a set of strings.
type Status = "active" | "retired"; function label(status: Status): string { switch (status) { case "active": return "still here"; case "retired": return "gone"; } } console.log(label("active")); const parsed: Status = "retired"; // any matching literal; nothing checks it at run time console.log(parsed);
<?php declare(strict_types=1); 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(), "\n"; echo Status::from('retired')->value, "\n";
That buys things the TypeScript version cannot have: methods, interface implementations, constants, and a value that cannot be a typo because it cannot be constructed. A backed enum (the : string) carries a scalar for the database or JSON, with from to convert in — it throws on an unknown value, and tryFrom returns null instead. It costs the ergonomics of a bare string: every use is Status::Active, and serialising needs ->value.
Exhaustiveness, and who enforces it
The discriminated union — a tag plus a payload, with a never assignment proving every case is handled — is the TypeScript pattern with no direct PHP counterpart.
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number }; function area(shape: Shape): number { switch (shape.kind) { case "circle": return 3.14 * shape.radius ** 2; case "square": return shape.side ** 2; default: { const unreachable: never = shape; // the exhaustiveness check return unreachable; } } } console.log(area({ kind: "circle", radius: 1 }));
<?php declare(strict_types=1); abstract class Shape {} final class Circle extends Shape { public function __construct(public readonly float $radius) {} } final class Square extends Shape { public function __construct(public readonly float $side) {} } function area(Shape $shape): float { return match (true) { $shape instanceof Circle => 3.14 * $shape->radius ** 2, $shape instanceof Square => $shape->side ** 2, }; } echo area(new Circle(1)), "\n";
The closest shape is an abstract base class with final subclasses, matched with instanceof. match throws UnhandledMatchError when nothing matches, so an unhandled case fails loudly at run time rather than being caught by the compiler — a weaker guarantee than never, and a real one. PHP has no sealed classes, so nothing stops a third subclass appearing; PHPStan can be told the class list with a @phpstan-sealed-style annotation, which is again the analyser doing the compiler's job.
match against switch
PHP 8's match fixes the three complaints everyone has about switch — in both languages.
function describe(code: number): string { switch (code) { case 200: case 201: return "ok"; case 404: return "missing"; default: return "unknown"; } } console.log(describe(201), describe(404), describe(500));
<?php declare(strict_types=1); 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 it compares with ===, so match("1") does not hit the 1 arm. An unmatched value with no default throws UnhandledMatchError rather than silently doing nothing — which is the behaviour the never trick simulates in TypeScript. match(true) with boolean arms is the idiomatic replacement for an if/else ladder.
null, undefined and ?->
One empty value, not two
TypeScript has null and undefined, and strictNullChecks to keep them out of your types. PHP has only null.
const config: { host?: string | null } = { host: null }; console.log(config.host ?? "localhost"); console.log(config.missing ?? "default"); console.log(config.host === undefined, config.host === null);
<?php declare(strict_types=1); $config = ['host' => null]; echo $config['host'] ?? "localhost", "\n"; echo $config['missing'] ?? "default", "\n"; echo var_export(isset($config['host']), true), " ", var_export(array_key_exists('host', $config), true), "\n";
That collapses a distinction you have been maintaining: "absent" and "present but empty" are the same value here, and the two questions are asked with different functions rather than different constants. isset() means "set and not null", array_key_exists() means "the key is there even if its value is null", and empty() is the trap — it is true for 0, "0", "" and [] as well. ?? behaves exactly as it does in TypeScript, including not falling back on 0.
Nullable types are part of the signature
A leading ? makes a declared type nullable, and it is enforced: returning null from a plain string return type is a TypeError.
function find(names: string[], target: string): string | null { return names.find((name) => name === target) ?? null; } const found = find(["ada"], "bob"); console.log(found?.length ?? -1);
<?php declare(strict_types=1); function find(array $names, string $target): ?string { foreach ($names as $name) { if ($name === $target) return $name; } return null; } $found = find(["ada"], "bob"); echo $found !== null ? strlen($found) : -1, "\n";
?string is shorthand for string|null and the two are interchangeable. This is the one place PHP's runtime types give you something close to strictNullChecks — a function that says string genuinely cannot hand back null, checked on every return rather than at compile time. Note that array as a parameter type says nothing about what is in the array; that gap is what the generics and PHPStan sections are about.
?-> is the same operator
The nullsafe operator arrived in PHP 8 and reads the same way: if the left side is null, the whole chain short-circuits to null instead of erroring.
class Address { constructor(public city: string | null) {} } class Customer { constructor(public address: Address | null) {} } class Order { constructor(public customer: Customer | null) {} } const order = new Order(new Customer(new Address(null))); console.log(order.customer?.address?.city ?? "unknown");
<?php declare(strict_types=1); final class Address { public function __construct(public readonly ?string $city) {} } final class Customer { public function __construct(public readonly ?Address $address) {} } final class Order { public function __construct(public readonly ?Customer $customer) {} } $order = new Order(new Customer(new Address(null))); echo $order->customer?->address?->city ?? "unknown", "\n";
The arrow is -> rather than a dot, so it is ?->. It works for method calls as well as properties, and — unlike TypeScript's ?. — it may not be used for array access or as ?.(); array reads use ?? null instead. Combining it with ?? as above is the idiom, and it is one of the few places where PHP code and TypeScript code look identical.
readonly, with a different scope
Both languages have a readonly modifier on properties, and once again the difference is whether anything enforces it after compilation.
class Point { constructor(readonly x: number, readonly y: number) {} } const point = new Point(1, 2); // point.x = 99; // a compile error — and erased from the output (point as { x: number }).x = 99; // so at run time, nothing stops this console.log(point.x);
<?php declare(strict_types=1); final class Point { public function __construct( public readonly int $x, public readonly int $y, ) {} } $point = new Point(1, 2); try { $point->x = 99; // Error: Cannot modify readonly property } catch (Error $error) { } echo $point->x, "\n";
TypeScript's is erased: the assignment is a compile error, the emitted JavaScript performs it happily — the column prints 99 — and one cast is all it takes to get there, which is why Object.freeze exists as the run-time version. PHP's readonly (8.1) throws an Error on any write after initialisation, from inside the class as well as outside. It is per-property rather than per-object, applies only to typed properties, and PHP 8.2 added readonly class to mark them all at once. There is no as const and no readonly array type.
The One Array Type
Arrays, objects and tuples are one type
Three TypeScript types collapse into one. A PHP array is an ordered map: keys are integers or strings, insertion order is preserved, and a list is simply the case where the keys are 0, 1, 2.
const numbers: number[] = [10, 20, 30]; const person: { name: string; age: number } = { name: "Ada", age: 36 }; const pair: [string, number] = ["ada", 36]; console.log(numbers[1], person.name, pair[0]);
<?php $numbers = [10, 20, 30]; $person = ['name' => 'Ada', 'age' => 36]; $pair = ['ada', 36]; echo $numbers[1], " ", $person['name'], " ", $pair[0], "\n";
So the type declaration array tells you almost nothing — not the element type, not whether it is a list, not what keys it has. That is the single biggest gap for a TypeScript reader, and the whole reason PHPStan's array<string, User> and list<int> docblocks exist. One consequence to watch immediately: json_encode emits […] for a list and {…} for anything else, so removing one element from the middle of a list changes the shape of your API response.
An array is a value; an object is a reference
Every array and object in TypeScript is a reference. A PHP 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. The copy is lazy underneath — PHP copies on write — but the semantics are a full copy, which means the defensive [...items] spread you write in TypeScript is unnecessary here. Objects go the other way and behave exactly as in TypeScript: $b = $a gives two names for one instance, and clone makes a shallow copy.
map and filter are functions, not methods
The same three operations exist as free functions, so there is no chaining and the intermediate steps get names.
const numbers = [1, 2, 3, 4, 5, 6]; const total = numbers .filter((number) => number % 2 === 0) .map((number) => number * number) .reduce((running, number) => running + number, 0); console.log(total);
<?php declare(strict_types=1); $numbers = [1, 2, 3, 4, 5, 6]; $evens = array_filter($numbers, fn(int $number): bool => $number % 2 === 0); $squares = array_map(fn(int $number): int => $number * $number, $evens); $total = array_reduce($squares, fn(int $running, int $number): int => $running + $number, 0); echo $total, "\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 keys 1, 3, 5 and turn your next json_encode into an object — wrap it in array_values(). This is the row where PHP is plainly worse than TypeScript, and knowing it in advance saves the surprise.
Destructuring and spread
Destructuring exists for both list and keyed arrays, and spread works in array literals and calls — with one hole.
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] = [1, 2, 3, 4]; $rest = array_slice([1, 2, 3, 4], 1); echo $first, " ", implode(",", $rest), "\n"; ['name' => $name, 'age' => $age] = ['name' => 'Ada', 'age' => 36]; echo $name, " ", $age, "\n"; $merged = [...['a' => 1], ...['b' => 2]]; echo json_encode($merged), "\n";
There is no rest element in a destructuring assignment: [$first, ...$rest] is a parse error, so the tail comes from array_slice. Everything else carries over: nested patterns, foreach ($pairs as [$left, $right]), spread with string keys (PHP 8.1+), and ... in a parameter list for variadics. Spreading into a function call spreads a keyed array into named arguments, which has no TypeScript equivalent at all.
Finding things
The names change 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 }; console.log("ada" in ages);
<?php declare(strict_types=1); $names = ["ada", "grace", "alan"]; echo var_export(in_array("grace", $names, true), true), "\n"; echo var_export(array_search("alan", $names, true), true), "\n"; $matches = array_values(array_filter($names, fn(string $name): bool => str_starts_with($name, "a"))); echo $matches[0], "\n"; $ages = ['ada' => 36]; echo var_export(array_key_exists('ada', $ages), true), "\n";
Pass true and mean it. array_search returns the key, or false when nothing matched, so compare with !== false rather than truthiness — key 0 is falsy. There is no find: filter and take the first. And note the false friend on this row — TypeScript's in tests keys while PHP's in_array tests values; the key test is array_key_exists or isset.
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, and the inconsistency is historical and permanent. The modern additions are consistent and worth preferring: str_contains, str_starts_with and str_ends_with (PHP 8.0) take the haystack first and return real booleans, replacing the old strpos(...) !== false dance.
Template literal types have no counterpart
This is the part of TypeScript that has no analogue anywhere in PHP, and it is worth naming so you stop looking for it.
type Route = `/api/${string}`; const route: Route = "/api/users"; console.log(route); type Method = "GET" | "POST"; type Endpoint = `${Method} ${Route}`; const endpoint: Endpoint = "GET /api/users"; console.log(endpoint);
<?php declare(strict_types=1); // PHP has no literal types and no type-level string manipulation. // The runtime check is the only check: function requireRoute(string $route): string { if (!str_starts_with($route, '/api/')) { throw new InvalidArgumentException("not an API route: $route"); } return $route; } echo requireRoute("/api/users"), "\n"; echo "GET " . requireRoute("/api/users"), "\n";
There are no literal types, no template literal types, no keyof, no mapped or conditional types, and no type-level computation of any kind — in the language or in PHPStan. What replaces them is a run-time guard at the boundary, as above, or a value object whose constructor validates once so the rest of the code can take the type's word for it. PHPStan does understand a few value types in docblocks (positive-int, non-empty-string, class-string), which is a narrow but genuinely useful slice.
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 TypeScript 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: for user-facing text use the mb_* family 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.
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 TypeScript 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: no interpolation, no escapes. There is no tagged-template mechanism, so nothing corresponds to sql`...` or css`...`.
Functions
Named arguments replace the options object
The options-object pattern exists in TypeScript because there is no way to skip a positional argument. PHP 8 added named arguments, so it is unnecessary here.
function connect({ host, port = 5432, timeout = 30 }: { host: string; port?: number; timeout?: number; }): void { console.log(`${host}:${port} timeout=${timeout}`); } connect({ host: "db.example.com" }); connect({ host: "db.example.com", timeout: 5 });
<?php declare(strict_types=1); 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 may be passed by name, in any order after the positional ones, and each one keeps its own declared type — which the options object gives up unless you write the type literal out. The new obligation is that a parameter name is now part of your public signature, so renaming one is a breaking change. Variadics are ...$rest in the signature, and spreading a keyed array at a call site fills in named arguments.
A closure closes over nothing by default
PHP functions do not see the variables of the function that created them. You list what may be captured, in a use clause.
function makeCounter(): () => number { let count = 0; return () => ++count; } const counter = makeCounter(); console.log(counter(), counter(), counter());
<?php declare(strict_types=1); function makeCounter(): callable { $count = 0; return function () use (&$count): int { return ++$count; }; } $counter = makeCounter(); echo $counter(), " ", $counter(), " ", $counter(), "\n";
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 TypeScript closure always does. Arrow functions (fn() => ...) capture automatically by value and are limited to a single expression — so a multi-statement closure still needs the long form and its use list.
callable says nothing about the signature
A function type is one of the places the runtime type system runs out. callable means "something you can call" and nothing about what it takes or returns.
type Transform = (value: number) => string; function apply(transform: Transform, value: number): string { return transform(value); } console.log(apply((value) => `#${value}`, 7));
<?php declare(strict_types=1); /** * @param callable(int): string $transform */ function apply(callable $transform, int $value): string { return $transform($value); } echo apply(fn(int $value): string => "#$value", 7), "\n";
So the signature lives in a docblock, where PHPStan and Psalm read it and the engine does not — a wrongly-shaped callback is a TypeError inside the callback, at the moment it runs. The Closure type is narrower (a closure specifically, not a string function name or an array method reference) and is worth preferring for parameters you will invoke. First-class callable syntax — strlen(...), $object->method(...), PHP 8.1 — turns any of those forms into a real Closure, checked where it is written rather than where it is called.
No overload signatures
TypeScript overload signatures let a function's return type depend on which argument type it received. PHP has one signature per function and no overloading at all.
function parse(value: string): number; function parse(value: number): string; function parse(value: string | number): number | string { return typeof value === "string" ? Number(value) : String(value); } console.log(parse("42"), typeof parse("42")); console.log(parse(42), typeof parse(42));
<?php declare(strict_types=1); /** * @return ($value is string ? int : string) */ function parse(string|int $value): int|string { return is_string($value) ? (int) $value : (string) $value; } echo parse("42"), " ", gettype(parse("42")), "\n"; echo parse(42), " ", gettype(parse(42)), "\n";
The engine can only say int|string, so the caller has to narrow. What fills the gap is a PHPStan conditional return type in a docblock — the ($value is string ? int : string) above is real PHPStan syntax and gives the analyser the same information the overloads gave tsc. Nothing at run time uses it. The everyday alternative is two differently-named functions, which is usually clearer anyway.
Generators, which do exist
Generators are a place the two languages agree: a function containing yield returns a lazy iterator, and nothing in the body runs until it is consumed.
function* countdown(start: number): Generator<number> { while (start > 0) yield start--; } console.log([...countdown(3)].join(","));
<?php declare(strict_types=1); 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. yield $key => $value produces keys, yield from delegates, and $generator->send() passes values back in, exactly like TypeScript's. Spreading needs iterator_to_array rather than [...]. Generators are the standard way to stream a large database result or file without holding it in memory, and they are also how Laravel's lazy collections work.
Classes & Properties
Constructor promotion, on both sides
TypeScript's parameter properties and PHP 8's constructor promotion are the same feature: a visibility keyword on a constructor parameter declares the property and assigns it.
class Account { constructor( public readonly owner: string, private balance: number = 0, ) {} deposit(amount: number): void { this.balance += amount; } total(): number { return this.balance; } } const account = new Account("Ada"); account.deposit(100); console.log(account.owner, account.total());
<?php declare(strict_types=1); final class Account { public function __construct( public readonly string $owner, private int $balance = 0, ) {} public function deposit(int $amount): void { $this->balance += $amount; } public function total(): int { return $this->balance; } } $account = new Account("Ada"); $account->deposit(100); echo $account->owner, " ", $account->total(), "\n";
The differences are all in what happens after compilation. private in TypeScript is erased — the emitted JavaScript has an ordinary property, which is why #private fields were added — while PHP's private is enforced by the engine. $this-> is the arrow, not a dot, and $this is mandatory: a bare $balance inside a method is a local variable. Marking a class final is common practice in modern PHP, since there is no sealed and inheritance is otherwise open.
A property without a value is uninitialized, not undefined
A typed property with no default is in an uninitialized state — a fourth thing, distinct from null, from unset and from a value.
class Config { host!: string; // the ! promises it will be set } const config = new Config(); console.log(config.host); // undefined, at run time
<?php declare(strict_types=1); final class Config { public string $host; // typed, and NOT null } $config = new Config(); try { echo $config->host, "\n"; } catch (Error $error) { echo "Error: ", $error->getMessage(), "\n"; }
Reading it throws Error: Typed property Config::$host must not be accessed before initialization, which is a far better failure than TypeScript's undefined leaking into the program from a definite-assignment assertion nobody rechecked. The lesson to carry over: give every property a value in the constructor, or declare it ?string $host = null and mean it. Property types are checked on every write, so assigning the wrong type to a public property throws at the assignment.
Getters are magic methods, not syntax
PHP 8.3 has no get/set accessor syntax. A computed value is an ordinary method, called with parentheses.
class Rectangle { constructor(private width: number, private height: number) {} get area(): number { return this.width * this.height; } } const rectangle = new Rectangle(3, 4); console.log(rectangle.area);
<?php declare(strict_types=1); final class Rectangle { public function __construct( private float $width, private float $height, ) {} public function area(): float { return $this->width * $this->height; } } $rectangle = new Rectangle(3, 4); echo $rectangle->area(), "\n";
The alternative is the __get magic method, which intercepts reads of properties that do not exist — that is how Laravel's Eloquent models expose database columns, and it is invisible to static analysis unless you annotate the class with @property. Property hooks (public float $area { get => ...; }) arrived in PHP 8.4, so a codebase on 8.4 or later has the syntax; this page targets 8.3, where a method is the answer.
Static members and the double colon
Anything reached through the class rather than an instance uses ::, an operator TypeScript has no counterpart for.
class Counter { static created = 0; static readonly LABEL = "counter"; constructor() { Counter.created += 1; } } new Counter(); new Counter(); console.log(Counter.created, Counter.LABEL);
<?php declare(strict_types=1); final 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:: means the class the code was written in and static:: the class actually being called — late static binding, which matters as soon as you subclass. parent::method() is super.method(). Note the inconsistency you will mistype at least once: a static property keeps its $ after the colons (Counter::$created) while a const does not.
Traits, where TypeScript uses mixins
The mixin pattern — a function returning a class that extends its argument — is TypeScript's way of sharing implementation without multiple inheritance. PHP has a keyword for it.
type Constructor<T = {}> = new (...args: any[]) => T; function Timestamped<TBase extends Constructor>(Base: TBase) { return class extends Base { updatedAt: string | null = null; touch(): void { this.updatedAt = "2026-08-18"; } }; } class Post {} const TimestampedPost = Timestamped(Post); const post = new TimestampedPost(); post.touch(); console.log(post.updatedAt);
<?php declare(strict_types=1); trait HasTimestamps { public ?string $updatedAt = null; public function touch(): void { $this->updatedAt = "2026-08-18"; } } final class Post { 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, so there is no extra class in the chain and no complicated type gymnastics to keep the analyser happy. Conflicts between two traits are an error you resolve explicitly with insteadof, where a mixin chain silently takes the last one. A trait cannot be used as a type — declare an interface alongside it and implements that, which is the standard pairing.
Structural Against Nominal
Shape is not enough
TypeScript matches types by shape. PHP matches by name: a value satisfies an interface only if its class said implements.
interface Named { name: string } function greet(thing: Named): string { return `hello ${thing.name}`; } class Dog { constructor(public name: string) {} } console.log(greet(new Dog("Rex"))); // a class satisfies it console.log(greet({ name: "Ada" })); // and so does a plain object — shape is enough
<?php declare(strict_types=1); interface Named { public function name(): string; } function greet(Named $thing): string { return "hello " . $thing->name(); } final class Dog implements Named { public function __construct(private string $name) {} public function name(): string { return $this->name; } } echo greet(new Dog("Rex")), "\n"; // greet(new class { public function name(): string { return "Ada"; } }); // → TypeError: the class does not implement Named, whatever its shape
That is the deepest structural difference between the two type systems, and it changes how code is designed. The anonymous object literal has no equivalent as a parameter — every value that crosses a typed boundary is an instance of a named class. Retroactive conformance is impossible: you cannot make a third-party class implement your interface, so the pattern is an adapter class that wraps it. In exchange, an interface name means one specific contract rather than any object that happens to have the right members.
There is no `type` alias
There is no type X = ... anywhere in PHP. Every named type is a class, an interface, an enum or a built-in.
type UserId = number; type Point = { x: number; y: number }; type Result = { ok: true; value: string } | { ok: false; error: string }; const point: Point = { x: 1, y: 2 }; const result: Result = { ok: true, value: "done" }; // a union alias, erased console.log(point.x);
<?php declare(strict_types=1); // No type aliases in the language. A named type is a class or an interface: final class UserId { public function __construct(public readonly int $value) {} } final class Point { public function __construct(public readonly float $x, public readonly float $y) {} } $point = new Point(1, 2); echo $point->x, "\n"; // PHPStan and Psalm DO have aliases, in a docblock on any class or file: /** @phpstan-type Result array{ok: bool, value?: string, error?: string} */ final class ResultShapes {}
A one-field wrapper class in place of type UserId = number looks heavy, and it buys something the alias does not: UserId and OrderId become genuinely different types that cannot be swapped, which is what a TypeScript author simulates with branded types. For pure shapes, PHPStan's @phpstan-type and Psalm's @psalm-type declare a reusable alias understood by the analyser — including array{ok: bool, value: string}, which is as close as anything gets to an object type literal.
Narrowing works the way you expect
Type narrowing by instanceof reads identically, and PHPStan tracks it exactly as tsc does.
class Circle { constructor(public radius: number) {} } class Square { constructor(public side: number) {} } function area(shape: Circle | Square): number { if (shape instanceof Circle) return 3.14 * shape.radius ** 2; return shape.side ** 2; } console.log(area(new Circle(1)), area(new Square(2)));
<?php declare(strict_types=1); final class Circle { public function __construct(public readonly float $radius) {} } final class Square { public function __construct(public readonly float $side) {} } function area(Circle|Square $shape): float { if ($shape instanceof Circle) return 3.14 * $shape->radius ** 2; return $shape->side ** 2; } echo area(new Circle(1)), " ", area(new Square(2)), "\n";
The narrowing functions are is_int, is_string, is_array, is_callable and friends rather than typeof, and analysers understand all of them. What has no counterpart is the user-defined type guard — function isUser(value: unknown): value is User. PHPStan's replacement is a docblock: @phpstan-assert-if-true User $value on a boolean-returning function tells the analyser the same thing, and again the engine ignores it.
Generics, and Where PHP Stops
There are no generic classes
This is the section a TypeScript reader most needs and is least likely to guess. PHP has no generics in the language — and the ecosystem has a complete answer that lives in comments.
class Box<T> { constructor(private value: T) {} get(): T { return this.value; } } const box = new Box<string>("hello"); console.log(box.get().toUpperCase());
<?php declare(strict_types=1); /** * @template T */ final class Box { /** @param T $value */ public function __construct(private mixed $value) {} /** @return T */ public function get(): mixed { return $this->value; } } /** @var Box<string> $box */ $box = new Box("hello"); echo strtoupper($box->get()), "\n";
@template T, @param T and @return T are read by PHPStan and Psalm, which check the calls, infer Box<string> through the code, and report an error if you put an int in and call a string method on the way out. It is genuinely comparable to tsc, with one difference that matters: nothing at run time enforces it, and mixed is what the engine actually sees. Generics in the language have been proposed repeatedly and are blocked on the cost of run-time checking; do not wait for them.
Typing what is inside an array
The array type declaration says nothing about the elements, so every collection signature in a typed PHP codebase carries a docblock beside it.
type User = { id: number; name: string }; function names(users: User[]): string[] { return users.map((user) => user.name); } console.log(names([{ id: 1, name: "Ada" }]).join(","));
<?php declare(strict_types=1); final class User { public function __construct( public readonly int $id, public readonly string $name, ) {} } /** * @param list<User> $users * @return list<string> */ function names(array $users): array { return array_map(fn(User $user): string => $user->name, $users); } echo implode(",", names([new User(1, "Ada")])), "\n";
The vocabulary is worth learning because it is more precise than TypeScript's in one respect: list<User> means sequential integer keys from zero, array<int, User> means integer keys that may have gaps, array<string, User> is a map, and non-empty-list<User> exists too. array{id: int, name: string} describes a keyed array with exactly those keys — TypeScript's object type literal, in a comment. The engine checks only that an array arrived.
The other answer: a typed collection class
The alternative to docblock generics is the pre-generics answer: a class that wraps an array and accepts only one type through its own typed methods.
class UserList { private items: string[] = []; add(name: string): this { this.items.push(name); return this; } all(): readonly string[] { return this.items; } } const users = new UserList().add("ada").add("grace"); console.log(users.all().join(","));
<?php declare(strict_types=1); final class UserList { /** @var list<string> */ private array $items = []; public function add(string $name): static { $this->items[] = $name; return $this; } /** @return list<string> */ public function all(): array { return $this->items; } } $users = (new UserList())->add("ada")->add("grace"); echo implode(",", $users->all()), "\n";
Every write goes through add(string $name), which the engine checks — so the collection's element type is enforced at run time, not merely analysed. That is worth the boilerplate for a type that matters, and it is why Doctrine, Laravel and most domain-driven PHP codebases are full of small collection classes. static as a return type is the equivalent of TypeScript's this type and is what makes the fluent chain keep the subclass's type.
PHPStan & Psalm — the tsc You Run Separately
The type checker you run separately
This is the tool that closes most of the gap, and a TypeScript reader arriving at a PHP codebase should ask which level it runs at before anything else.
// package.json: "typecheck": "tsc --noEmit" // Runs on every commit; the editor runs the same compiler live. // Strictness is one setting: "strict": true. console.log("tsc --noEmit");
<?php // composer require --dev phpstan/phpstan // vendor/bin/phpstan analyse src --level 9 // // phpstan.neon: // parameters: // level: 9 // paths: [src] // // Level 0 is loose; level 9 treats 'mixed' as an error and is the // approximate equivalent of "strict": true. echo "vendor/bin/phpstan analyse\n";
PHPStan has ten levels, and the number tells you a great deal about the code: level 0 catches undefined functions, level 5 checks argument types, level 8 checks nullability, and level 9 refuses to let mixed pass unchecked. Psalm is the alternative, with errorLevel counting the other way (1 is strictest) and a stronger taint-analysis story. Both read the same docblock vocabulary, both have baseline files for adopting them on a legacy codebase, and both are dev dependencies rather than part of the runtime.
The docblock vocabulary worth memorising
The analysers understand a type language of their own, and it reaches places TypeScript does not — value-level types like positive-int and non-empty-string.
function describe( names: string[], // list<string> byId: Record<number, string>, // array<int, string> point: { x: number; y: number }, // array{x: float, y: float} className: string, // class-string<Throwable> limit: number, // positive-int ): string { return [className, names.length, Object.keys(byId).length, point.x, limit].join(":"); } console.log(describe(["a"], { 1: "b" }, { x: 1, y: 2 }, "RuntimeException", 5));
<?php declare(strict_types=1); /** * @param list<string> $names sequential keys from 0 * @param array<int, string> $byId integer keys, gaps allowed * @param array{x: float, y: float} $point exactly these keys * @param class-string<Throwable> $className a class name, not any string * @param positive-int $limit narrower than int * @return non-empty-string */ function describe(array $names, array $byId, array $point, string $className, int $limit): string { return $className . ':' . count($names) . ':' . count($byId) . ':' . $point['x'] . ':' . $limit; } echo describe(['a'], [1 => 'b'], ['x' => 1.0, 'y' => 2.0], RuntimeException::class, 5), "\n";
The ones you will use daily are list<T>, array<K, V>, array{...} and class-string<T>; the last is how a factory or a container is typed, and it is checked much better than TypeScript's new () => T. @template declares a generic, @phpstan-assert declares a type guard, and @phpstan-type declares an alias. What is missing is everything computed: no keyof, no mapped types, no conditional types beyond the single return-type form.
any against mixed
mixed is PHP's any and its unknown at once, and which one it behaves like depends on the PHPStan level you run.
const payload: any = JSON.parse('{"id":1}'); console.log(payload.id.toFixed(2)); // compiles; any silences everything const safer: unknown = JSON.parse('{"id":1}'); // console.log(safer.id); // error: unknown must be narrowed first console.log(typeof safer);
<?php declare(strict_types=1); $payload = json_decode('{"id":1}', associative: true); // mixed // PHPStan at level 9 refuses to let mixed be used without a check: if (is_array($payload) && isset($payload['id']) && is_int($payload['id'])) { echo number_format((float) $payload['id'], 2), "\n"; } echo gettype($payload), "\n";
At low levels mixed flows anywhere unchecked, exactly like any. At level 9 it must be narrowed before use, exactly like unknown — which is the strongest argument for running level 9 on new code. The run-time half is unchanged either way: mixed as a declared type accepts everything, so the checking really is the analyser's job here. Everything arriving from json_decode, a database driver or a superglobal starts as mixed, which makes the boundary the place to spend your validation effort.
Errors & Exceptions
catch is typed
PHP's catch selects by type, so the instanceof ladder that TypeScript forces on you is unnecessary.
class NotFound extends Error {} try { throw new NotFound("no such user"); } catch (error) { if (error instanceof NotFound) { console.log("NotFound:", error.message); } else { throw error; } } finally { console.log("always runs"); }
<?php declare(strict_types=1); final class NotFoundException extends RuntimeException {} try { throw new NotFoundException("no such user"); } catch (NotFoundException $error) { echo "NotFound: ", $error->getMessage(), "\n"; } finally { echo "always runs\n"; }
Several catch blocks may follow one try, and a single block can list alternatives (catch (AException|BException $error)). TypeScript types the caught value as unknown under useUnknownInCatchVariables, which is the right default and the reason for the ladder — anything can be thrown in JavaScript. PHP can only throw an object implementing Throwable, so the caught value always has getMessage(), getCode(), getFile(), getLine() and getPrevious().
Error and Exception are siblings
PHP splits throwables into two branches that do not inherit from each other: Exception for what your code is expected to handle, and Error for the language's own failures.
try { const value: any = null; console.log(value.length); } catch (error) { console.log((error as Error).constructor.name); }
<?php declare(strict_types=1); try { $value = null; echo strlen($value), "\n"; } catch (TypeError $error) { echo get_class($error), "\n"; }
TypeError, ValueError, DivisionByZeroError and ArgumentCountError are all Errors, so catch (Exception $e) — which looks like a catch-all and is the reflex from other languages — will not catch a type failure. Catch \Throwable when you genuinely mean everything. For your own exceptions, extend RuntimeException or LogicException rather than Exception directly; the SPL hierarchy is shallow and conventional.
Nothing declares what a function throws
Neither language has checked exceptions, so in both the @throws tag is documentation.
/** * @throws {RangeError} when the value is negative */ function squareRoot(value: number): number { if (value < 0) throw new RangeError("negative"); return Math.sqrt(value); } console.log(squareRoot(9));
<?php declare(strict_types=1); /** * @throws InvalidArgumentException when the value is negative */ function squareRoot(float $value): float { if ($value < 0) throw new InvalidArgumentException("negative"); return sqrt($value); } echo squareRoot(9), "\n";
The difference is who reads it. TypeScript's @throws is understood by editors and by nothing else. PHPStan and Psalm do read PHP's, and both can be configured to report a call that ignores a documented exception — Psalm's checkThrows and PHPStan's exceptions rules turn it into something close to a checked exception, opt in per project. It is one more example of the pattern on this page: the run-time behaviour is the same, and the analyser is where the rigour lives.
Warnings: the failure that does not stop anything
PHP has an older channel for problems: a diagnostic is emitted, execution continues, and in a badly configured environment it lands in 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 and the value is null. The second line is where the two languages diverge dangerously: adding one gives NaN in JavaScript, which is at least visibly wrong, and 1 in PHP, because null converts to 0 and a wrong answer looks exactly like a right one. Two habits: use ?? to read anything that might be absent, and configure a set_error_handler that throws ErrorException — which every framework does for you. The @ operator suppresses diagnostics for one expression and is almost always the wrong tool.
The Request Lifecycle
A fresh process per request
Both columns print visit 1 then visit 2, because both calls happen inside one process. What differs is what happens next — which no single runnable example can show.
let visitCount = 0; function handleRequest(): string { visitCount += 1; return `visit ${visitCount}`; } console.log(handleRequest()); console.log(handleRequest()); // the module is still loaded
<?php declare(strict_types=1); function handleRequest(): string { static $visitCount = 0; $visitCount += 1; return "visit $visitCount"; } echo handleRequest(), "\n"; echo handleRequest(), "\n"; // still the SAME request
A Node process stays up between requests, so module-level state keeps accumulating until you redeploy. 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, Redis or the database. The upside is that a leak or a crash costs one visitor rather than every visitor; the downside is that there is no warm cache and no long-lived connection pool without an extra layer. /javascript/php owns this story in full.
No event loop, and no async
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 returns it.
(async () => { const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const fetchTotal = async (): Promise<number> => { await wait(1); return 42; }; console.log("total", await fetchTotal()); })();
<?php declare(strict_types=1); function fetchTotal(): int { usleep(1000); // a blocking wait, and that is fine return 42; } echo "total ", fetchTotal(), "\n";
That removes function colouring entirely: any function may call any other, and no library needs an async variant. It also removes Promise.all — concurrent work becomes curl_multi_exec for HTTP, a queue worker for anything slow, or an event-loop runtime (ReactPHP, Amp) if you are willing to run your application differently. Fibers (8.1) are the machinery those runtimes use; you will almost certainly never write new Fiber yourself.
The request is ambient
In Node the request is an object handed to your handler. In PHP it is the environment: already there, in superglobals, before your first line runs.
type Request = { url: string }; function handler(request: Request): string { const name = new URL(request.url, "http://x").searchParams.get("name") ?? "stranger"; return `hello ${name}`; } console.log(handler({ url: "/greet?name=Ada" }));
<?php declare(strict_types=1); // 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. Every value in them is attacker-controlled text with no schema and, to the analyser, type mixed — so they are exactly where validation belongs. Frameworks wrap them in a PSR-7 Request object precisely so code can be tested and typed.
Composer, npm & Autoloading
use imports a name, not a module
A namespace is declared inside the file rather than derived from its path, and use imports a name — it loads nothing and runs nothing.
// import { Client } from "./http/client.js"; // The path is the module; the bundler resolves and loads it. class Client { send(): string { return "sent"; } } console.log(new Client().send()); console.log(Client.name);
<?php declare(strict_types=1); namespace App\Http; use InvalidArgumentException as BadArgument; final 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. So there is no import-time side effect, no circular-import problem and no tree shaking to think about. The separator is a backslash, which is why it must be escaped in double-quoted strings; a leading one means the global namespace (\strlen(), \Throwable). PSR-4 is the convention tying App\Http\Client to src/Http/Client.php.
Composer against npm
The mechanics rhyme — a manifest, a lockfile, a directory of dependencies — and two things around them do not.
// npm install zod // npm ci → node_modules/, thousands of packages // package.json + package-lock.json + tsconfig.json // Build: tsc / esbuild / vite, then ship the output. console.log("install, typecheck, build, ship the bundle");
<?php // composer require guzzlehttp/guzzle // composer install → vendor/, tens of packages // composer.json + composer.lock (+ phpstan.neon) // No build: require vendor/autoload.php and ship the source. echo "install, analyse, ship the source\n";
There is no module loader, so every entry point begins with require __DIR__ . '/vendor/autoload.php';, after which every installed class is available by name with no further imports. And there is no build: the source is the artifact, so what a TypeScript project spends on bundling, a PHP project spends on opcache configuration instead. Trees are an order of magnitude smaller, because the standard library covers strings, arrays, dates, hashing, HTTP and databases. composer.lock is committed and composer install honours it; composer update is the one that changes it.
The rest of the toolchain, mapped
The map is nearly one-to-one, and the difference is how much choice there is.
// tsc → type checking // eslint → linting // prettier → formatting // vitest/jest → tests // tsx / node → running a script console.log("five tools, chosen per project");
<?php // phpstan / psalm → type checking (see the PHPStan section) // php-cs-fixer → formatting, to the PSR-12 standard // phpunit / pest → tests // php file.php → running a script // php -S localhost:8000 → a development server, built in echo "four tools, and the same four in nearly every project\n";
PHP's ecosystem converged: PHPUnit is the test runner almost everywhere (Pest is a friendlier layer on top of it), PSR-12 is the formatting standard almost everywhere, and PHPStan or Psalm is the checker. Composer scripts play the role of npm scripts. What has no counterpart is the bundler question, because there is nothing to bundle — and php -S gives you a development server with no dependency at all, which is a small pleasure after configuring vite.