PHP 8.2 dropped December 8th. Readonly classes are the headline. The deprecation of dynamic properties is the one that actually requires your attention.

:no_entry_sign: 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.

:pencil: 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.

:twisted_rightwards_arrows: 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.

:game_die: 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.

:label: 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.

:package: 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.

:zipper_mouth_face: #[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.

:scissors: 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.

:wastebasket: 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.

:globe_with_meridians: 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.

:arrows_counterclockwise: 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.