PHP 8.3: New Features and Breaking Changes
Over the past several years, PHP has undergone a remarkable transformation. What began as a templating language for personal home pages has evolved into a mature, type-aware platform powering a significant portion of the web. With each point release—PHP 7.0, 7.1, 7.2, 7.3, 7.4, 8.0, 8.1, 8.2—the language has systematically addressed its historical pain points: inconsistent APIs, weak typing, and performance bottlenecks.
PHP 8.3 continues this trajectory. It doesn’t introduce revolutionary paradigms; rather, it refines the language based on years of developer feedback. In this article, we’ll examine the most consequential changes: typed class constants, the #[Override] attribute, json_validate(), and dynamic class constant fetch. We’ll also cover the breaking changes that could affect your existing code.
If you maintain a PHP codebase that’s running on PHP 8.2 or earlier, you’ll want to understand both the opportunities and the risks before upgrading. We’ll focus on practical application: not just what these features are, but when and why you’d use them, and what problems they solve.
Typed Class Constants
Let’s start with a concrete problem. Suppose you’re building a configuration system for a framework. You define an interface with constants that implementers must match:
interface Config {
const string DRIVER = 'mysql';
}
A developer implementing this interface might accidentally write:
class MyConfig implements Config {
// This was allowed but would fail at runtime
const DRIVER = ['mysql'];
}
In PHP 8.2 and earlier, this code would run without errors until you actually tried to use MyConfig::DRIVER expecting a string. You’d get a type mismatch—possibly in production, possibly far from the source of the problem.
PHP 8.3 allows you to enforce types on class constants. The interface declares const string DRIVER; the implementing class must also declare const string DRIVER. A mismatch now triggers a compilation error:
class MyConfig implements Config {
// Fatal error: Wrong type for constant
const DRIVER = ['mysql'];
}
Strictly speaking, typed class constants were technically possible in earlier versions for public constants, but the enforcement was inconsistent. PHP 8.3 makes this behavior uniform across all visibility levels. For library authors, this is particularly valuable: you can define contracts that implementers must follow, catching errors before code reaches production.
A word of caution: Typed constants are still limited to scalar types, arrays, and null. You cannot yet type a constant as an object instance (except for static return types in methods, which is a separate feature). Also, note that adding types to existing constants is a breaking change if your code or downstream packages override those constants with different types.
The #[Override] Attribute
Refactoring object-oriented code is a routine but error-prone task. Imagine you have a base class with a method you want to override in a child class:
abstract class PaymentGateway {
public function process(float $amount): void {
// validation logic
}
}
You create a subclass:
class StripeGateway extends PaymentGateway {
public function proccess(float $amount): void { // typo: "proccess"
// implementation
}
}
You’ve introduced a subtle bug: the method name is misspelled. Instead of overriding process(), you’ve created a new method proccess(). The parent’s method remains in effect, silently ignored. This kind of error can be maddening to debug, especially in large codebases.
PHP 8.3 introduces the #[Override] attribute to catch this. When you annotate a method with #[Override], the engine verifies that a method with the same name exists in a parent class or implemented interface. If it doesn’t, you get a compile-time error:
class StripeGateway extends PaymentGateway {
#[Override]
public function proccess(float $amount): void { // Fatal error
// No matching parent method
}
}
This feature mirrors similar annotations in Java and C#. Of course, you’re not required to use #[Override] on every overriding method—it’s opt-in. But where you do use it, you get an extra safety net. The attribute also helps document intent: readers instantly know this method is meant to override something, which is useful in large inheritance hierarchies.
One limitation to note: #[Override] only checks for methods with exactly the same name. It does not verify that your method’s signature (parameter types, return type, default values) is compatible with the parent. PHP’s existing signature compatibility rules still apply separately. Also, the attribute is ignored on final or private methods since they cannot override anything anyway.
json_validate()
Validating JSON strings appears straightforward: decode the string, check for errors. But this approach has two hidden costs:
- Memory usage:
json_decode()builds the entire data structure in memory—potentially megabytes of arrays and objects—only to validate that the syntax is correct. - Error handling: You must remember to call
json_last_error()immediately afterjson_decode(), before any other JSON operation modifies the error state.
Here’s what you might have written in PHP 8.2:
function isValidJson(string $json): bool {
json_decode($json);
return json_last_error() === JSON_ERROR_NONE;
}
$isValid = isValidJson('{"user": "test"}'); // true
$isInvalid = isValidJson('{"user": "test"'); // false
This works, but for large JSON payloads, the memory cost can be significant. If you’re processing many JSON strings in a batch job, that overhead adds up.
PHP 8.3 introduces json_validate() as a dedicated, memory-efficient validator:
$isValid = json_validate('{"user": "test"}'); // true
$isInvalid = json_validate('{"user": "test"'); // false
The function parses just enough of the string to confirm validity without constructing the full data structure. For a 10MB JSON file, this can mean the difference between allocating 10MB of temporary data structures and allocating essentially nothing.
Important caveats:
json_validate()returnstrueonly for syntactically valid JSON. It does not validate against a schema. You still need a JSON Schema validator for structural validation.- The function accepts the same depth and flags parameters as
json_decode(), though the defaults match strict JSON syntax. - Because
json_validate()does not build the data structure, it cannot return the parsed content. If you need both validation and the parsed data, you’ll still need to calljson_decode()(though you could skip calling it if validation fails).
In practice, you might use json_validate() as a preflight check before accepting payloads from untrusted sources, or as a guard in input validation pipelines where you want to reject invalid JSON before attempting to deserialize it.
Dynamic Class Constant Fetch
PHP 8.3 introduces a more flexible syntax for accessing class constants when the constant name is determined at runtime:
class Status {
const PENDING = 'pending';
const COMPLETED = 'completed';
}
$statusName = 'PENDING';
// PHP 8.3 syntax
$statusValue = Status::{$statusName}; // 'pending'
Previously, you had to use constant():
$statusValue = constant("Status::$statusName");
The new syntax is cleaner and more consistent with variable variables. It’s particularly useful when you’re mapping string identifiers to constant values—for instance, when processing form inputs or API parameters that correspond to enum-like classes.
A few things to keep in mind:
- The constant name is case-sensitive by default, as with all class constant accesses.
Status::{$statusName}will fail if$statusNameis'pending'instead of'PENDING'. - Accessing a non-existent constant throws a
Errorexception. You may want to checkdefined()first if the constant might not exist:
$constantName = 'UNKNOWN';
if (defined("Status::$constantName")) {
$value = Status::{$constantName};
} else {
// handle missing constant
}
- The dynamic fetch syntax supports all constant visibilities (public, protected, private) with the usual access rules. If you try to access a private constant from outside the class, you’ll get an error just like with static constant access.
This feature doesn’t change what you can do—it just makes the syntax more ergonomic for dynamic cases. If you’re already using constant(), your code will continue to work.
Deep Cloning of readonly Properties
PHP 8.2 introduced readonly properties to enforce immutability after construction:
class User {
public readonly string $name;
public readonly Address $address;
public function __construct(string $name, Address $address) {
$this->name = $name;
$this->address = $address;
}
}
This works well until you need to clone the object. The __clone() method cannot modify readonly properties, even to create a proper deep copy:
public function __clone() {
// Fatal error: Cannot modify readonly property
$this->address = clone $this->address;
}
The result is a shallow clone: both the original and the clone share the same Address object. Mutating $clone->address->city would also affect $original->address->city. That defeats the purpose of cloning.
PHP 8.3 makes a targeted exception: readonly properties may be modified within __clone(). This enables deep cloning while preserving readonly semantics afterward:
class User {
public readonly string $name;
public readonly Address $address;
public function __construct(string $name, Address $address) {
$this->name = $name;
$this->address = $address;
}
public function __clone() {
$this->address = clone $this->address;
}
}
$address = new Address('New York');
$user1 = new User('John Doe', $address);
$user2 = clone $user1;
$user2->address->city = 'San Francisco';
echo $user1->address->city; // 'New York' - unchanged
echo $user2->address->city; // 'San Francisco'
What’s important here: The exception applies only to __clone(). Outside that method, readonly properties remain immutable. This is a narrow, purposeful allowance—not a general relaxation of readonly semantics.
Also note that the property must be readonly at the point of declaration. Adding readonly later (e.g., via a trait) doesn’t change the analysis. If you have complex objects with nested readonly properties, you’ll need to ensure each level clones properly in __clone().
Breaking Changes in PHP 8.3
Now let’s discuss changes that could break existing code. For most applications, the impact is minor—but you should verify compatibility before upgrading.
unserialize() Error Handling
unserialize() historically emitted a notice (E_NOTICE) for some malformed inputs and a warning (E_WARNING) for others. PHP 8.3 standardizes on always emitting E_WARNING when unserialization fails.
What does this mean for you? If your code currently suppresses notices but not warnings (e.g., with error_reporting(E_ALL & ~E_NOTICE)), failed unserialization that previously went unnoticed will now produce warnings. If you have code that relies on unserialize() returning false silently, you should add explicit error handling:
$result = @unserialize($data); // @ suppresses the warning
if ($result === false && $data !== 'b:0;') {
// handle invalid serialized data
}
The @ operator suppresses the warning, but using it carries performance costs. A better approach is to validate the string beforehand or catch any exception if you’re using the allowed_classes option with exceptions enabled.
range() Function Changes
The range() function generates an array of values between a start and end. PHP 8.3 tightens the behavior for certain edge cases: specifically, when you provide a negative step that makes the sequence logically impossible.
For example:
// PHP 8.2: returns an array with just the start value
$result = range(5, 10, -1);
// PHP 8.3: returns an empty array
$result = range(5, 10, -1);
If your code assumes range() always returns at least the start value, this change could affect you. The rule is: if the step direction would not advance from start toward end, the result is empty. Review any range() calls with computed step values to ensure they handle the empty-array case.
Date/Time Exception Hierarchy
Several date/time-related exceptions in PHP have been reorganized. Previously, many date/time errors threw the generic Exception class. PHP 8.3 introduces a new base class Date\Exception and makes specific exceptions (e.g., Date\InvalidArgumentException, Date\RuntimeException) extend it.
For most users, this is transparent. However, if you catch Exception specifically (rather than catching broader exceptions or using Throwable), you’re still covered. The change matters only if you have catch blocks that check the exact exception class and you’re using internal date/time functions that might throw these specific exceptions. In that case, you may need to update your catch clauses to include the new exception classes—or, better yet, catch Date\Exception to handle all date-related errors uniformly.
Practical Upgrade Considerations
Before upgrading a production system to PHP 8.3, we recommend the following:
- Run your test suite against PHP 8.3 in a development or staging environment. The breaking changes are minor, but they could surface in edge cases your tests don’t cover.
- Check your Composer dependencies. Most popular PHP packages support PHP 8.3, but some older or less-maintained packages may not. Use
composer check-platform-reqsto verify compatibility. - Review serialized data stored in databases or caches. If you have legacy serialized strings that were marginally valid, they might now trigger warnings upon unserialization.
- Be cautious with
range()in loops or boundary calculations. If you compute step values dynamically, add guards to handle the empty-array result.
Of course, PHP 8.3 also includes performance improvements and internal optimizations that benefit all code, not just code using the new features. The json_validate() function alone can reduce memory pressure in applications that process many JSON payloads.
Conclusion
PHP 8.3 is a pragmatic release. It builds on the foundation laid by PHP 8.0 (union types, attributes, JIT), PHP 8.1 (enums, readonly properties), and PHP 8.2 (disjunctive normal form types, readonly classes). The new features address specific friction points developers have identified: type safety in interfaces, refactoring safety, JSON validation efficiency, and cloning readonly objects.
The language continues its march toward greater correctness and developer ergonomics—without sacrificing backward compatibility for the vast majority of applications. If you’re already on PHP 8.2, upgrading to 8.3 should be straightforward. The changes are incremental, not disruptive.
What challenges have you faced with PHP’s type system or JSON handling? Which of these features will you adopt first? Let us know in the comments.
Sponsored by Durable Programming
Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.
Hire Durable Programming