PHP 8.4 is an unusually syntax-heavy release. Most of the headline additions are things you opt into, so existing PHP 8.3 code mostly keeps running. The work in an upgrade is finding the handful of places where old code now emits a deprecation or behaves a little differently.
The biggest new capabilities are in the object model. Property hooks let a property carry its own get and set logic, which can replace many trivial getter and setter methods and makes computed, virtual properties possible. Asymmetric visibility lets you declare a property that is publicly readable but only writable from inside the class or its children. Constructor calls can also be dereferenced directly, so new Foo()->method() no longer needs wrapping parentheses. The #[\Deprecated] attribute lets your own code participate in the same deprecation mechanism PHP uses for itself.
The changes most likely to surface during an upgrade are deprecations. Parameters declared with a non-nullable type and a null default are now flagged, as is raising zero to a negative power, naming a class _, and calling trigger_error() with E_USER_ERROR. None of these stop your code from running on 8.4, but they will fill logs and fail strict test suites that turn deprecations into errors. The E_STRICT constant is deprecated as well because the level itself is gone.
A few behavioral changes deserve a targeted search of your codebase. exit() and die() now follow ordinary type coercion rules, so passing an unsuitable value throws a TypeError rather than being cast to a string. Taking a reference to a readonly property inside __clone() is no longer allowed. Several extension resources have also become objects, so is_resource() checks on those values need to be replaced.
A sensible upgrade path is to run your test suite on 8.4 with error_reporting set to E_ALL and deprecations visible, fix the implicit nullable parameters first because they tend to be the most numerous, and then search for the specific constructs described below. Adopt the new syntax gradually: hooks and asymmetric visibility are good fits for new code, but there is no need to rewrite stable classes just because you can. Remember that any new function, class or constant added by PHP can collide with a symbol you declared yourself, so check for name clashes too.
NEW
Property hooks add get and set logic to properties
Properties can now declare get and set hooks, including virtual properties that have no backing value at all.
PHP 8.4 lets a property declaration carry its own get and set logic. A set hook can transform or validate an incoming value, and a get hook can compute the value on read. A property that has only a get hook and never touches its own backing value is virtual: it stores nothing, and writing to it throws an Error.
This matters because many classes contain boilerplate accessor methods whose only purpose is normalization or validation. Hooks keep that logic next to the property while callers still use plain property syntax, so you can start with a simple public property and add behavior later without changing any call sites.
There are pitfalls. A set hook written in block form must assign to the backing value itself, otherwise nothing is stored. The short arrow form stores the expression result automatically. Hooks also add per-access overhead, so they are not a free replacement for plain properties in hot paths. Finally, the exact error text for writing to a virtual property is part of the engine, so catch Error rather than matching message strings in your own code.
class Person
{
private string $firstName = '';
public function setFirstName(string $v): void
{
$this->firstName = ucfirst(strtolower($v));
}
public function getFirstName(): string
{
return $this->firstName;
}
}Works in PHP 8.3 and 8.4, but needs paired methods; hook syntax is a parse error before PHP 8.4. Shown for comparison; not executed on this site.
class Person
{
public string $firstName { set => ucfirst(strtolower($value)); }
public string $lastName {
set {
if (strlen($value) < 2) {
throw new InvalidArgumentException('Too short');
}
$this->lastName = $value;
}
}
public string $fullName { get => $this->firstName . ' ' . $this->lastName; }
}
$p = new Person();
$p->firstName = 'peter';
$p->lastName = 'Peterson';
echo $p->fullName, "\n";
try { $p->lastName = 'X'; } catch (InvalidArgumentException $e) { echo $e->getMessage(), "\n"; }
try { $p->fullName = 'x'; } catch (Error $e) { echo $e->getMessage(), "\n"; }Peter Peterson Too short Property Person::$fullName is read-only
NEW
Asymmetric property visibility
A property can now have one visibility for reading and a narrower one for writing, such as public read with private write.
With asymmetric visibility you write two modifiers on a property: the first controls who can read it, and the second, in the form private(set) or protected(set), controls who can write it. The read visibility must not be narrower than the write visibility, so public private(set) is valid while the reverse is not.
This gives you a lightweight way to expose state for reading without a getter method, while still keeping mutation inside the class. It is a good alternative to readonly when the object needs to change its own value over time, for example a counter or a status field that the class updates internally.
The main pitfall is that code outside the allowed scope fails at runtime with an Error, not at parse time, so tests must cover external writes you expect to be blocked. Also note that this is distinct from readonly: a private(set) property can be reassigned many times by the class itself. Constructor promotion works with the new modifiers, which keeps value-like classes compact.
class Counter
{
private int $count = 0;
public function count(): int
{
return $this->count;
}
}Works in PHP 8.3 and 8.4, but readers must call a method; the (set) modifier syntax needs PHP 8.4. Shown for comparison; not executed on this site.
class Counter
{
public private(set) int $count = 0;
public function increment(): void
{
$this->count++;
}
}
$c = new Counter();
$c->increment();
$c->increment();
echo $c->count, "\n";
try {
$c->count = 10;
} catch (Error $e) {
echo get_class($e), "\n";
}
echo $c->count, "\n";2 Error 2
NEW
Chaining on new expressions without parentheses
A new expression with constructor arguments can be followed directly by a method call or property access.
Before PHP 8.4, calling a method on a freshly constructed object required wrapping the new expression in parentheses. Now a new expression that includes its constructor arguments is dereferencable, so you can chain a method call, property access and similar operations straight after it.
This is mostly a readability improvement. It is handy for small value objects, builders and one-off calls where creating a variable adds noise. It does not change how objects are constructed or how long they live.
The pitfall is that the old parenthesized form remains valid, so there is no need for a mass rewrite, and a codebase can safely mix both styles. However, code using the unparenthesized form will not parse on PHP 8.3, so avoid it in libraries that still support older versions. Also keep chains short: constructing and immediately calling a method is easy to read, but long chains after new can hide the object being created.
class Greeter
{
public function __construct(private string $name) {}
public function greet(): string { return "Hello, {$this->name}"; }
}
echo (new Greeter('Ada'))->greet(), "\n";Required in PHP 8.3; still accepted in PHP 8.4 and later. Shown for comparison; not executed on this site.
class Greeter
{
public function __construct(private string $name) {}
public function greet(): string { return "Hello, {$this->name}"; }
}
echo new Greeter('Ada')->greet(), "\n";Hello, Ada
DEPRECATED
Implicitly nullable parameter types are deprecated
A typed parameter with a null default no longer silently accepts null; declare the type as nullable explicitly.
Historically, writing a parameter such as string $text = null quietly widened the type so that it also accepted null. PHP 8.4 deprecates this implicit widening. The fix is to say what you mean: use ?string $text = null or string|null $text = null.
This is probably the deprecation you will see most often when upgrading, because the old pattern is very common in older code, especially in frameworks and libraries written before union types existed. The code still runs on 8.4, but each affected declaration produces a deprecation notice at compile time.
A related case is an optional parameter followed by a required one. If a parameter with a null default sits before a mandatory parameter, the nullable type must be written explicitly and the useless default should be removed. Automated tools can rewrite most occurrences safely, but review the results: the explicit form is also valid on PHP 7.1 and later, so the change is backward compatible and can be applied before you upgrade.
function label(string $text = null): string
{
return $text ?? 'none';
}Accepted silently in PHP 8.3; emits a deprecation in PHP 8.4 and later. Shown for comparison; not executed on this site.
function label(?string $text = null): string
{
return $text ?? 'none';
}
echo label(), "\n";
echo label('custom'), "\n";none custom
NEW
The #[\Deprecated] attribute for your own code
Functions, methods and class constants can be marked deprecated, producing an E_USER_DEPRECATED diagnostic when used.
PHP 8.4 adds a Deprecated attribute that marks functions, methods and class constants as deprecated. Using the marked item triggers a deprecation just like PHP's own deprecations do, except the error level is E_USER_DEPRECATED instead of E_DEPRECATED. PHP also moved its own deprecations onto this attribute, which lets their messages include a short explanation.
This is useful for library authors who previously had to call trigger_error() by hand inside the old function. The attribute keeps the deprecation declarative, visible to tooling, and consistent with the engine's behavior.
The main pitfall is that the notice is emitted when the item is used, so test suites that convert deprecations into exceptions will start failing for every caller that has not migrated yet. Because the level is E_USER_DEPRECATED, handlers and filters that only look at E_DEPRECATED will miss it. Provide a message that names the replacement so callers know what to do. The example installs an error handler so the diagnostic can be observed without relying on message wording.
function oldApi(): int
{
trigger_error('oldApi() is deprecated', E_USER_DEPRECATED);
return 1;
}Works in PHP 8.3 and 8.4; the attribute form requires PHP 8.4. Shown for comparison; not executed on this site.
set_error_handler(function (int $no, string $str): bool {
echo $no === E_USER_DEPRECATED ? "user deprecation\n" : "other\n";
return true;
});
#[\Deprecated('use newApi() instead')]
function oldApi(): int
{
return 1;
}
echo oldApi(), "\n";user deprecation 1
CHANGED BEHAVIOR
exit() and die() now follow function-style typing
exit() and die() now apply normal type coercion, so invalid argument types throw a TypeError instead of being cast to a string.
The exit() and die() language constructs now behave more like functions. They can be passed around like callables, they respect declare(strict_types=1), and they perform the usual type coercions instead of casting every non-integer value to a string.
The practical effect is that passing an unsuitable value, such as an array, now consistently throws a TypeError. Previously the value was quietly converted to a string, which could hide mistakes in shutdown and error-handling paths.
The pitfall is that a TypeError thrown from within exit() is an ordinary exception: it can be caught, and if it is not, it becomes a fatal error with a different exit path than you intended. Search for dynamic calls such as exit($status) where the variable might hold something other than an integer or string, and in strict-types files make sure integer statuses are really integers. Passing an integer exit code or a message string continues to work as before.
$status = [];
try {
exit($status);
} catch (TypeError $e) {
echo "TypeError caught\n";
}
echo "still running\n";TypeError caught still running
CHANGED BEHAVIOR
Indirect modification of readonly properties in __clone() is forbidden
Taking a reference to a readonly property inside __clone() is no longer allowed; assign to the property directly instead.
PHP 8.3 allowed readonly properties to be reinitialized inside __clone(). PHP 8.4 closes an oversight in that feature: indirect modification there, for example creating a reference with $ref = &$this->items, is no longer permitted. This was already forbidden for readonly initialization in general.
The safe pattern is a direct assignment to the property from within __clone(), building the new value from the old one. That still works and keeps the readonly guarantee for everything outside the clone operation.
The pitfall is that code relying on references, or on functions that take arguments by reference, such as sorting a readonly array property in place, will now fail with an Error at runtime in the clone hook. Compute a new array in a local variable and assign it. Because the failure only appears when an object is actually cloned, make sure your tests exercise clone on classes with readonly properties before upgrading.
final class Basket
{
public function __construct(public readonly array $items) {}
public function __clone()
{
$ref = &$this->items;
$ref[] = 'copy';
}
}Accepted in PHP 8.3; throws an Error in PHP 8.4 and later when cloned. Shown for comparison; not executed on this site.
final class Basket
{
public function __construct(public readonly array $items) {}
public function __clone()
{
$this->items = [...$this->items, 'copy'];
}
}
$a = new Basket(['a']);
$b = clone $a;
echo json_encode($a->items), "\n";
echo json_encode($b->items), "\n";["a"] ["a","copy"]
DEPRECATED
Raising zero to a negative power is deprecated
0 ** -n and pow(0, -n) are deprecated because they amount to dividing by zero; use explicit handling or fpow().
Raising a number to a negative power means taking the reciprocal of the number raised to the positive power. For zero that is a division by zero, so PHP 8.4 deprecates both the ** operator and pow() when the base is zero and the exponent is negative.
The code still produces the same result on 8.4, but it now emits a deprecation, so it is worth fixing before the behavior changes. If you want IEEE 754 semantics, where the result is infinity, the new fpow() function provides them without a diagnostic. If zero is a genuine invalid input in your domain, check for it explicitly and throw or return a sentinel.
The pitfall is that the base is often a variable, so the problem only shows up with particular data at runtime. Search for calculations such as inverse powers, scaling factors and unit conversions where a zero base can occur. Calls with nonzero bases, including negative exponents like 2 ** -2, are unaffected.
$result = 0 ** -2;
var_dump($result);Silent in PHP 8.3; emits a deprecation in PHP 8.4 and later. Shown for comparison; not executed on this site.
$inf = fpow(0, -2);
echo is_infinite($inf) ? "INF" : "finite", "\n";
echo 2 ** -2, "\n";INF 0.25
DEPRECATED
trigger_error() with E_USER_ERROR is deprecated
Calling trigger_error() with E_USER_ERROR is deprecated; throw an exception or call exit() instead.
Using trigger_error() with the E_USER_ERROR level to signal a fatal condition is deprecated in PHP 8.4. The recommended replacements are throwing an exception, which lets callers handle the failure, or calling exit() when the script genuinely has to stop.
This matters because the old pattern gives you a fatal error that normal try/catch code cannot intercept, which makes libraries hard to test and hard to integrate. An exception carries a type and a message, and can be handled at whatever layer knows how.
The pitfall is that replacing the call changes control flow: code that previously ended the request now propagates an exception, so make sure something sensible catches it or that an uncaught exception is an acceptable outcome. Other levels such as E_USER_WARNING, E_USER_NOTICE and E_USER_DEPRECATED are not affected by this deprecation. Choose a specific exception class so callers can distinguish your failure from unrelated ones.
function fail(string $message): never
{
trigger_error($message, E_USER_ERROR);
}Accepted in PHP 8.3; emits a deprecation in PHP 8.4 and later. Shown for comparison; not executed on this site.
function fail(string $message): never
{
throw new RuntimeException($message);
}
try {
fail('configuration missing');
} catch (RuntimeException $e) {
echo 'Handled: ', $e->getMessage(), "\n";
}Handled: configuration missing
DEPRECATED
Naming a class _ is deprecated
A class named exactly _ now triggers a deprecation, while longer names that begin with an underscore remain fine.
Declaring a class whose entire name is a single underscore is deprecated in PHP 8.4. Names that merely start with an underscore, such as _MyClass, are not affected.
This is a narrow change, and most codebases will never hit it. It tends to appear in generated code, translation helper stubs or experiments where _ was used as a short alias. The fix is to rename the class to something descriptive and update references.
The pitfall is that a class named _ may be referenced from many places through autoloading or string class names, so a plain text search for the declaration is not enough. Search for usages in strings, configuration and templates as well. Since the deprecation is raised when the class is declared, it will be visible as soon as the file is loaded, which makes it easy to spot in a test run on PHP 8.4. If you must keep a short name, prefer one with at least one additional character.
class _ {}Accepted in PHP 8.3; emits a deprecation in PHP 8.4 and later. Shown for comparison; not executed on this site.
class _MyClass {}
echo _MyClass::class, "\n";_MyClass