PHP 8: a revolution for the language
PHP 8 brings major improvements that modernize the language and boost performance thanks to the JIT compiler.
Released in November 2020, PHP 8.0 opened a series of yearly releases that have deeply changed the way PHP is written: more expressive typing, more concise syntax, immutable objects, native enumerations. Modern Symfony or Laravel code relies heavily on these features, and recent libraries often require PHP 8.2 or later. This article reviews the features with the biggest day-to-day impact, version by version, with their concrete benefits and pitfalls.
A bit of context before we start: each minor PHP version gets two years of active support followed by two years of security fixes. Staying on an end-of-life version does not just mean missing out on features; above all, it means exposure to vulnerabilities that will no longer be fixed. Check the supported versions page to plan your upgrades.
Named Arguments
Named arguments let you pass a parameter by its name rather than its position. You no longer need to repeat every default value just to change the last parameter, and the call becomes self-documenting:
// Before
htmlspecialchars($string, ENT_COMPAT | ENT_HTML401, 'UTF-8', false);
// PHP 8
htmlspecialchars($string, double_encode: false);
The flip side: parameter names are now part of a function's public API. Renaming a parameter in a library can break its users' code. If you maintain a package, treat such renames as breaking changes.
Property promotion, union types and the nullsafe operator
PHP 8.0 also introduced three features you use in almost every file. Constructor property promotion removes the repetitive declaration and assignment code. Union types (int|string) describe precisely which values are accepted. The nullsafe operator ?-> short-circuits a call chain as soon as one link is null:
final class InvoiceService
{
public function __construct(
private InvoiceRepository $invoices,
private ?LoggerInterface $logger = null,
) {}
public function findReference(int|string $id): ?string
{
$invoice = $this->invoices->find($id);
// null if the invoice, customer or address does not exist
return $invoice?->getCustomer()?->getAddress()?->getCountryCode();
}
}
On top of that come long-awaited functions (str_contains(), str_starts_with(), str_ends_with()), the mixed type, and throw usable as an expression, for example $value ?? throw new InvalidArgumentException().
Match Expression
match is a safer version of switch: it is an expression that returns a value, the comparison is strict (===), there is no break to forget, and if no arm matches and there is no default, PHP throws an UnhandledMatchError instead of silently carrying on.
$status = match($code) {
200 => 'OK',
301 => 'Redirect',
404 => 'Not Found',
500 => 'Server Error',
default => 'Unknown',
};
Watch out for the strict comparison: if $code comes from a string ('200'), no integer arm will match. Convert the value beforehand. Several values can share an arm by separating them with commas (301, 302 => 'Redirect'), and match(true) lets you evaluate arbitrary conditions.
Enums (PHP 8.1)
Enumerations replace class constants and "magic" strings. A backed enum associates each case with a scalar value, which makes it easy to store in a database or serialize to JSON. It can carry methods and implement interfaces:
enum Status: string {
case Active = 'active';
case Inactive = 'inactive';
case Pending = 'pending';
public function label(): string {
return match($this) {
self::Active => 'Active',
self::Inactive => 'Inactive',
self::Pending => 'Pending',
};
}
}
The automatically generated methods cover the common use cases:
$status = Status::from('active'); // Status::Active
$maybe = Status::tryFrom('archived'); // null instead of an exception
$all = Status::cases(); // list of all cases
function activate(User $user): void
{
// the type guarantees a valid value, no manual validation
$user->setStatus(Status::Active);
}
Doctrine ORM (via enumType on a column), the Serializer and Symfony forms (EnumType) handle enums natively. Use from() when an invalid value is a bug, and tryFrom() for user input.
Fibers (PHP 8.1)
Fibers are interruptible functions: they can suspend their execution and resume it later while keeping their call stack. They do not create parallelism (everything runs in a single thread), but they make it possible to write asynchronous code that looks like synchronous code.
$fiber = new Fiber(function (): void {
$value = Fiber::suspend('first pause');
echo "Received: $value\n";
});
$result = $fiber->start(); // 'first pause'
$fiber->resume('data');
In practice, you rarely handle Fibers directly. They are low-level building blocks used by libraries such as AMPHP or the Revolt event loop, which orchestrate suspensions during network I/O.
Other PHP 8.1 additions
- First-class callable syntax:
$fn = strlen(...);creates aClosurefrom any function or method. - The
neverreturn type for functions that always throw an exception or end the script. newin initializers: an object can be used as a parameter's default value, as in theUserexample below.array_is_list()to check that an array has consecutive 0, 1, 2… keys.
Readonly Properties (PHP 8.1) and readonly classes (PHP 8.2)
A readonly property can only be initialized once, from within the class scope. It is ideal for value objects, DTOs and events, which must not change after they are created:
class User {
public function __construct(
public readonly string $name,
public readonly string $email,
public readonly DateTimeImmutable $createdAt = new DateTimeImmutable(),
) {}
}
PHP 8.2 lets you declare the whole class readonly, which avoids repeating the keyword on every property. To "modify" such an object, you create a copy with the new value (with…() methods):
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {}
public function withAmount(int $amount): self
{
return new self($amount, $this->currency);
}
}
Limits to keep in mind: a readonly property must be typed, cannot have a default value in its declaration, and immutability is shallow. If the property holds a mutable object, that object can still be modified. Hence the value of DateTimeImmutable over DateTime. PHP 8.2 also brought DNF types ((Countable&ArrayAccess)|null) and deprecated dynamic property creation.
PHP 8.3 and 8.4: the latest additions
PHP 8.3 introduced typed class constants, the #[\Override] attribute (PHP throws an error if the method does not actually override anything, for example after a rename in the parent class) and the json_validate() function. PHP 8.4 added two important features for modeling: property hooks, which attach read or write logic directly to a property, and asymmetric visibility, which allows, for example, public reads and private writes:
final class Product
{
public const string DEFAULT_CURRENCY = 'EUR'; // PHP 8.3
public private(set) string $status = 'draft'; // PHP 8.4
public int $priceInCents { // PHP 8.4
set(int $value) {
if ($value < 0) {
throw new InvalidArgumentException('The price must be positive.');
}
$this->priceInCents = $value;
}
}
public string $formattedPrice {
get => number_format($this->priceInCents / 100, 2).' '.self::DEFAULT_CURRENCY;
}
public function __construct(int $priceInCents)
{
$this->priceInCents = $priceInCents;
}
public function publish(): void
{
$this->status = 'published';
}
}
PHP 8.4 also lets you chain a call directly after new without extra parentheses (new Foo()->bar()) and adds functions such as array_find() and array_any(). PHP 8.5, released in November 2025, continues in the same direction, notably with the pipe operator |>.
JIT performance
The JIT compiler improves performance by 2 to 3x for CPU-intensive operations. Enable it in php.ini:
opcache.jit=1255
opcache.jit_buffer_size=100M
This needs some nuance, though: the JIT compiles bytecode into machine code, which mainly benefits pure computation (image processing, mathematical algorithms, parsers). A typical web application spends most of its time waiting for the database, the network or the file system, and the gain there is usually small. OPcache, on the other hand, remains essential in every case, and the JIT only works when it is enabled. The values tracing and function are more readable aliases for opcache.jit, and since PHP 8.4 the JIT is disabled by default (opcache.jit=disable). Measure with your own workload before enabling it in production.
Checklist for migrating to PHP 8.x
- Read the official migration guides for each version on php.net, especially the "backward incompatible changes" and "deprecations" sections.
- Enable deprecation reporting in the test environment (
error_reporting=E_ALL) and fix them before switching versions. - Run static analysis (PHPStan or Psalm) and automate repetitive rewrites with Rector.
- Update Composer dependencies and check the
"php"constraint incomposer.json. - Run the full test suite on the new version in CI before switching production.
Adopting these features gradually, file by file, is enough to make a codebase safer and more readable: types catch errors earlier, enums and readonly objects eliminate entire categories of bugs, and match makes exhaustive branching explicit.