PHP 8 Features: Enums, Match, Readonly Properties and Constructor Promotion
A working guide to the features that changed how modern PHP is written — covering PHP 8.0 through 8.3, with the before-and-after of each so the benefit is concrete rather than abstract.
PHP 8 is not a cosmetic release. Constructor promotion removed most boilerplate from value objects, enums replaced class constants as the way to model a closed set of states, and readonly properties made genuine immutability possible without private fields and a wall of getters.
Taken together these features let you express intent in the type system that previously lived only in documentation and defensive runtime checks.
Declaring and Assigning in One Place
Before PHP 8, a value object repeated every property three times: the declaration, the constructor parameter, and the assignment. Promotion collapses all three into the parameter list.
<?php
class Money
{
private int $amount;
private string $currency;
public function __construct(int $amount, string $currency)
{
$this->amount = $amount;
$this->currency = $currency;
}
}
<?php
final class Money
{
public function __construct(
public readonly int $amount,
public readonly string $currency = 'INR',
) {
if ($amount < 0) {
throw new InvalidArgumentException('Amount cannot be negative.');
}
}
public function add(Money $other): self
{
if ($other->currency !== $this->currency) {
throw new InvalidArgumentException('Currency mismatch.');
}
// Immutable: return a new instance rather than mutating
return new self($this->amount + $other->amount, $this->currency);
}
}
$total = (new Money(1000))->add(new Money(250));
echo $total->amount; // 1250
__construct, not other methods.Immutability Enforced by the Engine
A readonly property may be written exactly once, from inside the declaring class's scope. Any later write throws an Error, which turns accidental mutation into an immediate, located failure.
<?php
// PHP 8.2: readonly on the class marks every property readonly
final readonly class Coordinates
{
public function __construct(
public float $latitude,
public float $longitude,
) {}
// Named constructors pair well with immutability
public static function fromString(string $pair): self
{
[$lat, $lng] = array_map('floatval', explode(',', $pair));
return new self($lat, $lng);
}
public function withLatitude(float $latitude): self
{
return new self($latitude, $this->longitude);
}
}
$point = Coordinates::fromString('12.97,77.59');
// Error: Cannot modify readonly property Coordinates::$latitude
// $point->latitude = 0.0;
$moved = $point->withLatitude(13.0); // a new instance
readonly is shallow. If the property holds an array or object, the reference cannot be replaced but the object's own contents can still be mutated. For deep immutability, the nested objects must be readonly too.Replacing Class Constants with Real Types
Before PHP 8.1 a closed set of values was modelled with class constants and a string or int type hint, which meant any string was accepted and validity had to be checked by hand. An enum makes the set a type.
<?php
interface HasLabel
{
public function label(): string;
}
enum OrderStatus: string implements HasLabel
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'Awaiting payment',
self::Paid => 'Payment received',
self::Shipped => 'On its way',
self::Cancelled => 'Cancelled',
};
}
public function isFinal(): bool
{
return in_array($this, [self::Shipped, self::Cancelled], true);
}
}
// The parameter can now only receive a valid status
function notify(OrderStatus $status): string
{
return $status->label();
}
// from() throws on an unknown value; tryFrom() returns null
$status = OrderStatus::from('paid');
$maybe = OrderStatus::tryFrom($_GET['status'] ?? '') ?? OrderStatus::Pending;
// cases() lists every member — handy for building a <select>
foreach (OrderStatus::cases() as $case) {
echo "{$case->value}: {$case->label()}\n";
}
Use tryFrom for anything originating outside your code — request parameters, API payloads, database columns written by an older version. Use from only where an invalid value genuinely indicates a bug worth an exception.
| Enum kind | Declaration | Has ->value | Use for |
|---|---|---|---|
| Pure | enum Suit {} | No | Internal states never persisted |
| Backed (string) | enum S: string {} | Yes | Database columns, API fields |
| Backed (int) | enum L: int {} | Yes | Ordered levels, bitmask-like sets |
Strict Comparison, Exhaustive, Returns a Value
match differs from switch in four ways that each remove a class of bug: it is an expression, it compares with ===, it does not fall through, and it throws if nothing matches.
<?php
// switch: loose ==, needs break, silently does nothing if unmatched
switch ($httpCode) {
case 200:
case 201:
$result = 'success';
break;
case 404:
$result = 'not found';
break;
default:
$result = 'unknown';
}
// match: strict ===, an expression, exhaustive
$result = match ($httpCode) {
200, 201 => 'success',
404 => 'not found',
default => 'unknown',
};
// Without a default, an unhandled value throws
// UnhandledMatchError — which is usually what you want
$label = match ($status) {
OrderStatus::Paid => 'Paid',
OrderStatus::Shipped => 'Shipped',
};
// match(true) replaces long if/elseif chains
$tier = match (true) {
$total >= 100_000 => 'platinum',
$total >= 50_000 => 'gold',
$total >= 10_000 => 'silver',
default => 'bronze',
};
switch ("1") matches case 1, but match does not. That is a fix, not a regression — but it will change behaviour in code that relied on the coercion.Smaller Features That Change Daily Code
These three are individually small but appear constantly in modern PHP codebases.
<?php
// Named arguments: skip optional parameters and document the call site
function createUser(
string $email,
string $name = '',
bool $isAdmin = false,
bool $sendWelcome = true,
): User {
// ...
}
// Previously: createUser('a@b.c', '', false, false) — what do these mean?
createUser(email: 'a@b.c', sendWelcome: false);
// Nullsafe operator: short-circuits the whole chain on null
$city = $order?->customer?->address?->city;
// Equivalent to this, but without the nesting:
// $city = null;
// if ($order !== null && $order->customer !== null) { ... }
// First-class callable syntax (8.1) — type-safe references
$fn = strlen(...);
$self = $this->handle(...);
$stat = OrderStatus::tryFrom(...);
$lengths = array_map(strlen(...), ['one', 'three']);
// Readable numeric literals (7.4) and 8.1 octal notation
$budget = 1_500_000;
$perms = 0o755;
null. It does not help if a property is missing or an array key is absent — use ?? for those.Other Additions Worth Knowing
- Union types (8.0):
int|stringin a signature, replacing docblock-only hints. - Intersection types (8.1):
Countable&Iteratorrequires both interfaces. - Attributes (8.0): native metadata as
#[Route('/posts')], replacing annotation parsing from comments. - never return type (8.1): marks a function that always throws or exits.
- Fibers (8.1): the primitive async runtimes build on.
- json_validate() (8.3): checks JSON validity without building the decoded structure.
- Typed class constants (8.3):
const string VERSION = '1.0';
• Replace class-constant sets with backed enums; use tryFrom for external input.
• Prefer match over switch — strict comparison and exhaustiveness catch real bugs.
• Use named arguments at call sites with several boolean flags.
• Remember readonly and nullsafe are both shallow.
Summary
PHP 8 moved a large amount of correctness checking from runtime conventions into the language. Constructor promotion and readonly make immutable value objects concise, enums turn string states into types, and match makes conditional logic exhaustive by default.
The practical migration path is incremental: adopt promotion and match in new code, convert constant sets to enums as you touch them, and let readonly drive value objects toward immutability.
The through-line is that each feature lets the engine enforce something you previously had to remember — which is exactly the kind of change that pays off most in a large codebase.