PHP 8.0 → 8.114 language changes15 min readEnd of life

PHP 8.1 language changes

A migration-focused tour of the PHP 8.1 language changes: enums, readonly properties, new type features, stricter semantics and new deprecations compared with PHP 8.0.

PHP 8.1 is mostly an additive release at the language level, but it also tightens a few behaviors that can surface during an upgrade from PHP 8.0. This overview is based on the official migration guide pages for new features, incompatible changes and deprecations. It has not been human reviewed.

The headline additions are enumerations, readonly properties, first-class callable syntax, new in initializers, the never return type, intersection types and Fibers. Smaller syntax additions include the explicit 0o octal prefix, array unpacking with string keys, named arguments after an argument unpack, and the final modifier for class constants. Most of these are opt-in: your existing code keeps working until you choose to use them.

The items that can change behavior without any edits on your side deserve attention first. An optional parameter declared before a required one is now always treated as required, even when you call the function with named arguments, so a call that merely produced a deprecation notice in 8.0 now throws ArgumentCountError. A inherited method that uses static variables now shares them with the parent method instead of getting its own copy. $GLOBALS also loses support for writes to the whole array, though element access such as $GLOBALS['name'] is unchanged. Two new reserved words matter too: readonly is now a keyword and never can no longer name a class, interface or trait.

Then come the new deprecations, which are the ones most likely to flood your logs. Passing null to non-nullable parameters of built-in functions, implicit float-to-int conversions that lose precision, and appending to false as if it were an array all emit deprecation diagnostics. Calling static members directly on a trait, returning by reference from a void function, and returning a non-array from __sleep() are deprecated as well, as is implementing Serializable without __serialize() and __unserialize().

Suggested upgrade path: run your test suite on PHP 8.1 with E_ALL and deprecations visible, and fix required-after-optional signatures and static-variable assumptions first because they alter behavior. Then work through the deprecations, which are usually mechanical: add explicit casts, null checks or array initialization. Overriding methods of internal classes may also need compatible return types or the ReturnTypeWillChange attribute. Only after that should you start adopting the new syntax, beginning with enums and readonly properties, which tend to remove the most boilerplate. Library, extension and INI changes are covered separately and are not discussed here.

NEW

Enumerations

PHP 8.1 adds native enums, replacing hand-written class constants with a real type whose cases are singleton objects.

PHP 8.1 adds language support for enumerations. An enum declares a closed set of named cases, and each case is a distinct value that you can use in type declarations and compare with ===. Backed enums attach a scalar value to each case, which is available through the value property, while every case exposes its name. Enums may also contain methods, so logic that used to live in helper functions next to a group of constants can sit beside the cases.

The main migration benefit is type safety: a parameter typed with an enum cannot receive an arbitrary string or integer the way a constant-based API can. Be aware that enum becomes a declaration keyword in this position, so check for conflicts with existing code when upgrading. Enum cases are not plain scalars, so existing code that stores or compares raw values has to read value explicitly.

Before: Class constants as a pseudo-enum (PHP 8.0)
<?php
final class Status {
    public const DRAFT = 'draft';
    public const PUBLISHED = 'published';
}
function label(string $status): string {
    return ucfirst($status);
}
echo label(Status::PUBLISHED), "\n";

Valid in PHP 8.0 and still valid in 8.1 and later, but any string is accepted, so nothing stops invalid values. Shown for comparison; not executed on this site.

After: Backed enum with a method
<?php
enum Status: string {
    case Draft = 'draft';
    case Published = 'published';

    public function label(): string {
        return ucfirst($this->value);
    }
}

$s = Status::Published;
echo $s->name, ' ', $s->value, ' ', $s->label(), "\n";
echo $s === Status::Published ? "same\n" : "different\n";
echo Status::Draft->label(), "\n";
Output on PHP 8.5.10
Published published Published
same
Draft

Official migration notes

NEW

Readonly properties

The readonly modifier lets a property be initialized once and then never modified, removing getter-only boilerplate.

PHP 8.1 introduces the readonly property modifier. A readonly property can be assigned a single time, normally in the constructor, and any later write throws an Error. Combined with constructor property promotion from PHP 8.0, this makes small immutable value objects very short to write.

Two practical points. First, readonly is now a keyword, although it can still be used as a function name, so check any code that used it in other positions. Second, readonly protects the property binding, not the value it points to: if a readonly property holds an object, that object can still be changed through its own methods. Readonly properties must also be typed. For modification you generally create a new instance, the so-called wither pattern shown below, rather than editing the existing one.

Before: Private property plus getter (PHP 8.0)
<?php
final class Point {
    public function __construct(private int $x, private int $y) {}
    public function x(): int { return $this->x; }
    public function y(): int { return $this->y; }
}
$p = new Point(1, 2);
echo $p->x(), ',', $p->y(), "\n";

Works in PHP 8.0 and later; it needs an accessor per property to expose values without allowing writes. Shown for comparison; not executed on this site.

After: Immutable value object with readonly
<?php
final class Point {
    public function __construct(public readonly int $x, public readonly int $y) {}
    public function withX(int $x): self {
        return new self($x, $this->y);
    }
}
$p = new Point(1, 2);
$q = $p->withX(5);
echo $p->x, ',', $p->y, ' ', $q->x, ',', $q->y, "\n";
try {
    $p->x = 9;
} catch (Error $e) {
    echo $e->getMessage(), "\n";
}
Output on PHP 8.5.10
1,2 5,2
Cannot modify readonly property Point::$x

Official migration notes

CHANGED BEHAVIOR

Optional parameter before required is now always required

A parameter with a default that precedes a required one is treated as required, so named-argument calls that skipped it now throw ArgumentCountError.

In PHP 8.0 an optional parameter placed before a required parameter was deprecated, but a call using named arguments could still skip the optional one and succeed. In PHP 8.1 the optional parameter is implicitly treated as required in every case. The call that omitted it now throws ArgumentCountError, exactly as a positional call would. The definition itself still emits a deprecation.

This is one of the few places where code that ran in 8.0 can fail at runtime in 8.1, so search for signatures with defaults ahead of required parameters. The fix is to move the required parameters first, or to remove the default if it never applied. The one documented exception is a parameter with a default of null used to make a type nullable: it is still accepted, but the parameter remains required.

Before: Default before required (PHP 8.0 call succeeds)
<?php
function makeyogurt($container = "bowl", $flavour)
{
    return "Making a $container of $flavour yogurt.\n";
}
echo makeyogurt(flavour: "raspberry");

PHP 8.0 emits a deprecation on the definition but runs the call. PHP 8.1 throws ArgumentCountError for the call. Shown for comparison; not executed on this site.

After: Required parameter first, defaults last
<?php
function makeyogurt(string $flavour, string $container = "bowl"): string {
    return "Making a $container of $flavour yogurt.\n";
}
echo makeyogurt(flavour: "raspberry");
echo makeyogurt("mango", container: "cup");
Output on PHP 8.5.10
Making a bowl of raspberry yogurt.
Making a cup of mango yogurt.

Official migration notes

DEPRECATED

Passing null to non-nullable built-in parameters

Built-in functions no longer silently accept null for scalar parameters that are not declared nullable; PHP 8.1 emits a deprecation.

Scalar parameters of built-in functions used to accept null and quietly coerce it. PHP 8.1 deprecates this to match user-defined functions, where a scalar type must be marked nullable explicitly. The call still works in 8.1, but it emits a deprecation, and the guide gives str_contains("foobar", null) as an example.

This tends to be the noisiest item during an upgrade because the null often comes from request data, optional array keys or database columns. Do not suppress the message. Decide what null should mean at that point: usually a default such as an empty string via ??, or an earlier branch that skips the call. Static analysis helps find these locations, since the parameter types of built-ins are well documented. Treat it as a bug-finding opportunity as well, since an unexpected null often signals missing validation upstream.

Before: null passed to a built-in (deprecated in 8.1)
<?php
function clean(?string $input): string {
    return trim($input);
}
var_dump(clean(null));

Accepted silently by PHP 8.0; PHP 8.1 still runs it but emits a deprecation for passing null to trim(). Shown for comparison; not executed on this site.

After: Handle null explicitly before calling
<?php
function clean(?string $input): string {
    return trim($input ?? '');
}
var_dump(clean(null));
var_dump(clean("  hi "));
Output on PHP 8.5.10
string(0) ""
string(2) "hi"

Official migration notes

DEPRECATED

Implicit lossy float to int conversion

Implicitly converting a float with a fractional part to int, for example as an array key, is deprecated in PHP 8.1.

PHP 8.1 deprecates implicit float-to-int conversions that lose precision. The guide lists three places this affects: array keys, int type declarations in coercive mode, and operators that work on integers. A float with no fractional part, such as 15.0, is still fine because the conversion loses nothing.

The pitfall is that results of arithmetic are often floats with a hidden fractional part, such as a division or a percentage, which are then used as an offset. Make the intent explicit: use floor(), ceil() or round() to pick the rounding rule, then cast with (int), or use intdiv() where integer division is what you mean. Silent truncation was previously hiding that decision. Keep an eye on indexes computed from user input, since fractional values there are a sign of a logic error.

Before: Fractional float as array key (deprecated in 8.1)
<?php
$prices = [15 => 'low', 16 => 'high'];
echo $prices[15.5], "\n";

PHP 8.0 silently truncates the key to 15; PHP 8.1 still runs it but emits a deprecation because 0.5 is lost. Shown for comparison; not executed on this site.

After: Round explicitly, whole floats are fine
<?php
$prices = [15 => 'low', 16 => 'high'];
$index = 15.5;
echo $prices[(int) floor($index)], "\n";
echo $prices[16.0], "\n";
Output on PHP 8.5.10
low
high

Official migration notes

CHANGED BEHAVIOR

Static variables shared with inherited methods

An inherited, non-overridden method now shares its static variables with the parent method instead of getting a separate copy.

Before PHP 8.1, a child class that inherited a method without overriding it got its own copy of that method's static variables. In 8.1 the inherited method shares them with the parent's method. The guide notes that static variables in methods now behave the same way as static properties.

The risk is code that used a static variable as per-class state, such as a counter or cache inside a method called through several subclasses. After the upgrade those classes will see one combined value. If you need separate state per class, either override the method in the subclass so it has its own variables, or switch to a static property keyed by static::class. Add a test that calls the method through parent and child to catch this early.

Before: Per-class copy of static variable (PHP 8.0)
<?php
class A {
    public static function counter(): int { static $c = 0; return ++$c; }
}
class B extends A {}
echo A::counter(), A::counter(), B::counter(), B::counter(), "\n";

PHP 8.0 prints 1212 because B gets its own copy. PHP 8.1 and later share the variable. Shown for comparison; not executed on this site.

After: Shared state across parent and child
<?php
class A {
    public static function counter(): int {
        static $c = 0;
        return ++$c;
    }
}
class B extends A {}
echo A::counter(), A::counter(), B::counter(), B::counter(), "\n";
Output on PHP 8.5.10
1234

Official migration notes

NEW

new in initializers

Object creation with new is now allowed in parameter defaults, static variable and global constant initializers, and attribute arguments.

PHP 8.1 allows new ClassName() expressions in places that previously required constant values: default values of parameters, static variable initializers, global constant initializers and attribute arguments. Objects can also be passed to define().

The most common use is a default collaborator for a constructor parameter. Before, you had to make the parameter nullable, default it to null and then substitute an instance inside the body, which blurred the real type. Now the parameter keeps its non-nullable type and the default is visible in the signature. Remember that the default expression is evaluated at call time, so each call without an argument gets a fresh object. This applies to the listed contexts only; it does not make arbitrary expressions valid in constant expressions elsewhere.

Before: Nullable parameter with manual fallback (PHP 8.0)
<?php
interface Logger { public function log(string $m): void; }
class NullLogger implements Logger { public function log(string $m): void {} }
class Service {
    private Logger $logger;
    public function __construct(?Logger $logger = null) {
        $this->logger = $logger ?? new NullLogger();
    }
}

Valid in PHP 8.0 and later; the nullable type hides that a logger is always present. Shown for comparison; not executed on this site.

After: Default object in the signature
<?php
interface Logger { public function log(string $m): void; }
class NullLogger implements Logger { public function log(string $m): void {} }
class EchoLogger implements Logger {
    public function log(string $m): void { echo "log: $m\n"; }
}
class Service {
    public function __construct(private Logger $logger = new NullLogger()) {}
    public function run(): void { $this->logger->log('run'); }
}
(new Service())->run();
(new Service(new EchoLogger()))->run();
echo "done\n";
Output on PHP 8.5.10
log: run
done

Official migration notes

NEW

First-class callable syntax

Writing myFunc(...) creates a Closure for a function or method, equivalent to Closure::fromCallable().

PHP 8.1 lets you turn any callable into a Closure by writing the call with ... in place of the arguments, for example myFunc(...). The guide describes it as identical to Closure::fromCallable('myFunc'). It works for plain functions, instance methods and static methods, so it replaces string and array callables such as 'strlen' or [$obj, 'method'].

The advantage is that the target is written as real code, so editors and static analyzers can rename and check it, and a typo fails when the expression runs instead of later when the callable is invoked. The ... is part of the syntax and is not an omission. Do not confuse it with argument unpacking, which has the ellipsis before an actual argument.

Before: Closure::fromCallable with strings (PHP 8.0)
<?php
function double(int $n): int { return $n * 2; }
$f = Closure::fromCallable('double');
echo implode(',', array_map($f, [1, 2, 3])), "\n";

Works in PHP 8.0 and later, but the function name is just a string. Shown for comparison; not executed on this site.

After: Closures from function, method and static method
<?php
function double(int $n): int { return $n * 2; }
final class Tax {
    public function add(int $n): int { return $n + 3; }
    public static function neg(int $n): int { return -$n; }
}
$f = double(...);
$g = (new Tax)->add(...);
$h = Tax::neg(...);
$s = strlen(...);
echo implode(',', array_map($f, [1, 2, 3])), "\n";
echo $g(10), ' ', $h(4), ' ', $s('abcd'), "\n";
Output on PHP 8.5.10
2,4,6
13 -4 4

Official migration notes

NEW

Array unpacking with string keys

The spread operator inside array literals now accepts arrays with string keys, with later entries overriding earlier ones.

Array unpacking inside an array literal, available for integer-keyed arrays since PHP 7.4, now also supports string keys in PHP 8.1. You can merge associative arrays with the spread operator and add extra entries in the same literal.

For string keys, a later value replaces an earlier one with the same key, and the key keeps the position where it first appeared, which matches how array_merge() treats string keys. Integer keys are renumbered as before. The change mainly makes configuration merging and option defaults more readable. One pitfall: spreading something that is not an array or Traversable is still an error, so values that may be null need handling first.

Before: array_merge for associative arrays (PHP 8.0)
<?php
$defaults = ['host' => 'localhost', 'port' => 3306];
$overrides = ['port' => 3307, 'debug' => true];
$config = array_merge($defaults, $overrides, ['user' => 'app']);
echo json_encode($config), "\n";

Valid in PHP 8.0 and later; the spread form in an array literal with string keys needs PHP 8.1. Shown for comparison; not executed on this site.

After: Spread associative arrays
<?php
$defaults = ['host' => 'localhost', 'port' => 3306];
$overrides = ['port' => 3307, 'debug' => true];
$config = [...$defaults, ...$overrides, 'user' => 'app'];
echo json_encode($config), "\n";
Output on PHP 8.5.10
{"host":"localhost","port":3307,"debug":true,"user":"app"}

Official migration notes

NEW

The never return type

A function that always throws or exits can now declare never, and never becomes a reserved word.

PHP 8.1 adds never as a return-only type. It tells the engine and readers that a function does not return normally: it exits, throws an exception or never terminates. This is stricter than void, which means the function returns with no value.

It is most useful for small helpers that raise errors or redirect, because static analyzers can then treat code after the call as unreachable and stop complaining about missing return statements. Note the compatibility angle: never is now a reserved word, so it cannot name a class, interface or trait, and cannot be used in namespaces. If your codebase has a class called Never, rename it before upgrading. A function declared never must not contain a path that returns.

Before: void helper that always throws (PHP 8.0)
<?php
/** @return void */
function fail(string $m): void {
    throw new RuntimeException($m);
}
fail('boom');

Valid in PHP 8.0 and later, but the type says nothing about never returning, so callers still need explicit returns. Shown for comparison; not executed on this site.

After: never on a throwing helper
<?php
function fail(string $m): never {
    throw new RuntimeException($m);
}
function parse(string $v): int {
    if (!ctype_digit($v)) {
        fail("bad: $v");
    }
    return (int) $v;
}
echo parse('42'), "\n";
try {
    parse('x');
} catch (RuntimeException $e) {
    echo $e->getMessage(), "\n";
}
Output on PHP 8.5.10
42
bad: x

Official migration notes

NEW

Intersection types

A parameter, return or property type can require a value to satisfy several class or interface types at once using &.

PHP 8.1 adds intersection types, written with &, which require a value to be an instance of every listed type. A parameter typed Countable&Traversable accepts only objects that implement both interfaces. Previously you could only express this with docblocks plus manual instanceof checks, or by inventing a combined interface that third-party classes would not implement.

The guide includes a caution: intersection types cannot be combined with union types in the same declaration. That limitation matters when you are tempted to write something like a nullable intersection. A value that fails the check raises a TypeError, as with other declared types. Use them for capability-based parameters, and keep the list of types short so the contract stays readable.

Before: Manual instanceof checks (PHP 8.0)
<?php
function summarize($c): string {
    if (!$c instanceof Countable || !$c instanceof Traversable) {
        throw new TypeError('Countable and Traversable required');
    }
    return (string) count($c);
}

Works in PHP 8.0 and later, but the requirement is hidden in the body instead of the signature. Shown for comparison; not executed on this site.

After: Countable&Traversable parameter
<?php
final class Bag implements Countable, IteratorAggregate {
    public function __construct(private array $items) {}
    public function count(): int { return count($this->items); }
    public function getIterator(): Iterator { return new ArrayIterator($this->items); }
}
function summarize(Countable&Traversable $c): string {
    $out = [];
    foreach ($c as $i) { $out[] = $i; }
    return count($c) . ': ' . implode(',', $out);
}
echo summarize(new Bag(['a', 'b'])), "\n";
try {
    summarize([1]);
} catch (TypeError $e) {
    echo "TypeError\n";
}
Output on PHP 8.5.10
2: a,b
TypeError

Official migration notes

NEW

Fibers

PHP 8.1 adds Fibers, which let a piece of code suspend itself and be resumed later, keeping its call stack.

PHP 8.1 adds support for Fibers. A fiber runs a callable that can pause itself with Fiber::suspend(), handing a value back to the code that started or resumed it, and can later be continued with resume(). The state of the whole call stack inside the fiber is preserved, which is different from generators, where only the generator function itself can yield.

For most application code Fibers are a building block rather than something you call directly; they are mainly used by schedulers and asynchronous frameworks. Adopting them does not require changes to existing synchronous code. Be careful when mixing them with code that assumes a straight-line execution order, since a suspension can occur deep inside nested calls and control returns to the caller of start() or resume().

Example: Suspend and resume a fiber
<?php
$fiber = new Fiber(function (string $start): string {
    $got = Fiber::suspend("$start-1");
    echo "resumed with $got\n";
    return "finished";
});
$v = $fiber->start('go');
echo $v, "\n";
$fiber->resume('data');
echo $fiber->getReturn(), "\n";
Expected output (not run on this site)
go-1
resumed with data
finished

The browser PHP WebAssembly build traps when starting a Fiber, so this example is not executed on this site. Its output must be verified on a native PHP 8.1+ runtime during review.

Official migration notes

NEW

Final class constants

Class constants can be marked final to prevent overriding, and interface constants are now overridable by default.

PHP 8.1 adds support for the final modifier on class constants. A final constant cannot be redefined in a subclass, which is useful for values that other code relies on staying fixed. Trying to override one is a compile-time error, so mistakes show up as soon as the file loads.

The same release also changes the default for interface constants: they are now overridable unless declared final. If you relied on implementing classes being unable to change an interface constant, add final to it in the interface. Constants without final in classes remain overridable, as before. Use final sparingly and only for values that are truly part of a contract, since it limits how others can extend your class.

Example: final constant and overridable interface constant
<?php
class Base {
    final public const VERSION = '1.0';
    public const NAME = 'base';
}
class Child extends Base {
    public const NAME = 'child';
}
echo Child::VERSION, ' ', Child::NAME, "\n";

interface HasLimit {
    const LIMIT = 10;
}
class Impl implements HasLimit {
    const LIMIT = 20;
}
echo Impl::LIMIT, "\n";
Output on PHP 8.5.10
1.0 child
20

Official migration notes

DEPRECATED

Autovivification from false

Appending to a variable holding false used to create an array silently; PHP 8.1 deprecates it, while null and undefined still work.

Autovivification means PHP creates an array automatically when you append to a value that is not one yet. Scalars have never been allowed to do this, but false was an exception. PHP 8.1 deprecates that exception, so code like assigning with [] to a variable that holds false now emits a deprecation.

Autovivification from null and from undefined variables or array elements is still allowed, so the usual pattern of building an array lazily keeps working. The practical risk is functions that return false on failure and whose result is then appended to directly. Initialize an empty array explicitly when you see false, or check the value before writing to it. Doing so also makes the failure case visible instead of quietly turning it into data.

Before: Appending to false (deprecated in 8.1)
<?php
$arr = false;
$arr[] = 2;
var_dump($arr);

PHP 8.0 converts false to an array silently; PHP 8.1 still does it but emits a deprecation. Shown for comparison; not executed on this site.

After: Replace false with an explicit empty array
<?php
function collect(array|false $found): array {
    $list = $found === false ? [] : $found;
    $list[] = 2;
    return $list;
}
echo json_encode(collect(false)), json_encode(collect([1])), "\n";
Output on PHP 8.5.10
[2][1,2]

Official migration notes

← PHP 8.0 changes PHP 8.2 changes →