Back to blog

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.

10 yrs
Of incremental additions, from scalar types in PHP 7.0 (2015) to property hooks in 8.4 (2024), to reach today's still-partial type system
0
Generics in the language itself. Generics exist only through Psalm and PHPStan docblock annotations the runtime does not read
2
Static analyzers a serious PHP team runs in CI (Psalm and PHPStan), separate from the runtime, to recover what other languages give you in the compiler

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.

!
Generics in the language
A Collection<User> is impossible to express to the runtime. The runtime sees array or iterable. Type identity stops at the container boundary.
!
Type inference for local variables
No let, no val, no walrus operator with inferred type. Locals are dynamically typed; static analyzers infer them post hoc and disagree on edge cases.
!
Discriminated unions
TypeScript's 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.
!
Generic constraints
No where T : Comparable. You either accept the constraint at runtime and throw, or you document it in a docblock and hope.
!
Mapped, conditional, and template literal types
The vocabulary that lets TypeScript express 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 shapes at the runtime
Psalm and PHPStan support 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 Product to a Collection<User> and PHP accepts it. The bug surfaces wherever you call a User method on it.
  • The docblock and the signature can disagree. The signature says $item is untyped or mixed; the docblock says T. 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 mixed on 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

TypeScript
~98%
Kotlin
~98%
Python + Pyright
~92%
PHP + Psalm strict
~78%
PHP, no analyzer
~30%

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.