The PHP Type System Grew Up: From Loose Scripts to Strict Contracts
Direct answer: PHP went from almost no types to a strict, runtime-enforced type system in about ten years. PHP 7 added scalar and return types, 7.4 added typed properties, 8.0 added union types, 8.1 added enums, readonly and never, 8.2 added DNF types and readonly classes, and 8.3 added typed constants. PHP still has no native generics. Static analysers like PHPStan and Psalm fill that gap with docblock generics checked before deploy.
If you last wrote PHP in the 5.x era, the type system is the biggest thing you've missed. This post walks through how it grew, how to use it well today, and where it still stops. It's part of my PHP in 2026 series.
How PHP's type system grew#
| Version | Type features added |
|---|---|
| 5.x (2004–2014) | Class, interface, array and callable parameter types only. No scalars, no return types. |
| 7.0 (2015) | Scalar types (int, float, string, bool), return types, declare(strict_types=1) |
| 7.1 | Nullable types (?string), void, iterable |
| 7.2 | object type |
| 7.4 | Typed properties, covariant returns and contravariant parameters |
| 8.0 (2020) | Union types (int|string), mixed, static return type, constructor promotion |
| 8.1 | Enums, readonly properties, intersection types (A&B), never |
| 8.2 | DNF types ((A&B)|null), standalone true/false/null, readonly classes |
| 8.3 | Typed class constants, #[\Override] |
| 8.4–8.6 | Properties in interfaces via hooks, asymmetric visibility, readonly property defaults |
strict_types: the switch most people misunderstand#
By default PHP coerces scalars: pass "5" to an int parameter and you get 5. With declare(strict_types=1); at the top of a file, a wrong scalar type throws a TypeError instead.
The subtle part: strict mode applies to calls made from that file, not to functions defined in it. A library declaring strict types doesn't make your calls strict; your file has to declare it. The practical rule is to put it in every file and enforce it with a coding-standard check.
<?php
declare(strict_types=1);
function addVat(int $cents): int
{
return (int) round($cents * 1.13);
}
addVat("500"); // TypeError in strict mode; silently 500 without it
Enums replaced magic strings#
Before 8.1, statuses were strings or class constants, and nothing stopped 'pendng' from reaching the database. Backed enums fix that and carry behaviour:
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Refunded = 'refunded';
public function isFinal(): bool
{
return $this === self::Refunded;
}
}
$status = OrderStatus::from($row['status']); // throws on unknown values
Laravel casts enum columns automatically, and match on an enum makes missing cases obvious in code review and in static analysis.
Readonly and immutable objects#
final readonly class Money
{
public function __construct(
public int $amount,
public Currency $currency,
) {}
}
Readonly classes make value objects (money, coordinates, date ranges, API payloads) safe to pass around without defensive copying. PHP 8.5's clone() with overrides makes them easy to modify, and 8.6 allows readonly defaults.
The precise types: never, intersections, DNF#
nevermeans "this function doesn't return": it throws or exits. That lets analysers know code afterabort()is unreachable.- Intersection types (
Countable&Iterator) require an object to implement several interfaces at once. - DNF types combine both:
(HasId&HasName)|null.
The gap: generics#
PHP can't express "a collection of User" at runtime. array and Collection say nothing about their contents. Several generics proposals have been explored, and the most recent RFC (bound-erased generic types) was declined. There's no live generics RFC as of PHP 8.6.
The ecosystem's answer is docblock generics, checked by static analysis:
/**
* @template T
* @param class-string<T> $class
* @return list<T>
*/
function findAll(string $class): array { /* ... */ }
/** @var Collection<int, User> $users */
PHPStan and Psalm understand these annotations, along with array shapes (array{id: int, email: string}), literal types and non-empty strings. Laravel's own codebase ships generic annotations, and Larastan brings PHPStan's understanding to Eloquent.
Static analysis is part of the type system now#
In modern PHP, "the type system" really means two layers: runtime types enforced by the engine, and static types enforced in CI. A practical setup:
declare(strict_types=1)everywhere.- Native types on every parameter, return and property. Use docblocks only for what native types can't express.
- PHPStan (or Larastan for Laravel) in CI. Start at a level your codebase passes, use a baseline file for existing errors, and raise the level over time.
- Treat new type errors like failing tests: they block the merge.
This is also what makes AI-assisted coding safer. Typed code gives tools and reviewers a contract to check against, a point I made in how AI coding tools changed our velocity.
Why types matter beyond catching bugs#
Fully typed PHP is what makes ahead-of-time compilation possible at all. Projects like TypePHP can only compile code whose types are known. The JIT benefits from stable types too. Every type you add today keeps future performance options open.
Key takeaways#
- PHP's type system grew from class hints in PHP 5 to unions, enums, readonly classes, DNF and typed constants in PHP 8.
strict_typesapplies to calls made from a file, so declare it in every file.- Enums and readonly value objects remove whole categories of bugs.
- There are no native generics, and the last RFC was declined. Use PHPStan or Psalm docblock generics.
- Runtime types plus static analysis in CI is the modern PHP type system.