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.
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.
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";Published published Published same Draft
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.
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.
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";
}1,2 5,2 Cannot modify readonly property Point::$x
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.
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.
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");Making a bowl of raspberry yogurt. Making a cup of mango yogurt.
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.
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.
function clean(?string $input): string {
return trim($input ?? '');
}
var_dump(clean(null));
var_dump(clean(" hi "));string(0) "" string(2) "hi"
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.
$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.
$prices = [15 => 'low', 16 => 'high'];
$index = 15.5;
echo $prices[(int) floor($index)], "\n";
echo $prices[16.0], "\n";low high
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.
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.
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";1234
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.
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.
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";log: run done
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.
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.
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";2,4,6 13 -4 4
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.
$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.
$defaults = ['host' => 'localhost', 'port' => 3306];
$overrides = ['port' => 3307, 'debug' => true];
$config = [...$defaults, ...$overrides, 'user' => 'app'];
echo json_encode($config), "\n";{"host":"localhost","port":3307,"debug":true,"user":"app"}
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.
/** @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.
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";
}42 bad: x
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.
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.
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";
}2: a,b TypeError
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().
$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";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.
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.
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";1.0 child 20
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.
$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.
function collect(array|false $found): array {
$list = $found === false ? [] : $found;
$list[] = 2;
return $list;
}
echo json_encode(collect(false)), json_encode(collect([1])), "\n";[2][1,2]