PHP 8.2: readonly classes and the deprecation that matters
PHP 8.2 dropped December 8th. Readonly classes are the headline. The deprecation of dynamic properties is the one that actually requires your attention.
Dynamic properties deprecated
PHP has always allowed adding properties to objects that weren’t declared in the class:
class User {}
$user = new User();
$user->name = 'Alice'; // no declaration, no error... until now
In 8.2, this triggers a deprecation notice. In PHP 9.0 it becomes a fatal error. The grace period exists, but the migration clock is running.
The reasoning is solid: dynamic properties are a classic source of typos that silently pass (write $user->nmae and PHP just creates a new property instead of complaining). Explicit declarations make the class contract clear and make tooling actually useful.
Migration is mostly mechanical: declare the properties, or slap #[AllowDynamicProperties] on legacy classes you can’t touch yet.
Readonly classes
8.1 added readonly for individual properties. 8.2 adds it to the class declaration itself:
readonly class Point {
public function __construct(
public float $x,
public float $y,
public float $z,
) {}
}
All promoted and explicitly declared properties become readonly automatically. Value objects (coordinates, money amounts, identifiers) are the obvious target. The syntax is clean and the intent reads clearly.
One constraint: readonly classes can’t have non-typed properties, which were already a bad idea with readonly anyway.
DNF types
Disjunctive Normal Form types let you combine union and intersection types:
function process(Countable&Iterator|null $collection): void { ... }
(Countable&Iterator)|null: an object that implements both interfaces, or null. This covers type expressions that 8.0 union types and 8.1 intersection types each got partway to but couldn’t represent together.
The Random extension
A dedicated Random extension replaces the scattered rand(), mt_rand(), random_int() functions with an object-oriented API:
$rng = new Random\Randomizer();
$rng->getInt(1, 100);
$rng->shuffleArray($items);
Engines are swappable: Mersenne Twister, PCG64, Xoshiro256StarStar, or CryptoSafeEngine for security-sensitive contexts. Same code, seeded deterministic engine in tests, cryptographic engine in production.
8.2 is a consolidation release. The dynamic properties deprecation is the one decision you need to make now.
null, false, and true as standalone types
PHP has had nullable types since 7.1 and union types since 8.0, but null as a standalone type declaration wasn’t valid. 8.2 fixes that:
function alwaysNull(): null {
return null;
}
function disabled(): false {
return false;
}
function enabled(): true {
return true;
}
false and true as standalone types are useful when you need to be precise about what a function can actually return. It’s narrow but correct: a function that returns false on failure and a string on success should declare string|false, and now both sides of that union are real types.
Constants in traits
Traits could hold properties and methods. Constants were the odd gap. 8.2 closes it:
trait Timestamps {
public const DATE_FORMAT = 'Y-m-d H:i:s';
public function formatCreatedAt(): string {
return $this->createdAt->format(self::DATE_FORMAT);
}
}
class Article {
use Timestamps;
}
echo Article::DATE_FORMAT; // 'Y-m-d H:i:s'
The constant belongs to the class that uses the trait, not the trait itself, so you can’t access Timestamps::DATE_FORMAT directly. Expected scoping behavior, consistent with how trait methods already work.
#[SensitiveParameter]
Stack traces have always been a liability: function arguments get logged verbatim, which means passwords and tokens end up in your error logs and monitoring dashboards. 8.2 adds an attribute to stop that:
function authenticate(
string $user,
#[\SensitiveParameter] string $password,
): bool {
// if this throws, the stack trace shows:
// authenticate('alice', Object(SensitiveParameterValue))
return hash('sha256', $password) === getStoredHash($user);
}
The parameter value in the trace gets replaced with a SensitiveParameterValue object. One attribute, zero excuses not to add it to every function that touches credentials.
Deprecated string interpolation syntaxes
Two ways to interpolate expressions inside strings are deprecated in 8.2:
$name = 'world';
// These are deprecated:
echo "Hello ${name}"; // use "$name" or "{$name}"
echo "Hello ${getName()}"; // use "{$this->getName()}"
The ${...} forms created ambiguity between variable variables and expressions. The cleaner {$...} syntax has always been there and does the same thing. This is mostly a search-and-replace job in codebases that picked up the deprecated forms out of habit.
utf8_encode() and utf8_decode() deprecated
These two functions are deprecated in 8.2 and gone in 9.0. Their behavior was always narrower than the names suggested: utf8_encode() converts ISO-8859-1 to UTF-8, not “any encoding to UTF-8.”
// Deprecated in 8.2:
$utf8 = utf8_encode($latin1String);
// Use instead:
$utf8 = mb_convert_encoding($latin1String, 'UTF-8', 'ISO-8859-1');
mb_convert_encoding() or iconv() handle the general case. If you’re actually dealing with Latin-1 input, the replacement is a direct swap.
Locale-independent string functions
Several string functions silently varied behavior based on the system locale, producing different results in production versus a dev container. In 8.2, they’re locale-independent and ASCII-only:
// strtolower, strtoupper, stristr, stripos, strripos,
// lcfirst, ucfirst, ucwords, str_ireplace now do ASCII case conversion only.
// For locale-aware behavior, use mb_* equivalents:
$lowered = mb_strtolower($text, 'UTF-8');
This is a correctness fix. If your code was relying on locale-sensitive behavior from these functions, it was already broken on systems with different locale configurations. 8.2 makes the behavior deterministic everywhere, which is what you actually wanted.
str_split() on empty string
A quiet behavior change worth noting:
// PHP 8.1: str_split('') === ['']
// PHP 8.2: str_split('') === []
The new behavior makes more sense: splitting nothing produces nothing. If you’re checking count(str_split($input)), an empty input no longer produces a count of 1.