Leaving PHP, Part 3: the type system arrived late and incomplete
Part 1 looked at who you can hire. Part 2 looked at the runtime they would inherit. Part 3 looks at the artifact they spend most of their day in: the source code, and the tools meant to verify it. PHP added a type system in increments over a decade. Each release closed a gap. Taken together, the gaps that remain are the ones that bite hardest on a large codebase.
PHP 7.0 added scalar parameter and return types. PHP 7.1 added nullable types, void, and iterable. PHP 7.4 added typed properties. PHP 8.0 added union types and mixed. PHP 8.1 added enums, intersection types, never, and readonly properties. PHP 8.2 added readonly classes and disjunctive normal form types. PHP 8.3 added typed class constants. PHP 8.4 added property hooks and asymmetric visibility. Every one was a real improvement. None of them, in combination, gives you what a TypeScript or Kotlin codebase has shipped for years.
A decade of catching up
The most generous way to describe PHP's type system is "iterative." A less generous way is "a feature backlog the language has been working through since 2015 and is not done with."
| Version | Year | Type features added |
|---|---|---|
| PHP 7.0 | 2015 | Scalar parameter types, return types |
| PHP 7.1 | 2016 | Nullable types, void, iterable |
| PHP 7.4 | 2019 | Typed properties, contravariant parameter / covariant return |
| PHP 8.0 | 2020 | Union types, mixed, static return, named arguments |
| PHP 8.1 | 2021 | Enums, intersection types, never, readonly properties |
| PHP 8.2 | 2022 | Readonly classes, DNF types, true/false/null as standalone |
| PHP 8.3 | 2023 | Typed class constants |
| PHP 8.4 | 2024 | Property hooks, asymmetric visibility |
For reference: TypeScript shipped in October 2012 with generic types, structural typing, and union types on day one. Kotlin reached 1.0 in February 2016 with full generics, sealed classes, and null safety baked in. Python's typing module landed in 3.5 (2015), and Pyright has given Python codebases compiler-grade inference since 2019. PHP's type-system roadmap is, in effect, a list of features the comparable language ecosystems treated as table stakes a decade ago.
What is still missing
A PHP 8.4 codebase, written by an attentive team and analyzed in CI by Psalm in strict mode, is genuinely well-typed compared to a PHP 5.6 codebase. It is also genuinely behind what a TypeScript or Kotlin team gets from the compiler for free.
Collection<User> is impossible to express to the runtime. The runtime sees array or iterable. Type identity stops at the container boundary.let, no val, no walrus operator with inferred type. Locals are dynamically typed; static analyzers infer them post hoc and disagree on edge cases.type Result = Ok | Err with a tag field, narrowed by a switch, has no first-class equivalent. Enums help, but they cannot carry per-case payloads with different shapes.where T : Comparable. You either accept the constraint at runtime and throw, or you document it in a docblock and hope.Partial<T>, Pick<T, K>, or strongly typed route parameters is simply absent. PHPStan has partial conditional-return support; the runtime still does not.array{id: int, name: string} shapes. PHP itself does not. Every PHP team eventually invents an ad-hoc DTO class to make up for it.None of these is fatal on its own. Together they describe a language where the type information that lives in your head, your docblocks, and your IDE has no consistent representation in the artifact that actually runs.
Generics, by docblock
The single most-cited gap is generics. PHP has none. PHPStan and Psalm have built a parallel type system in docblocks that pretends PHP has them.
/**
* @template T
*/
class Collection {
/** @var array<int, T> */
private array $items = [];
/** @param T $item */
public function add($item): void {
$this->items[] = $item;
}
/** @return T|null */
public function first() {
return $this->items[0] ?? null;
}
}
This works. PHPStorm, Psalm, and PHPStan will all carry T through call sites. Pass a User to add(), get a User|null back from first(). The experience inside the IDE is essentially what TypeScript gives you natively.
The catch is what the runtime sees:
- The runtime sees nothing. The docblock is a comment. Pass a
Productto aCollection<User>and PHP accepts it. The bug surfaces wherever you call aUsermethod on it. - The docblock and the signature can disagree. The signature says
$itemis untyped ormixed; the docblock saysT. When they drift, the IDE lies to you in a different direction from what the runtime does. - You write types in two places. Once in the signature where the runtime enforces them, once in the docblock where the analyzer reads them. On every change you maintain both.
- Library authors are not uniformly diligent. A dependency with sloppy docblocks pollutes your inference chain. The fix is to upstream patches, or pin
mixedon the boundary and lose the type entirely.
The Hack language, which Facebook forked from PHP in 2014 precisely because the type system was inadequate, has had real generics with runtime erasure since 1.0. PHP has spent ten years adding features around the edges of the same gap and still does not ship them.
The refactor at scale
The cost is theoretical until the codebase gets large. At 5,000 lines, you can hold the call graph in your head and grep for callers. At 200,000 lines, you cannot.
Take a concrete operation: rename UserRepository::getUser() to UserRepository::findUser(). In a TypeScript codebase:
- Right-click in the IDE, rename symbol
- The compiler updates every reference in the project
- Anything the rename cannot resolve (dynamic dispatch, JSON config) becomes a compile error
- Ship
In a PHP codebase:
- Rename the method on the class
- Run PHPStan at the highest level your codebase tolerates; some call sites flagged, others not (anything reached through
mixed, untyped legacy code, magic__call, or dynamic method names is invisible) - Run Psalm with strict config; a different subset flagged
- Run the test suite; another subset surfaces
- Ship to staging; pray for the rest
Each tool catches a different slice. None catches everything. Teams compensate by writing more tests, which is fine, except that the test pyramid you need to substitute for compiler guarantees is large, slow, and itself a maintenance burden.
That 5% is the bill. On a small codebase it is a rounding error. On a long-lived 200,000-line product it is the difference between a one-day refactor and a two-sprint migration with a stabilization period.
A cross-language scorecard
The comparison that matters is not PHP 5 vs PHP 8.4. It is PHP 8.4 vs what teams choosing a language today are actually choosing between.
| Feature | PHP 8.4 + Psalm | TypeScript 5 | Kotlin | Python + Pyright |
|---|---|---|---|---|
| Generics in language | Docblock | Native | Native | Native |
| Local type inference | Analyzer | Compiler | Compiler | Pyright |
| Discriminated unions | No | Yes | Sealed classes | Match + Literal |
| Mapped / conditional types | No | Yes | Partial | Partial |
| Array / record shapes | Analyzer only | Yes | data class | TypedDict |
| Rename refactor confidence | Analyzer + tests | Compiler | Compiler | Pyright |
| Runtime enforcement | Yes (declared) | None (stripped) | Yes | None |
PHP wins one row outright: it enforces declared types at runtime, where TypeScript and Python do not. That is a real virtue. It is also a virtue you can recover in TypeScript with zod, io-ts, or typia at the boundary, and most teams do precisely that.
How the refactoring confidence stack adds up
The chart is indicative, not measured. The shape is the point. A serious PHP team with Psalm in strict CI is much better off than the median 2018 codebase. They are still in the second tier. The gap shows up exactly when you most need the safety net: a senior leaves, a junior renames a method, a refactor sprawls across modules that the analyzer cannot fully cover.
Up next in the series
The talent argument was about who can write the code. The runtime argument was about what the code runs on. The type argument is about how confident anyone can be in the code they did not write. Part 4 moves to the layer above that: the ecosystem you build on top, the dependencies that came with it, and the cost of staying on a stack the rest of the world is leaving.
If you want to start the migration before Part 10 lands, book a demo and we will walk you through what Pext does to your codebase.