PHP 8.2 → 8.312 language changes15 min readSecurity fixes only

PHP 8.3 language changes since PHP 8.2

A migration-focused tour of the PHP 8.3 language changes: typed constants, #[\Override], readonly amendments, new deprecations and a few silent behavior shifts.

PHP 8.3 is mostly an additive release for the language. Most of the syntax it adds can be adopted one file at a time, and almost none of it breaks code that ran on PHP 8.2. The exceptions are a handful of semantic changes that alter behavior without any error, so those deserve your attention first during an upgrade.

The silent changes are the ones to test for. Assigning a negative index to an empty array now continues from that index plus one instead of falling back to zero. Classes that use a trait declaring static properties now get their own storage even when a parent class already used the same trait. Neither produces a warning, so a test suite that touches array keys or static counters is your safety net.

The deprecations are noisy rather than dangerous. Incrementing or decrementing empty or non-alphanumeric strings now emits E_DEPRECATED, and incrementing other non-numeric strings is soft-deprecated, meaning no diagnostic yet but you should not write new code that relies on it. Calling get_class() or get_parent_class() with no arguments is also deprecated. Both are easy to fix mechanically with self::class, static::class or parent::class, and you can find them with a search before you upgrade.

The new capabilities are where PHP 8.3 pays off. Typed class constants close a long-standing gap in the type system. The #[\Override] attribute turns a misspelled or removed parent method into a compile-time error. Readonly properties can be reinitialized inside __clone(), which makes deep-clone patterns possible, and anonymous classes can be declared readonly. Smaller additions include dynamic class constant fetch with C::{$name}, arbitrary expressions in static variable initializers, named arguments for closures built from magic methods, and the final modifier on imported trait methods.

A sensible upgrade path is to run your tests on PHP 8.3 with error_reporting set to E_ALL so deprecations surface, fix the string increment and get_class() cases, review static properties in traits, and only then start adopting the new syntax. Remember that code using typed constants, readonly anonymous classes, dynamic constant fetch or as final will no longer parse on PHP 8.2, so adopt those only once every environment is on 8.3 or later.

CHANGED BEHAVIOR

Negative index on an empty array continues upward

After assigning a negative index to an empty array, the next appended key is that index plus one, not zero.

In PHP 8.2, if you assigned a negative key such as -5 to an empty array and then appended with [], the new element received key 0. PHP 8.3 changes this so the next key is $n + 1, which here is -4. This makes negative starting keys behave like positive ones, where appending always continues after the highest integer key.

The danger is that nothing warns you. Code that builds arrays from negative offsets, for example sparse timelines or coordinate-based buckets, and later relies on the appended element living at key 0 will quietly produce different keys. Serialized output, array_keys() results and lookups by key can all shift.

The rule only applies when the array was empty before the negative assignment. Search for places that start an array with a negative key and then append, and write the key explicitly if you need a specific value. The example shows the new keys directly.

Before: PHP 8.2 result for the same code
<?php
$a = [];
$a[-5] = 'first';
$a[] = 'second';
// PHP 8.2 keys: -5 and 0
// PHP 8.3 keys: -5 and -4

PHP 8.2 appended the second element at key 0; PHP 8.3 and later use -4. Shown for comparison; not executed on this site.

After: Append continues after a negative key
<?php
$a = [];
$a[-5] = 'first';
$a[] = 'second';
echo json_encode(array_keys($a)), "\n";
Output on PHP 8.5.10
[-5,-4]

Official migration notes

CHANGED BEHAVIOR

Traits now redeclare inherited static properties

A class that uses a trait with static properties gets separate static storage, even when its parent class used the same trait.

Before PHP 8.3, if a parent class used a trait with a static property and a child class also used that trait, the child could end up sharing the parent's static storage. PHP 8.3 treats the trait use as if you had written the static property directly in the child class. The child therefore gets its own separate property with its own initial value.

This matters for counters, registries and caches kept in static properties through a trait. Code that relied on a child updating the same value as its parent will now see two independent values, and nothing raises an error. The reverse also holds: code that was surprised by shared state may now behave as originally expected.

Audit traits that declare static properties and are used at more than one level of a class hierarchy. If you do want shared state, declare the static property on the parent class only, or access it through the parent class name explicitly. The example shows the parent holding 5 while the child starts its own counter.

Before: Trait with a static property used at two levels
<?php
trait Counts { public static int $n = 0; }
class ParentModel { use Counts; }
class ChildModel extends ParentModel { use Counts; }
ParentModel::$n = 5;
ChildModel::$n++;
// PHP 8.2: both read 6 (shared)
// PHP 8.3: 5 and 1 (separate)

PHP 8.2 shared one storage slot between parent and child; PHP 8.3 and later separate them. Shown for comparison; not executed on this site.

After: Parent and child keep separate static values
<?php
trait Counts { public static int $n = 0; }
class ParentModel { use Counts; }
class ChildModel extends ParentModel { use Counts; }
ParentModel::$n = 5;
ChildModel::$n++;
echo ParentModel::$n, ' ', ChildModel::$n, "\n";
Output on PHP 8.5.10
5 1

Official migration notes

DEPRECATED

Increment and decrement on odd strings are deprecated

Using ++ on empty or non-alphanumeric strings and -- on empty or non-numeric strings now emits a deprecation.

PHP 8.3 starts cleaning up the quirky string behavior of ++ and --. Incrementing an empty string or a non-alphanumeric string now emits E_DEPRECATED, and decrementing an empty or non-numeric string does the same. Incrementing other non-numeric strings, the Perl-style 'a' to 'b' behavior, is only soft-deprecated: no diagnostic yet, but you should not write new code that depends on it. The manual points to str_increment() as the explicit replacement for that alphanumeric case.

The common real-world trigger is a counter initialized as an empty string, for example $count = '' followed by $count++. That used to produce the string '1' without complaint. The fix is to initialize numeric counters as integers.

The code still works on PHP 8.3, so the practical risk is log noise or a failing test suite that turns deprecations into exceptions. Search for increments on variables that may hold strings. The example uses an integer counter, which is correct on every version.

Before: Counter initialized as an empty string
<?php
$hits = '';
$hits++;
echo $hits, "\n";

Works silently on PHP 8.2; emits a deprecation on PHP 8.3 and later. Shown for comparison; not executed on this site.

After: Integer counter instead of a string one
<?php
$hits = 0;
foreach (['a', 'b', 'c'] as $_) {
    $hits++;
}
echo $hits, "\n";
Output on PHP 8.5.10
3

Official migration notes

DEPRECATED

get_class() without arguments is deprecated

Calling get_class() or get_parent_class() with no arguments is deprecated; use self::class, static::class or parent::class instead.

The argument-less forms of get_class() and get_parent_class() relied on implicit context: they returned the class where the code was written, or its parent. PHP 8.3 deprecates both forms because the same information is available directly through language syntax.

The replacements are explicit about which class you mean. Use self::class for the class where the code is declared, which is what get_class() with no arguments returned. Use static::class when you want the runtime class of the object, and parent::class for the parent name. Choosing between self and static is the main pitfall, because they differ in subclasses, and the old call always matched self. Check each call site for which behavior the surrounding code actually depends on.

The old calls still run on PHP 8.3 but emit E_DEPRECATED, so they are easy to find in logs after upgrading. The example prints both forms side by side for a subclass.

Before: Implicit class lookup
<?php
class Model {
    public function kind(): string { return get_class(); }
}

Accepted on PHP 8.2; deprecated on PHP 8.3 and later. Shown for comparison; not executed on this site.

After: Explicit self::class and static::class
<?php
class Model {
    public function kind(): string {
        return static::class . '/' . self::class;
    }
}
class User extends Model {}
echo (new User)->kind(), "\n";
Output on PHP 8.5.10
User/Model

Official migration notes

NEW

Class constants can declare types

Class, interface, trait and enum constants now accept a type declaration such as const string NAME.

Until PHP 8.3, constants were the one part of a class without a type declaration. You can now write public const string LABEL = 'x' in classes, interfaces, traits and enums. The engine checks that the value matches the declared type, and a child class that overrides the constant must keep a compatible type.

This makes interface contracts stronger. An interface can state that a constant is an int, and every implementing class is held to it instead of just relying on documentation. It also helps static analysis tools and readers.

There are pitfalls. Code using this syntax does not parse on PHP 8.2, so adopt it only after every environment runs 8.3 or later. A typed constant cannot be declared static or with a type like void, and an override with an incompatible type is a fatal error at compile time. Also note that extension classes may declare constant types too, which can affect subclasses.

Before: Untyped constants
<?php
class Invoice {
    public const LABEL = 'invoice';
    public const RETRIES = 3;
}

This untyped form works on all PHP 8 versions; the typed form needs PHP 8.3 or later. Shown for comparison; not executed on this site.

After: Typed constants with an interface contract
<?php
interface HasLabel { public const string LABEL = 'base'; }
final class Invoice implements HasLabel {
    public const string LABEL = 'invoice';
    public const int RETRIES = 3;
    public const array STATES = ['draft', 'sent'];
}
echo Invoice::LABEL, ' ', Invoice::RETRIES, ' ';
echo implode(',', Invoice::STATES), "\n";
Output on PHP 8.5.10
invoice 3 draft,sent

Official migration notes

NEW

#[\Override] checks that a method really overrides

The #[\Override] attribute makes PHP raise an error if the method has no matching parent class or interface method.

Renaming or removing a parent method used to leave a child's override silently orphaned: the child method simply stopped being called by the parent. PHP 8.3 adds the #[\Override] attribute to catch this. Put it on a method that is intended to override a parent method or implement an interface method, and PHP produces a fatal error at compile time if no such method exists.

The attribute is useful in refactoring, because a typo in a method name or an upstream library removing a method is reported when the class is loaded rather than discovered later through changed behavior.

Keep a few points in mind. The check is existence only; it does not compare signatures beyond the normal inheritance rules. The attribute can be used on methods but is not a substitute for tests. Because attributes are ignored by older engines, the syntax is harmless on PHP 8.2, but you only get the safety check on 8.3 or later. Constructors are exempt from the requirement.

Before: Override without a safety check
<?php
class Base { public function name(): string { return 'base'; } }
class Child extends Base {
    public function name(): string { return 'child'; }
}

Valid on PHP 8.2 and later, but a misspelled method name would go unnoticed. Shown for comparison; not executed on this site.

After: Marked override
<?php
class Base { public function name(): string { return 'base'; } }
class Child extends Base {
    #[\Override]
    public function name(): string { return 'child'; }
}
echo (new Child)->name(), "\n";
Output on PHP 8.5.10
child

Official migration notes

NEW

Readonly properties can be reinitialized while cloning

Inside __clone(), readonly properties may now be reassigned, which makes deep-copy patterns work with readonly state.

Readonly properties in PHP 8.2 could not be modified after initialization, which made __clone() awkward: you could not deep-copy a readonly property that held an array or object. PHP 8.3 relaxes the rule so that readonly properties can be reinitialized during cloning, within the __clone() method.

This lets immutable value objects clone nested state, for example giving the copy its own array, a fresh collection or a cloned child object, without dropping readonly. The example appends an entry to a log in the clone and leaves the original untouched.

The permission is narrow. Only code running inside __clone() may reassign the property, so a regular with method that clones and then assigns still fails with an Error. The property must also be assigned from the class scope that declared it. Code relying on this syntax behavior only works on PHP 8.3 or later, while PHP 8.2 throws when you try to modify the property in __clone().

Before: Modifying a readonly property in __clone
<?php
final class Doc {
    public function __construct(public readonly array $log = []) {}
    public function __clone() { $this->log = [...$this->log, 'cloned']; }
}

PHP 8.2 throws an Error when the clone is made; PHP 8.3 and later allow it. Shown for comparison; not executed on this site.

After: Readonly property updated inside __clone
<?php
final class Doc {
    public function __construct(public readonly array $log = []) {}
    public function __clone() {
        $this->log = [...$this->log, 'cloned'];
    }
}
$a = new Doc(['created']);
$b = clone $a;
echo implode(',', $a->log), '|', implode(',', $b->log), "\n";
Output on PHP 8.5.10
created|created,cloned

Official migration notes

NEW

Anonymous classes can be readonly

You can write new readonly class { ... } so every property of an anonymous class is readonly.

PHP 8.2 introduced readonly classes, but anonymous classes could not use the modifier. In PHP 8.3 you can write new readonly class(...) { ... }, and all declared properties become readonly, which is convenient for small inline value objects, test doubles and data carriers.

The usual readonly rules still apply. Every property needs a type, static properties are not allowed, and assigning to a property after construction throws an Error. Promoting constructor parameters keeps the declaration short, as in the example.

The main pitfall is compatibility: the syntax is a parse error on PHP 8.2, so you cannot ship it to mixed environments. When you print messages from errors on anonymous classes, remember that the generated class name contains extra characters, so avoid asserting on the full message in tests. The example only reports that the write was blocked.

Before: Readonly on each property instead
<?php
$p = new class(3, 4) {
    public function __construct(
        public readonly int $x,
        public readonly int $y,
    ) {}
};

This property-level form works on PHP 8.1 and later; new readonly class needs PHP 8.3. Shown for comparison; not executed on this site.

After: Readonly anonymous class
<?php
$p = new readonly class(3, 4) {
    public function __construct(public int $x, public int $y) {}
};
try {
    $p->x = 9;
} catch (Error $e) {
    echo "blocked\n";
}
echo $p->x + $p->y, "\n";
Output on PHP 8.5.10
blocked
7

Official migration notes

NEW

Dynamic class constant fetch with C::{$name}

Class constants and enum cases can be read through a variable name using C::{$name}, with no constant() call.

Reading a class constant whose name is only known at runtime used to require constant('Class::NAME'), which builds a string and loses the readability of normal syntax. PHP 8.3 adds C::{$name}, where the braces contain an expression yielding the constant name. It works with the class name, with static, and with an object on the left, and it also reaches enum cases because they are class constants.

This is handy for mapping input such as a status string to an enum case or a configuration key to a constant, without a lookup table.

Be careful with untrusted input. A missing constant throws an Error, so validate or catch it when the name comes from users. Visibility rules still apply, so private constants remain private. The syntax does not parse on PHP 8.2, so keep constant() until all your environments have moved to 8.3 or later.

Before: constant() with a built string
<?php
class Config { public const string MODE = 'strict'; }
$name = 'MODE';
echo constant('Config::' . $name), "\n";

Works on PHP 8.2 and later; the braces syntax needs PHP 8.3. Shown for comparison; not executed on this site.

After: Dynamic constant and enum case fetch
<?php
enum Status: string {
    case Draft = 'draft';
    case Sent = 'sent';
}
class Config { public const string MODE = 'strict'; }
$name = 'MODE';
echo Config::{$name}, "\n";
$case = 'Sent';
$status = Status::{$case};
echo $status->value, "\n";
Output on PHP 8.5.10
strict
sent

Official migration notes

NEW

Static variable initializers accept arbitrary expressions

Static variables inside functions can now be initialized with function calls and other arbitrary expressions.

Static variables previously had to be initialized with constant expressions, so a value computed by a function call needed an awkward null check on every invocation. PHP 8.3 lets the initializer be any expression, including calls to functions, and PHP evaluates it once, the first time execution reaches the declaration.

This removes boilerplate in memoization helpers, lazily built lookup tables and ID generators. The example builds a prefix with strtoupper() and a starting number with intdiv() directly in the declarations.

There are a couple of cautions. The initializer runs at first use rather than at compile time, so side effects and exceptions happen then. A static variable in a method is also separate per class when inherited, as before. Code using non-constant initializers is a compile error on PHP 8.2, so the old guard pattern must stay until you drop support for it.

Before: Lazy initialization with a null guard
<?php
function ticket(): string {
    static $n = null;
    if ($n === null) { $n = 8 + intdiv(7, 2); }
    return 'T-' . $n++;
}

Works on PHP 8.2; PHP 8.3 and later also allow the direct expression form. Shown for comparison; not executed on this site.

After: Function calls in static initializers
<?php
function ticket(): string {
    static $n = 2 ** 3 + intdiv(7, 2);
    static $prefix = strtoupper('t') . '-';
    return $prefix . $n++;
}
echo ticket(), ' ', ticket(), "\n";
Output on PHP 8.5.10
T-11 T-12

Official migration notes

NEW

Closures from magic methods accept named arguments

A closure made from an __call or __callStatic target can now be invoked with named arguments, collected under string keys.

First-class callable syntax such as $obj->method(...) can wrap a method that does not exist but is handled by __call(). In PHP 8.3, the resulting closure accepts named arguments, and the extra names arrive in the magic method's $arguments array as string keys, just like a direct call to the magic method.

This matters for proxies, fluent builders and routers that forward calls and want to preserve the caller's argument names. Before this change, passing named arguments to such a closure failed, so forwarding code had to avoid them.

Watch the shape of the array: positional values have integer keys and named values have string keys, so code that assumes a list needs to handle both. The example encodes the received array as JSON to show the names. Because this relies on engine behavior added in 8.3, keep positional-only calls if you must also run on PHP 8.2.

Before: Positional arguments only
<?php
class Router {
    public function __call(string $name, array $args): string {
        return $name . ':' . json_encode($args);
    }
}
$fn = (new Router)->dispatch(...);
echo $fn('/a', 5), "\n";

Positional calls work on PHP 8.2; named arguments through the closure need PHP 8.3. Shown for comparison; not executed on this site.

After: Named arguments through a magic-method closure
<?php
class Router {
    public function __call(string $name, array $args): string {
        return $name . ':' . json_encode($args, JSON_UNESCAPED_SLASHES);
    }
}
$fn = (new Router)->dispatch(...);
echo $fn(path: '/a', id: 5), "\n";
Output on PHP 8.5.10
dispatch:{"path":"/a","id":5}

Official migration notes

NEW

final can be applied to an imported trait method

A trait method can be imported with the final modifier so subclasses of the using class cannot override it.

A trait's methods become part of the using class, but until PHP 8.3 you could not change their modifiers to final when importing them. The as clause now accepts final, for example use Greets { hi as final; }. The method keeps its name and becomes non-overridable in subclasses of the class that uses the trait.

This is useful when a trait supplies behavior that must stay stable, such as a serialization hook, and you want to stop descendants from replacing it. Previously you had to wrap the logic in a final method of your own.

Subclasses that try to override the method trigger a fatal error at compile time, so you will see problems on load rather than at runtime. The syntax does not parse on PHP 8.2. The example checks the result with ReflectionMethod::isFinal() instead of triggering the fatal error.

Before: Trait method imported without final
<?php
trait Greets { public function hi(): string { return 'hi'; } }
class A { use Greets; }

Works on PHP 8.2 and later, but subclasses may override hi(); 'as final' needs PHP 8.3. Shown for comparison; not executed on this site.

After: Import a trait method as final
<?php
trait Greets { public function hi(): string { return 'hi'; } }
class A {
    use Greets { hi as final; }
}
$m = new ReflectionMethod(A::class, 'hi');
echo $m->isFinal() ? 'final' : 'open', "\n";
echo (new A)->hi(), "\n";
Output on PHP 8.5.10
final
hi

Official migration notes

← PHP 8.2 changes PHP 8.4 changes →