PHP has changed dramatically over the past few years. If your codebase still looks like it did in the PHP 7 era — verbose constructors, scattered constants, manual callback wiring — you're leaving clarity and safety on the table. Modern PHP gives you tools that eliminate entire categories of boilerplate while making your intent explicit at the language level.
This post walks through the features that should be part of every PHP developer's daily toolkit in 2026, with before-and-after comparisons so you can see exactly what changes and why it matters.
Constructor Property Promotion
Introduced in PHP 8.0, constructor promotion collapses the declare-then-assign pattern into a single line. If you're still writing constructors the old way, this is the single highest-impact change you can make today.
Here's the classic approach:
// Before: PHP 7.x style
class Invoice
{
private string $number;
private float $total;
private \DateTimeImmutable $issuedAt;
private ?string $notes;
public function __construct(
string $number,
float $total,
\DateTimeImmutable $issuedAt,
?string $notes = null
) {
$this->number = $number;
$this->total = $total;
$this->issuedAt = $issuedAt;
$this->notes = $notes;
}
}
Every property is declared three times — as a property, as a parameter, and as an assignment. With promotion, the same class becomes:
// After: PHP 8.0+ with constructor promotion
class Invoice
{
public function __construct(
private string $number,
private float $total,
private \DateTimeImmutable $issuedAt,
private ?string $notes = null,
) {}
}
The class drops from 18 lines to 8. You can still mix promoted and non-promoted parameters in the same constructor when you need to compute a value during construction:
class Order
{
private string $slug;
public function __construct(
private string $name,
private float $total,
) {
$this->slug = Str::slug($name);
}
}
Promotion works with any visibility modifier, default values, and even with readonly (which we'll cover next). Adopt it everywhere — there's no downside.
Readonly Properties and Readonly Classes
PHP 8.1 introduced readonly properties, and PHP 8.2 extended the concept to entire classes. A readonly property can only be assigned once, typically in the constructor, and can never be modified afterward.
// Before: Manually enforced immutability
class Coordinate
{
private float $lat;
private float $lng;
public function __construct(float $lat, float $lng)
{
$this->lat = $lat;
$this->lng = $lng;
}
public function getLat(): float
{
return $this->lat;
}
public function getLng(): float
{
return $this->lng;
}
}
With readonly and promotion combined:
// After: PHP 8.1+ readonly + promotion
class Coordinate
{
public function __construct(
public readonly float $lat,
public readonly float $lng,
) {}
}
$point = new Coordinate(40.7128, -74.0060);
echo $point->lat; // 40.7128
// $point->lat = 0; // Fatal error: Cannot modify readonly property
The properties are public (no getter boilerplate needed) but immutable (no risk of accidental modification). For DTOs and value objects, you can go further with a readonly class:
// PHP 8.2+ readonly class
readonly class Money
{
public function __construct(
public int $cents,
public string $currency,
) {}
}
Every property in a readonly class is implicitly readonly. You can't add non-readonly properties, which prevents you from accidentally breaking the immutability contract.
Enums
Before PHP 8.1, representing a fixed set of values meant class constants, and those constants had no type safety at all. Any string or integer could slip through. Enums changed everything.
// Before: class constants with no type safety
class OrderStatus
{
public const PENDING = 'pending';
public const PROCESSING = 'processing';
public const SHIPPED = 'shipped';
public const DELIVERED = 'delivered';
}
function updateStatus(string $status): void
{
// Nothing prevents: updateStatus('banana')
}
With a backed enum:
// After: PHP 8.1+ backed enum
enum OrderStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Shipped = 'shipped';
case Delivered = 'delivered';
}
function updateStatus(OrderStatus $status): void
{
// Only valid enum cases can be passed
}
updateStatus(OrderStatus::Shipped); // Works
// updateStatus('banana'); // TypeError
Enums give you type-safe parameters, exhaustive match expressions, and built-in serialization via ->value and ::from(). They're one of the most impactful additions to PHP in years, and we'll explore their full power in a companion post.
Intersection Types and Union Types
PHP 8.0 added union types (string|int) and PHP 8.1 added intersection types (Countable&Iterator). Together, they let you express precise type contracts that were previously only possible with PHPDoc annotations.
Union Types
Union types accept one of several types. They replace the vague mixed or PHPDoc-only annotations:
// Before: @param string|int $id (PHPDoc only, not enforced)
function find($id)
{
// No runtime type safety
}
// After: PHP 8.0+ union types
function find(string|int $id): Model|null
{
return Model::query()->find($id);
}
Intersection Types
Intersection types require a value to satisfy all of the specified types. This is powerful for requiring multiple interface implementations:
// The parameter must implement BOTH Countable AND Iterator
function processCollection(Countable&Iterator $items): void
{
echo "Processing {$items->count()} items..." . PHP_EOL;
foreach ($items as $item) {
// Process each item
}
}
A common real-world use case is type-hinting a logger that is also serializable, or a cache driver that implements both CacheInterface and LockProvider. Before intersection types, you'd need a dedicated interface that extended both — now the constraint is expressed inline.
First-Class Callable Syntax
PHP 8.1 introduced the functionName(...) syntax for creating closures from existing functions and methods. This eliminates the need for string-based function references and awkward array callables.
// Before: string-based and array callables
$filtered = array_filter($items, 'is_numeric');
$mapped = array_map([$this, 'transformItem'], $items);
$sorted = usort($items, [$this, 'compareItems']);
With first-class callable syntax:
// After: PHP 8.1+ first-class callables
$filtered = array_filter($items, is_numeric(...));
$mapped = array_map($this->transformItem(...), $items);
usort($items, $this->compareItems(...));
The (...) syntax creates a real Closure object, which means static analysis tools can verify the function exists and its signature matches what the caller expects. String-based callables gave you none of that.
This shines especially in collection pipelines and event registration:
// Laravel collection with first-class callables
$users = User::all();
$admins = $users->filter($this->isAdmin(...));
$emails = $users->map($this->extractEmail(...));
// Event registration
Event::listen(OrderShipped::class, $this->handleShipment(...));
Fibers
PHP 8.1 introduced Fibers, which are lightweight, cooperatively-scheduled units of execution. Unlike threads, fibers don't run in parallel — they yield control explicitly, allowing you to write asynchronous-style code within a synchronous-looking flow.
Most PHP developers will never create a Fiber directly. Instead, fibers power libraries like ReactPHP, Amp, and Revolt, giving them a cleaner foundation than generators. But understanding the mechanics helps you reason about these libraries.
$fiber = new Fiber(function (): void {
$value = Fiber::suspend('first pause');
echo "Resumed with: {$value}" . PHP_EOL;
Fiber::suspend('second pause');
echo "Finished." . PHP_EOL;
});
$result1 = $fiber->start(); // 'first pause'
$result2 = $fiber->resume('hello'); // Prints "Resumed with: hello", returns 'second pause'
$fiber->resume(); // Prints "Finished."
A Fiber starts executing when you call start() and pauses at each Fiber::suspend() call, returning the suspended value to the caller. The caller can then pass a value back in via resume(). This bidirectional communication is what makes fibers more powerful than generators for async patterns.
Practical Fiber Example: Non-Blocking Batch Processing
function batchProcessor(array $items, int $chunkSize = 100): Fiber
{
return new Fiber(function () use ($items, $chunkSize): void {
$chunks = array_chunk($items, $chunkSize);
foreach ($chunks as $index => $chunk) {
foreach ($chunk as $item) {
processItem($item);
}
// Yield progress after each chunk
Fiber::suspend([
'processed' => ($index + 1) * $chunkSize,
'total' => count($items),
]);
}
});
}
$fiber = batchProcessor($largeDataset, 500);
$progress = $fiber->start();
while (! $fiber->isTerminated()) {
echo "Processed {$progress['processed']} of {$progress['total']}" . PHP_EOL;
$progress = $fiber->resume();
}
This pattern lets a long-running operation yield progress updates without callbacks or events. The fiber processes a chunk, suspends with a status report, and the caller decides when to resume.
Named Arguments
Named arguments (PHP 8.0) let you pass values to a function by parameter name instead of position. This is especially valuable for functions with many optional parameters or boolean flags.
// Before: What does `true, false, true` mean?
$user = createUser('Jane', 'jane@example.com', true, false, true);
// After: Named arguments make intent explicit
$user = createUser(
name: 'Jane',
email: 'jane@example.com',
isAdmin: true,
sendWelcomeEmail: false,
requireMfa: true,
);
Named arguments also let you skip optional parameters entirely:
function createNotification(
string $message,
string $channel = 'email',
int $delay = 0,
bool $urgent = false,
): Notification {
// ...
}
// Skip $channel and $delay, only set $urgent
$notification = createNotification(
message: 'Server is down',
urgent: true,
);
In Laravel, named arguments pair beautifully with validation rules, route definitions, and anywhere you see functions with multiple optional parameters.
The Match Expression
The match expression (PHP 8.0) is a stricter, expression-based alternative to switch. It uses strict comparison, returns a value, and throws an exception if no arm matches.
// Before: switch with breaks and fallthrough risk
switch ($status) {
case 'pending':
$label = 'Waiting';
$color = 'yellow';
break;
case 'active':
$label = 'Active';
$color = 'green';
break;
case 'suspended':
$label = 'Suspended';
$color = 'red';
break;
default:
throw new \ValueError("Unknown status: {$status}");
}
// After: match expression
[$label, $color] = match ($status) {
'pending' => ['Waiting', 'yellow'],
'active' => ['Active', 'green'],
'suspended' => ['Suspended', 'red'],
};
Notice there's no default arm — if $status doesn't match any case, PHP throws an UnhandledMatchError. This is a feature, not a bug. It means you'll find missing cases immediately instead of silently falling through.
When paired with enums, match becomes even more powerful because static analysis tools can verify that every enum case is covered:
enum Color: string
{
case Red = 'red';
case Green = 'green';
case Blue = 'blue';
}
function hexCode(Color $color): string
{
return match ($color) {
Color::Red => '#FF0000',
Color::Green => '#00FF00',
Color::Blue => '#0000FF',
};
}
Putting It All Together
These features aren't isolated improvements. They compound. Here's a real-world service class that combines constructor promotion, readonly properties, enums, match expressions, named arguments, and first-class callables:
enum NotificationChannel: string
{
case Email = 'email';
case Sms = 'sms';
case Slack = 'slack';
}
readonly class NotificationConfig
{
public function __construct(
public NotificationChannel $channel,
public string $recipient,
public bool $urgent = false,
public int $retryAttempts = 3,
) {}
}
class NotificationService
{
public function __construct(
private readonly MailerInterface&LoggerInterface $mailer,
private readonly SmsGateway $sms,
private readonly SlackClient $slack,
) {}
public function send(NotificationConfig $config, string $message): void
{
$handler = match ($config->channel) {
NotificationChannel::Email => $this->sendEmail(...),
NotificationChannel::Sms => $this->sendSms(...),
NotificationChannel::Slack => $this->sendSlack(...),
};
retry(
times: $config->retryAttempts,
callback: fn () => $handler($config->recipient, $message),
);
}
private function sendEmail(string $to, string $message): void { /* ... */ }
private function sendSms(string $to, string $message): void { /* ... */ }
private function sendSlack(string $to, string $message): void { /* ... */ }
}
// Usage with named arguments
$config = new NotificationConfig(
channel: NotificationChannel::Slack,
recipient: '#alerts',
urgent: true,
);
$service->send($config, 'Deployment complete.');
Every feature in this class serves a purpose. The enum ensures only valid channels exist. The readonly DTO prevents accidental mutation of configuration. Constructor promotion keeps the boilerplate minimal. The intersection type on $mailer guarantees both contracts are satisfied. The match expression maps channels to handlers exhaustively. And first-class callable syntax turns methods into closures cleanly.
Start Today
You don't need to rewrite your entire codebase. Start with the next class you create. Use constructor promotion. Make your DTOs readonly. Replace a class constant with an enum. Swap a switch for a match. These features aren't about chasing novelty — they're about writing code that's shorter, safer, and harder to misuse.
Modern PHP isn't the PHP you learned five years ago. It's genuinely enjoyable to write — I catch myself looking forward to modeling new domains now, which is not something I'd have said in the PHP 7 days. The gap between PHP and languages that developers traditionally consider more elegant shrinks with every release. If you've been on the sidelines, 2026 is the year to catch up.