PHP 8.3: typed constants and the small wins that stick
PHP 8.3 landed November 23rd. Quiet release by PHP standards: no enum-sized shift, no JIT. What it does have is a focused set of improvements that close long-standing gaps in the type system and add functions that should have existed years ago.
Typed class constants
Class constants have been untyped since their introduction. PHP 8.3 fixes that:
interface HasVersion {
const string VERSION;
}
class App implements HasVersion {
const string VERSION = '1.0.0';
}
Without typed constants, an interface constant could be overridden with a completely different type in an implementing class and nothing would complain. Typed constants close that gap, and on interface-driven codebases the impact is immediate.
Dynamic class constant access
A gap that required a workaround since constants were introduced:
$name = 'STATUS';
echo MyClass::{$name}; // now works
Before, accessing a constant with a dynamic name meant calling constant('MyClass::STATUS'). The new syntax is consistent with how PHP already handles variable variables and dynamic method calls.
readonly can now be amended in clone
A specific but genuinely annoying limitation of 8.1 readonly: you couldn’t clone an object and change a readonly property. 8.3 adds the ability to reinitialize readonly properties during cloning, which makes immutable value objects usable in a lot more patterns.
json_validate()
if (json_validate($string)) {
$data = json_decode($string);
}
Before 8.3, the only way to validate a JSON string was to decode it and check for errors. json_validate() checks without allocating the decoded structure, which matters when you only need to know if the string is valid JSON, not what’s in it.
Randomizer improvements
getBytesFromString() generates a random string composed only of characters from a given set:
$rng = new Random\Randomizer();
$token = $rng->getBytesFromString('abcdefghijklmnopqrstuvwxyz0123456789', 32);
The previous approach: str_split, array_map, random selection, implode. It worked, but it was longer than it had any right to be.
8.3 is for the teams that adopt PHP versions quickly and want the incremental improvements. The typed constants alone are worth it on any codebase with interface constants.
#[\Override] makes inheritance explicit
Before 8.3, nothing stopped you from writing a method you thought was overriding a parent’s, when you had a typo in the name or the parent had quietly removed it. Silent bugs, zero feedback from the engine.
class Cache {
#[\Override]
public function get(string $key): mixed {
// Engine verifies this method exists in a parent or interface
}
}
If the method doesn’t exist in any parent class or implemented interface, PHP throws an error. Same concept as Java’s @Override or C#’s override, finally in PHP.
final on trait methods
Traits have always had rough edges in PHP’s OOP model. One specific problem: a class using a trait could override any of its methods, undermining whatever guarantees the trait was trying to provide. 8.3 lets the trait itself mark a method as final:
trait Singleton {
final public static function getInstance(): static {
// ...
}
}
Now a class using the trait cannot override getInstance(). The guarantee holds.
Anonymous classes can be readonly
PHP 8.1 brought readonly classes. Anonymous classes were left out for some reason. 8.3 fixes that:
$point = new readonly class(3, 4) {
public function __construct(
public float $x,
public float $y,
) {}
};
Handy when you need a throwaway immutable value object without the ceremony of naming it.
Static variable initializers accept expressions
A small but long-standing restriction: static variable initializers only accepted constant expressions, no function calls. 8.3 drops that constraint:
function connection(): PDO {
static $pdo = new PDO(getenv('DATABASE_URL'));
return $pdo;
}
The initializer runs once on first call, the static variable persists. Achievable with a null-check before, this is just cleaner.
mb_str_pad() finally exists
str_pad() has always been byte-aware, not character-aware. For multibyte strings (Arabic, Japanese, accented characters) it produced wrong output. 8.3 finally adds the multibyte variant:
$padded = mb_str_pad('日本', 10, '*', STR_PAD_BOTH);
The function respects character boundaries, not byte counts.
str_increment() and str_decrement()
PHP’s ++ operator on strings has a history of quirks: it increments letter sequences ('a' → 'b', 'z' → 'aa'), but -- never worked symmetrically. The behavior was surprising enough that 8.3 deprecates ++/-- on non-alphanumeric strings and introduces explicit functions:
echo str_increment('a'); // b
echo str_increment('Az'); // Ba
echo str_decrement('b'); // a
echo str_decrement('Ba'); // Az
The functions make the intent obvious and the behavior predictable.
Random\Randomizer gets float support
8.3 fills in the float side of the Randomizer API:
$rng = new Random\Randomizer();
// A float in [0.0, 1.0)
$f = $rng->nextFloat();
// A float in a specific range with controlled boundary inclusion
$f = $rng->getFloat(1.5, 3.5, Random\IntervalBoundary::ClosedOpen);
IntervalBoundary is a new enum with four values: ClosedOpen, ClosedClosed, OpenClosed, OpenOpen. This matters for statistical correctness: the naive approach of rand() / getrandmax() doesn’t produce a uniform distribution over floats.
The Date exception hierarchy
Date/time errors in PHP used to throw generic exceptions with no way to tell “malformed string” from “invalid timezone” without parsing the message yourself. 8.3 adds a proper hierarchy:
try {
new DateTimeImmutable('not a date');
} catch (DateMalformedStringException $e) {
// specifically a parsing failure
} catch (DateException $e) {
// other date-related errors
}
The full tree: DateError (engine-level), DateException (base), with specific subclasses for invalid timezone, malformed interval string, malformed period string, and malformed date string.
gc_status() tells you more
gc_status() now returns eight additional fields: running, protected, full, buffer_size, and timing breakdowns (application_time, collector_time, destructor_time, free_time). If you’re profiling memory pressure or GC pauses, this data was previously unavailable without pulling in an extension.
strrchr() grows a direction argument
strrchr() (find the last occurrence of a character, return from there to end) now accepts a $before_needle boolean, matching the API of strstr():
$path = '/var/www/html/index.php';
echo strrchr($path, '/', before_needle: true); // /var/www/html
echo strrchr($path, '/'); // /index.php
A function that’s been in PHP since 1994, finally consistent with its sibling.
Deprecations worth noting
get_class() and get_parent_class() without arguments now emit deprecation notices. The argumentless forms relied on implicit $this context, which was easy to misread. Pass the object explicitly.
assert_options() and the ASSERT_* constants are deprecated in favor of the zend.assertions INI directive, which is the right tool for controlling assertion behavior across environments.
The ++/-- operators on empty strings and non-numeric non-alphanumeric strings now emit deprecation warnings. The behavior was undefined territory. 8.3 starts the migration toward defined behavior in 9.0.
Stack overflow protection
Two new INI directives: zend.max_allowed_stack_size sets a hard limit on PHP’s stack depth, and zend.reserved_stack_size sets aside a buffer for cleanup after a limit is hit. Before 8.3, deeply recursive code could just crash at the OS level. Now PHP catches it and throws an Error with a useful message.