PHP 8.1: enums, fibers, and the type system growing up
PHP 8.1 released November 25th. It follows 8.0’s sweeping overhaul with something different: fewer features, but each one thought through rather than bolted on.
Enums
This is the one that changes codebases the moment you upgrade. Before 8.1, PHP enumerations were either class constants, strings, or integers with nothing enforcing them:
// before: nothing stops Status::INVALID from being passed
const ACTIVE = 'active';
const INACTIVE = 'inactive';
// after
enum Status: string {
case Active = 'active';
case Inactive = 'inactive';
}
function activate(Status $status): void { ... }
PHP enums are objects, not scalars. They support methods, interfaces, and constants. Backed enums (with a string or int value) serialize cleanly and map to database columns naturally. Pure enums (no backing type) enforce domain concepts without worrying about serialization.
The immediate effect: every status field, every finite set of states in every codebase I maintain became an enum candidate. The type system finally has a native way to express the thing every PHP project has been faking for years.
Fibers
Fibers are a cooperative concurrency primitive: you can pause and resume execution of a function, yielding control without threads.
$fiber = new Fiber(function(): void {
$value = Fiber::suspend('first');
echo "Resumed with: {$value}\n";
});
$result = $fiber->start(); // 'first'
$fiber->resume('hello'); // "Resumed with: hello"
Fibers are the foundation async libraries like ReactPHP and Amp have needed from the runtime for a while. For most application developers the direct API matters less than the libraries built on top of it, but understanding fibers explains what those libraries are doing underneath.
Readonly properties
8.0 brought constructor promotion. 8.1 adds readonly:
class User {
public function __construct(
public readonly int $id,
public readonly string $name,
) {}
}
A readonly property can be written exactly once, during initialization. After that, any write throws an Error. Combined with constructor promotion, value objects and DTOs become concise and actually mean what they say.
First-class callable syntax
$fn = strlen(...);
$fn = $this->process(...);
$fn = MyClass::create(...);
... after a callable creates a Closure without Closure::fromCallable() boilerplate. Useful when passing methods as callbacks.
8.1 is precise. Enums alone justify the upgrade.
Intersection types
Union types landed in 8.0. Intersection types follow in 8.1. Where a union says “one of these”, an intersection says “all of these”:
function process(Countable&Iterator $collection): void {
foreach ($collection as $item) { /* ... */ }
echo count($collection);
}
One constraint: intersection types can’t be mixed with union types in the same declaration (that arrives in 8.2 as DNF types). But this already unlocks precise type-checking for objects that must satisfy multiple interfaces at once, a pattern frameworks use constantly that had to stay untyped until now.
The never return type
A function that never returns (it always throws or exits) now has a type to say so:
function redirect(string $url): never {
header("Location: {$url}");
exit();
}
function fail(string $message): never {
throw new \RuntimeException($message);
}
The practical benefit: static analyzers can prove that code after a never function is unreachable, and callers know there’s no return value to handle. Before this, it lived in docblocks with no enforcement.
Final class constants
Before 8.1, any subclass could quietly override a parent’s class constant. Now you can put a stop to that:
class Base {
final public const VERSION = '1.0';
}
class Child extends Base {
// Fatal error: Cannot override final constant Base::VERSION
public const VERSION = '2.0';
}
Relatedly, interface constants are now overridable by implementing classes by default. A separate behavior fix that had been inconsistent since interfaces were introduced.
new in initializers
Default parameter values used to be restricted to scalars and arrays. 8.1 drops that restriction:
class Logger {
public function __construct(
private Handler $handler = new NullHandler(),
) {}
}
function createUser(
Validator $validator = new DefaultValidator(),
): User { /* ... */ }
Same goes for attribute arguments and static variable initializers. Which means dependency injection with sensible defaults no longer needs a null check and lazy instantiation inside the method body.
Array unpacking with string keys
Array unpacking via the spread operator only worked with integer-keyed arrays before 8.1. String keys work now too:
$defaults = ['color' => 'red', 'size' => 'M'];
$custom = ['size' => 'L', 'weight' => '200g'];
$merged = [...$defaults, ...$custom];
// ['color' => 'red', 'size' => 'L', 'weight' => '200g']
Later keys override earlier ones. Same behavior as array_merge(), but expressed inline. Performance difference is marginal; readability difference is not.
fsync and fdatasync
Two functions that had no good reason to be missing from a filesystem-oriented language:
$fp = fopen('/tmp/important.dat', 'w');
fwrite($fp, $data);
fsync($fp); // flush OS buffers to physical storage
fclose($fp);
fdatasync() does the same but skips metadata sync when you only care about the data being durable. Both return false on failure. If you’re writing anything that needs crash safety, you needed these.
Passing null to non-nullable built-in parameters
A quieter but consequential change: built-in functions that accept strings, integers, etc. have always silently swallowed null and coerced it. In 8.1, that starts emitting a deprecation notice.
str_contains("foobar", null);
// Deprecated: Passing null to parameter #2 ($needle) of type string is deprecated
This aligns built-in functions with user-defined functions, which already refused nullable arguments for non-nullable parameters. PHP 9.0 turns this into a hard error. If you’re passing null into string functions, now is a better time to fix it than during a production incident.
MySQLi exceptions by default
Before 8.1, MySQLi failed silently unless you explicitly called mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT). That’s now the default:
// This throws \mysqli_sql_exception on connection failure in 8.1
// Previously returned false and set an error you had to check manually
$connection = new mysqli('localhost', 'user', 'wrong_password', 'db');
Every codebase that catches MySQLi errors by checking return values needs to be reviewed. The silent failures that caused hard-to-diagnose bugs now throw exceptions, which is the right behavior, just potentially surprising if you hit it mid-upgrade.