PHP has changed. The language that powered the early web grew up, learned from its mistakes, and became something genuinely great. These are real, objective improvements that make PHP a joy to write — features I use every day and never want to give up.
Inspired by PHP Sadness — but this time, we're smiling.
Excellent Error Reporting
#1 PHP 8 named arguments — no more positional confusion 8.0 #2 Union types and intersection types 8.0+ #3 Enums — finally, proper enumerations 8.1 #37#[\Deprecated] attribute — deprecate your own code properly
8.4
#42
#[\NoDiscard] — warn when return values are ignored
8.5
#44
Fatal error backtraces — stack traces on fatal errors
8.5
Consistency Wins
#4str_contains(), str_starts_with(), str_ends_with() — finally consistent
8.0
#5
Array unpacking with string keys
8.1
#6
Fibers — proper async primitives
8.1
#41
array_first() and array_last()
8.5
Things That Just Work
#7 Composer — best-in-class dependency management #8 Built-in development server (php -S)
5.4+
#9
PDO — clean, consistent database abstraction
Modern Elegance
#10 Arrow functions 7.4 #11match expressions
8.0
#12
Null coalescing operator ?? and ??=
7.0+
#39
Pipe operator |> — left-to-right function chaining
8.5
Crystal Clear
#13 Typed properties 7.4 #14 Constructor property promotion 8.0 #15 Readonly properties and classes 8.1+ #33 Typed class constants 8.3 #35 Asymmetric visibility —public private(set)
8.4
No Limits
#16 First-class callable syntax 8.1 #17 Attributes — native metadata 8.0 #18 Named arguments 8.0 #43 Closures in constant expressions 8.5Functional Joy
#19array_map, array_filter, array_reduce — the functional trio
#20
Closures and variable binding with use()
#21
Generators and yield
5.5+
#36
array_find(), array_any(), array_all() — expressive array search
8.4
Batteries Included
#22 Built-in JSON support #23DateTime and DateTimeImmutable
#24
password_hash() and password_verify()
5.5+
#31
json_validate() — validate without decoding
8.3
#45
URI extension — proper URL parsing at last
8.5
OOP Done Right
#25 Interfaces, traits, and abstract classes #26 Late static binding 5.3+ #27 Anonymous classes 7.0 #32#[\Override] attribute — safe method overriding
8.3
#34
Property hooks — get/set without the boilerplate
8.4
#40
Clone with — modify properties while cloning
8.5
Parser Perfection
#28 Heredoc/Nowdoc flexibility 7.3 #29 Trailing commas everywhere 8.0 #30 Nullsafe operator?->
8.0
#38
new without parentheses — chain on construction
8.4
Warp Speed
#46 JIT compilation — PHP has a JIT compiler 8.0 #47 OPcache — bytecode caching in the box 5.5+True to Form
#48never return type — functions that never return
8.1
#49
array_is_list() — finally answer "is this sequential?"
8.1
Beyond PHP
#50 FFI — call C libraries directly from PHP 7.4Chaos Under Control
#51Random\Randomizer — a proper CSPRNG API
8.2
#52
WeakMap — object keys that don't leak memory
8.0
Pattern Matching
#53 Named capture groups inpreg_match
#54
Spaceship operator <=> — three-way comparison
7.0
#55
Array destructuring with keys — pattern matching for arrays
Behind the Curtain
#56 Stream wrappers &php:// — virtual file handles
World Ready
#57intl MessageFormatter — ICU-backed proper i18n
Trust but Verify
#58 PHPStan & Psalm — static analysis as a first-class citizenElegance in Brevity
#59 Numeric literal separators —1_000_000
7.4
PHP 8 Named Arguments
Before PHP 8, calling a function with many optional parameters meant counting commas and passing null for every parameter you didn't care about. Named arguments eliminated this entirely.
// Before: Which argument is which? Good luck.
htmlspecialchars($string, ENT_COMPAT | ENT_HTML401, 'UTF-8', false);
// After: Crystal clear intent.
htmlspecialchars($string, double_encode: false);
Named arguments aren't just syntactic sugar — they're self-documenting code. When you read double_encode: false, you know exactly what's happening without checking the docs. They also let you skip optional parameters entirely, calling only what matters.
Combined with phparray_slice and other functions that have many optional parameters, named arguments turn frustrating API calls into readable, maintainable code.
Significance: Readability
Union Types and Intersection Types
PHP's type system took a giant leap in 8.0 with union types, and again in 8.1 with intersection types. You can now express exactly what a function accepts or returns — no more docblock-only type hints.
// Union types (PHP 8.0): accept multiple types
function processInput(int|string $input): string|false {
return is_int($input) ? str_pad((string)$input, 5, '0', STR_PAD_LEFT) : $input;
}
// Intersection types (PHP 8.1): require multiple interfaces
function saveEntity(Countable&Iterator $collection): void {
foreach ($collection as $item) {
// We know it's both Countable AND Iterator
}
}
// DNF types (PHP 8.2): combine both
function process((Countable&Iterator)|null $items): void {
// Nullable intersection type — the full expression
}
The type system went from "basically untyped" to one of the most expressive among dynamic languages. Static analyzers like PHPStan and Psalm can now catch entire categories of bugs at analysis time rather than runtime.
Significance: Type Safety
Enums — Finally, Proper Enumerations
For decades, PHP developers faked enums with class constants, abstract classes full of const values, or — worst of all — magic strings. PHP 8.1 delivered real, first-class enums that are type-safe, autocompletable, and impossible to misuse.
// Pure enum — when you just need named cases
enum Suit {
case Hearts;
case Diamonds;
case Clubs;
case Spades;
}
// Backed enum — when you need database/API values
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 => 'Awaiting Review',
};
}
}
// Type-safe function signatures
function setStatus(Status $status): void {
// No invalid values possible — the type system enforces it
}
setStatus(Status::Active); // ✓
setStatus('active'); // TypeError — exactly what we want
Enums can implement interfaces, use traits, and have methods. They're a proper part of the type system, not a bolted-on afterthought. The match expression ensures exhaustive handling — miss a case and your static analyzer catches it.
Significance: Correctness
Status instead of string, it's impossible to pass a misspelled value, an outdated constant, or an empty string. The type system does the validation for you.str_contains(), str_starts_with(), str_ends_with()
This was the single most-requested feature in PHP's history, and 8.0 finally delivered. No more strpos() !== false gymnastics, no more off-by-one risks with index 0, no more substr($str, 0, strlen($prefix)) === $prefix.
// Before — the classic footgun
if (strpos($url, 'https') !== false) { /* ... */ } // Easy to write === 0 when you mean !== false
if (substr($file, -4) === '.php') { /* ... */ } // Works but reads terribly
// After — say what you mean
if (str_contains($url, 'https')) { /* ... */ }
if (str_starts_with($file, '/var/www')) { /* ... */ }
if (str_ends_with($file, '.php')) { /* ... */ }
These functions are named exactly as you'd expect. They take the haystack first, needle second — consistently. They return booleans. There is zero ambiguity about what they do or how to use them. This is what consistency looks like when a language team listens to its community.
Significance: Consistency
Array Unpacking with String Keys
PHP 7.4 introduced the spread operator for arrays, but it only worked with integer keys. PHP 8.1 completed the feature by supporting string keys — making array merging clean and expressive.
$defaults = ['timeout' => 30, 'retries' => 3, 'verify' => true];
$custom = ['timeout' => 60, 'debug' => true];
// Clean, readable merge with override semantics
$config = [...$defaults, ...$custom];
// ['timeout' => 60, 'retries' => 3, 'verify' => true, 'debug' => true]
// Works beautifully in function calls too
function createClient(string $host, array $options = []) {
$opts = [...self::DEFAULT_OPTIONS, ...$options];
// ...
}
The spread operator for arrays follows the same ... syntax used for function arguments, keeping the language consistent. Later values override earlier ones, just like phparray_merge, but with cleaner syntax.
Significance: Expressiveness
array_merge().Fibers — Proper Async Primitives
PHP 8.1 introduced Fibers: lightweight, cooperatively-scheduled coroutines. They're the foundation that frameworks like ReactPHP, Amp, and Revolt use to provide async I/O without callback hell.
$fiber = new Fiber(function (): void {
$value = Fiber::suspend('paused');
echo "Resumed with: $value\n";
});
$result = $fiber->start(); // "paused"
$fiber->resume('hello'); // "Resumed with: hello"
// Real-world: async HTTP with Revolt/Amp
use function Amp\async;
use function Amp\Future\await;
$responses = await([
async(fn() => $httpClient->request('GET', '/users')),
async(fn() => $httpClient->request('GET', '/posts')),
async(fn() => $httpClient->request('GET', '/comments')),
]);
// All three requests ran concurrently!
Fibers aren't meant to be used directly by most developers — they're an infrastructure primitive. But they enabled the PHP async ecosystem to mature rapidly, giving framework authors the tools to build ergonomic async APIs that look and feel like synchronous code.
Significance: Foundation
Composer — Best-in-Class Dependency Management
Composer didn't just give PHP a package manager — it gave PHP one of the best package managers in any language. Semantic versioning, autoloading, platform requirements, scripts, and a rich ecosystem of 350,000+ packages on Packagist.
// composer.json — clean, declarative, powerful
{
"require": {
"php": "^8.1",
"laravel/framework": "^11.0",
"league/flysystem": "^3.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
// That's it. Run `composer install` and everything works.
// PSR-4 autoloading means no more require/include spaghetti:
use App\Services\PaymentGateway;
$gateway = new PaymentGateway(); // Autoloaded. No require() needed.
Composer solved autoloading (via PSR-0/PSR-4), dependency resolution, and package distribution in one tool. The composer.lock file ensures reproducible builds. The platform-check plugin catches PHP version mismatches before deployment. It's genuinely world-class infrastructure.
Significance: Ecosystem
Built-in Development Server
No Apache config. No Nginx. No Docker. Just one command and you're running:
$ php -S localhost:8000
PHP Development Server started at http://localhost:8000
Document root is /var/www/myapp
Press Ctrl-C to quit.
# With a router script:
$ php -S localhost:8000 router.php
# Serve from a specific directory:
$ php -S localhost:8000 -t public/
The built-in server ships with every PHP installation since 5.4. It supports routing scripts, custom document roots, and outputs request logs to your terminal. For development, prototyping, and quick demos, it's unbeatable. Zero configuration, zero dependencies, instant feedback.
While not meant for production, it's perfectly adequate for local development — and it means any machine with PHP installed can serve a web application immediately.
Significance: Accessibility
PDO — Clean, Consistent Database Abstraction
PDO provides a uniform interface for accessing databases in PHP. MySQL, PostgreSQL, SQLite, SQL Server — same API, same prepared statements, same error handling. Switch databases without rewriting your data layer.
$pdo = new PDO('sqlite:app.db');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
// Prepared statements — SQL injection is simply not possible
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email AND active = :active');
$stmt->execute(['email' => $email, 'active' => true]);
$user = $stmt->fetch();
// Transactions with automatic rollback
try {
$pdo->beginTransaction();
$pdo->prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?')->execute([100, $from]);
$pdo->prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?')->execute([100, $to]);
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
throw $e;
}
PDO's prepared statements make SQL injection protection the path of least resistance. The parameterized query API is cleaner than string concatenation, so doing the safe thing is also the easy thing.
Significance: Security by Design
Arrow Functions
PHP 7.4 introduced short closures with the fn keyword. They automatically capture variables from the parent scope (no use() needed), have implicit returns, and are perfect for callbacks.
// Before: verbose closure with explicit `use`
$doubled = array_map(function ($n) {
return $n * 2;
}, $numbers);
// After: clean, concise, readable
$doubled = array_map(fn($n) => $n * 2, $numbers);
// Parent scope is captured automatically
$tax = 0.21;
$withTax = array_map(fn($price) => $price * (1 + $tax), $prices);
// Great for sorting
usort($users, fn($a, $b) => $a->name <=> $b->name);
// Chain beautifully
$result = array_filter(
array_map(fn($u) => $u->getProfile(), $users),
fn($p) => $p->isActive(),
);
Arrow functions don't replace regular closures — they complement them. Use fn for simple transforms and predicates, full closures for multi-line logic. The language gives you both tools and lets you choose.
Significance: Ergonomics
Match Expressions
match is what switch should have been. It uses strict comparison, returns a value, doesn't fall through, and throws an error if no arm matches. It's an expression, not a statement.
// switch: verbose, fall-through prone, loose comparison
switch ($statusCode) {
case 200:
case 201:
$text = 'Success';
break;
case 404:
$text = 'Not Found';
break;
default:
$text = 'Unknown';
break;
}
// match: concise, strict, returns a value
$text = match($statusCode) {
200, 201 => 'Success',
404 => 'Not Found',
500 => 'Server Error',
default => 'Unknown',
};
// No expression arms — use match(true) for conditions
$category = match(true) {
$age < 13 => 'child',
$age < 18 => 'teenager',
$age < 65 => 'adult',
default => 'senior',
};
The fact that match throws UnhandledMatchError when no arm matches is a feature, not a bug. It forces you to handle all cases explicitly, catching logic errors at runtime instead of silently producing wrong results.
Significance: Safety
switch fall-through bug that ever shipped to production was a consequence of switch's design. match makes the common case (no fall-through, strict comparison, return a value) the default, and eliminates the break ceremony entirely.Null Coalescing Operator ?? and ??=
The null coalescing operator is the perfect tool for defaults. It checks for null (and unset variables) without triggering notices, and it chains beautifully.
// Before: verbose isset checks
$username = isset($_GET['user']) ? $_GET['user'] : 'anonymous';
$config = isset($options['timeout']) ? $options['timeout'] : 30;
// After: clean and obvious
$username = $_GET['user'] ?? 'anonymous';
$config = $options['timeout'] ?? 30;
// Chaining — try multiple sources, fall back gracefully
$color = $user->preference('color') ?? $team->default('color') ?? '#777BB4';
// Null coalescing assignment (PHP 7.4)
$this->cache ??= []; // Initialize only if null
$options['retries'] ??= 3; // Set default without overwriting
// Perfect for lazy initialization
public function getLogger(): LoggerInterface {
return $this->logger ??= new NullLogger();
}
Unlike the ternary operator, ?? specifically checks for null — not falsy values. 0, '', and false pass through, which is almost always what you want. This distinction alone prevents countless bugs.
Significance: Pragmatism
isset check into three characters, making the most common PHP pattern — "use this value or fall back to that one" — effortless to write and read.Typed Properties
PHP 7.4 introduced type declarations for class properties. No more hoping someone passes the right type, no more docblock-only contracts — the engine enforces it.
class Product {
public string $name;
public float $price;
public ?string $description = null;
public array $tags = [];
public DateTimeInterface $createdAt;
public function __construct(string $name, float $price) {
$this->name = $name;
$this->price = $price;
$this->createdAt = new DateTimeImmutable();
}
}
$product = new Product('Widget', 9.99);
$product->price = 'free'; // TypeError: Cannot assign string to property Product::$price of type float
// Combined with union types (PHP 8.0):
public int|float $quantity;
// And intersection types (PHP 8.1):
public (Stringable&Countable) $value;
Typed properties work with all PHP types: scalars, arrays, classes, interfaces, nullable types, union types, and intersection types. The engine enforces them on every assignment, catching type errors immediately rather than letting corrupt data propagate through your system.
Significance: Reliability
$product->price is a float without checking. This cascading trust eliminates defensive type-checking throughout your codebase.Constructor Property Promotion
PHP 8.0 eliminated the most tedious boilerplate in the language: declaring a property, listing it as a constructor parameter, and assigning one to the other. Three places to maintain the same information, reduced to one.
// Before: say the same thing three times
class User {
private string $name;
private string $email;
private int $age;
private bool $active;
public function __construct(string $name, string $email, int $age, bool $active = true) {
$this->name = $name;
$this->email = $email;
$this->age = $age;
$this->active = $active;
}
}
// After: say it once
class User {
public function __construct(
private string $name,
private string $email,
private int $age,
private bool $active = true,
) {}
}
// Combined with readonly (PHP 8.1) — immutable value objects in one line:
class Point {
public function __construct(
public readonly float $x,
public readonly float $y,
) {}
}
You can mix promoted and non-promoted parameters freely. Promoted properties support all visibility modifiers and the readonly flag. It's one of those features that, once you use it, you can never go back.
Significance: Developer Experience
Readonly Properties and Classes
PHP 8.1 added readonly properties — set once, then immutable. PHP 8.2 extended this to entire classes. Immutability is no longer a convention; it's enforced by the engine.
// Readonly properties (PHP 8.1)
class Invoice {
public function __construct(
public readonly string $number,
public readonly float $total,
public readonly DateTimeImmutable $issuedAt,
) {}
}
$invoice = new Invoice('INV-001', 250.00, new DateTimeImmutable());
$invoice->total = 0; // Error: Cannot modify readonly property Invoice::$total
// Readonly classes (PHP 8.2) — all properties are implicitly readonly
readonly class Money {
public function __construct(
public int $amount,
public string $currency,
) {}
public function add(Money $other): self {
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Currency mismatch');
}
return new self($this->amount + $other->amount, $this->currency);
}
}
$price = new Money(1000, 'EUR');
$tax = new Money(210, 'EUR');
$total = $price->add($tax); // New object — originals unchanged
Readonly properties make value objects trivial to implement correctly. No more writing private properties with getters, no more worrying about someone mutating shared state. The language guarantees immutability.
Significance: Correctness
First-Class Callable Syntax
PHP 8.1 introduced a clean way to create closures from existing functions and methods using the (...) syntax. No more string-based function references, no more Closure::fromCallable().
// Before: strings as callables — no static analysis, no autocompletion
$lengths = array_map('strlen', $strings); // String reference
$filtered = array_filter($items, [$this, 'isValid']); // Array reference
// After: real, type-safe callable references
$lengths = array_map(strlen(...), $strings);
$filtered = array_filter($items, $this->isValid(...));
// Works with static methods, named functions, everything:
$encoder = json_encode(...);
$sorter = strcmp(...);
$validator = Validator::validate(...);
// They're real Closures — you can pass, store, and compose them:
$pipeline = array_reduce(
[trim(...), strtolower(...), htmlspecialchars(...)],
fn($carry, $fn) => fn($x) => $fn($carry($x)),
fn($x) => $x,
);
First-class callables are proper Closure objects that IDEs can analyze, static analyzers can check, and refactoring tools can rename. They make PHP's functional capabilities genuinely first-class.
Significance: Composability
Attributes — Native Metadata
PHP 8.0 replaced the docblock-annotation hack with proper, first-class attributes. They're real syntax, parseable by the engine, checked by static analysis, and validated at compile time.
// Define an attribute
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Route {
public function __construct(
public string $path,
public string $method = 'GET',
) {}
}
// Use it — clean, native syntax
class UserController {
#[Route('/users', method: 'GET')]
public function index(): Response { /* ... */ }
#[Route('/users/{id}', method: 'GET')]
#[Route('/user/{id}', method: 'GET')] // Repeatable!
public function show(int $id): Response { /* ... */ }
#[Route('/users', method: 'POST')]
#[RequiresAuth(role: 'admin')]
public function create(): Response { /* ... */ }
}
// Read attributes via Reflection
$method = new ReflectionMethod(UserController::class, 'index');
$routes = $method->getAttributes(Route::class);
foreach ($routes as $attr) {
$route = $attr->newInstance(); // Route object with path and method
}
Attributes replaced the fragile docblock annotation ecosystem (Doctrine Annotations) with something that's part of the language. They support named arguments, validation via reflection, and target constraints. Frameworks like Symfony and Laravel adopted them immediately.
Significance: Standardization
Named Arguments
Named arguments (PHP 8.0) let you pass values by parameter name instead of position. They make complex function calls readable, let you skip optional parameters, and serve as inline documentation.
// setcookie has 7 parameters. Which is which?
setcookie('theme', 'dark', 0, '/', '', true, true);
// Named arguments: crystal clear intent
setcookie('theme', 'dark', httponly: true, secure: true);
// Perfect for functions with boolean flags
$text = str_pad($input, length: 20, pad_type: STR_PAD_LEFT);
// Named arguments + array unpacking = powerful patterns
$defaults = ['secure' => true, 'httponly' => true, 'samesite' => 'Strict'];
setcookie('token', $value, ...$defaults);
// Great for test readability
$user = UserFactory::create(
name: 'Alice',
email: 'alice@example.com',
role: Role::Admin,
verified: true,
);
Named arguments work with all functions — built-in and user-defined. They interoperate with positional arguments (positional first, then named). They even work with phpcall_user_func_array when you pass an associative array.
Significance: Readability
array_map, array_filter, array_reduce — The Functional Trio
PHP's array functions have always been powerful, but with arrow functions (7.4) and first-class callables (8.1), they've become genuinely elegant. Transform, filter, and reduce collections without a single foreach loop.
$orders = getOrders();
// Transform: extract what you need
$totals = array_map(fn($o) => $o->total, $orders);
// Filter: keep what matches
$large = array_filter($orders, fn($o) => $o->total > 100);
// Reduce: collapse to a single value
$sum = array_reduce($orders, fn($carry, $o) => $carry + $o->total, 0);
// Compose them for expressive pipelines
$report = array_reduce(
array_map(
fn($o) => ['month' => $o->date->format('Y-m'), 'total' => $o->total],
array_filter($orders, fn($o) => $o->status === Status::Completed),
),
function ($acc, $item) {
$acc[$item['month']] = ($acc[$item['month']] ?? 0) + $item['total'];
return $acc;
},
[],
);
// Plus the underrated helpers:
$keys = array_keys($map);
$values = array_values($filtered); // Re-index after filter
$combined = array_combine($keys, $values);
$unique = array_unique($tags);
$chunks = array_chunk($items, 50); // Batch processing
PHP arrays are ordered hash maps — they work as lists, dictionaries, sets, stacks, and queues. The standard library gives you phparray_map, phparray_filter, phparray_reduce, and 75+ other array functions out of the box.
Significance: Versatility
Closures and Variable Binding with use()
PHP closures explicitly capture variables from the enclosing scope with use(). This isn't a limitation — it's a feature. You always know exactly what a closure depends on, making the code easier to reason about and debug.
// Explicit capture — no hidden dependencies
$multiplier = 1.21; // VAT rate
$applyVat = function (float $price) use ($multiplier): float {
return $price * $multiplier;
};
// Capture by reference for stateful closures
function createCounter(int $start = 0): Closure {
$count = $start;
return function () use (&$count): int {
return $count++;
};
}
$counter = createCounter();
echo $counter(); // 0
echo $counter(); // 1
// Closures can bind to objects — powerful for DSLs
$closure = Closure::bind(function () {
return $this->secret; // Access private property
}, $object, get_class($object));
// Middleware pattern with closures
$middleware = function (Request $request, Closure $next): Response {
// Before
$response = $next($request);
// After
return $response;
};
The explicit use() clause means you can look at any closure and immediately see its external dependencies. Combined with Closure::bind() and Closure::fromCallable(), PHP closures are flexible enough for any functional or object-oriented pattern.
Significance: Explicitness
use() clause makes the closure's contract visible: you see exactly what it captures, whether by value or reference. Explicit is better than implicit.Generators and yield
Generators let you iterate over data without loading everything into memory. Process a million-row CSV, stream API results, or build infinite sequences — all with constant memory usage.
// Read a 10GB file with constant memory
function readLines(string $file): Generator {
$handle = fopen($file, 'r');
while (($line = fgets($handle)) !== false) {
yield trim($line);
}
fclose($handle);
}
foreach (readLines('/var/log/huge.log') as $line) {
// Each line is read one at a time — never all in memory
}
// Generate infinite sequences
function fibonacci(): Generator {
[$a, $b] = [0, 1];
while (true) {
yield $a;
[$a, $b] = [$b, $a + $b];
}
}
// Delegate with yield from
function allUsers(): Generator {
yield from getAdmins(); // Generator
yield from getEditors(); // Generator
yield from [User::guest()]; // Array — also works!
}
// Two-way communication with send()
function accumulator(): Generator {
$total = 0;
while (true) {
$value = yield $total;
$total += $value;
}
}
$acc = accumulator();
$acc->current(); // 0
$acc->send(10); // 10
$acc->send(20); // 30
Generators follow the same Iterator interface as any other iterable, so they work seamlessly with foreach, phpiterator_to_array, and the spread operator.
Significance: Efficiency
Built-in JSON Support
JSON encoding and decoding is built into PHP core. No packages to install, no extensions to enable — it just works. Two functions, consistently named, with rich option flags.
// Encode — with formatting options
$data = ['users' => [['name' => 'Alice', 'age' => 30], ['name' => 'Bob', 'age' => 25]]];
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
// Decode — returns associative arrays or objects
$decoded = json_decode($json, associative: true); // Named argument!
echo $decoded['users'][0]['name']; // Alice
// Error handling (PHP 7.3+): throw on invalid JSON
$result = json_decode($input, flags: JSON_THROW_ON_ERROR);
// Throws JsonException instead of returning null silently
// Implement JsonSerializable for custom objects
class Money implements JsonSerializable {
public function __construct(
private int $cents,
private string $currency,
) {}
public function jsonSerialize(): mixed {
return [
'amount' => $this->cents / 100,
'currency' => $this->currency,
];
}
}
echo json_encode(new Money(1999, 'USD'));
// {"amount":19.99,"currency":"USD"}
The phpjson_encode and phpjson_decode functions handle all edge cases: Unicode, numeric precision, recursive structures, custom serialization. They're fast (PHP's JSON extension is implemented in C) and battle-tested across millions of applications.
Significance: Web-Native
DateTime and DateTimeImmutable
PHP's DateTime classes handle time zones, formatting, parsing, intervals, and arithmetic correctly. DateTimeImmutable ensures date calculations never accidentally mutate the original — a lesson learned from years of DateTime bugs.
// DateTimeImmutable — every operation returns a new instance
$now = new DateTimeImmutable();
$nextWeek = $now->modify('+1 week'); // $now is unchanged
$formatted = $now->format('Y-m-d H:i'); // "2026-03-12 14:30"
// Time zone handling — built in, not bolted on
$ny = new DateTimeImmutable('now', new DateTimeZone('America/New_York'));
$tokyo = $ny->setTimezone(new DateTimeZone('Asia/Tokyo'));
echo $tokyo->format('H:i'); // Correct local time in Tokyo
// Intervals and periods
$interval = new DateInterval('P30D'); // 30 days
$future = $now->add($interval);
$diff = $future->diff($now);
echo $diff->days; // 30
// Date periods — iterate over date ranges
$period = new DatePeriod(
new DateTimeImmutable('2026-01-01'),
new DateInterval('P1M'),
new DateTimeImmutable('2026-12-31'),
);
foreach ($period as $month) {
echo $month->format('F Y') . "\n"; // "January 2026", "February 2026", ...
}
// Parse anything
$parsed = DateTimeImmutable::createFromFormat('d/m/Y', '12/03/2026');
The distinction between phpDateTimeImmutable and phpDateTime is one of PHP's best design decisions. Use DateTimeImmutable everywhere and date-related bugs virtually disappear.
Significance: Correctness
password_hash() and password_verify()
PHP 5.5 gave developers secure password hashing with the simplest possible API. Two functions. No salt management. No algorithm selection anxiety. It defaults to bcrypt with a reasonable cost, and it's future-proof.
// Hash a password — that's it
$hash = password_hash('correct horse battery staple', PASSWORD_DEFAULT);
// $2y$12$eUz3RiQ... (bcrypt, cost 12, random salt — all automatic)
// Verify — constant-time comparison, safe against timing attacks
if (password_verify($userInput, $storedHash)) {
// Correct password
}
// Future-proof: check if rehashing is needed (algorithm/cost changed)
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
$newHash = password_hash($userInput, PASSWORD_DEFAULT);
updateStoredHash($userId, $newHash);
}
// Argon2 support (PHP 7.2+)
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => PASSWORD_ARGON2_DEFAULT_MEMORY_COST,
'time_cost' => PASSWORD_ARGON2_DEFAULT_TIME_COST,
'threads' => PASSWORD_ARGON2_DEFAULT_THREADS,
]);
// Get algorithm info
$info = password_get_info($hash);
// ['algo' => '2y', 'algoName' => 'bcrypt', 'options' => ['cost' => 12]]
The phppassword_hash function generates a random salt automatically, encodes the algorithm and cost in the hash string, and uses a timing-safe comparison. It's nearly impossible to misuse. When PASSWORD_DEFAULT changes to a stronger algorithm in a future PHP version, phppassword_needs_rehash handles the migration transparently.
Significance: Security by Default
password_hash(), developers used md5(), sha1(), or rolled their own salting. The result was millions of insecure password stores. By making the secure approach the easiest approach — literally two functions — PHP eliminated an entire class of security vulnerabilities across its ecosystem.Interfaces, Traits, and Abstract Classes
PHP's OOP toolkit gives you three complementary tools for code organization: interfaces for contracts, abstract classes for shared structure, and traits for horizontal code reuse. Together, they solve the diamond problem without multiple inheritance.
// Interface: define the contract
interface Cacheable {
public function getCacheKey(): string;
public function getCacheTTL(): int;
}
// Trait: reuse implementation across unrelated classes
trait HasTimestamps {
public DateTimeImmutable $createdAt;
public ?DateTimeImmutable $updatedAt = null;
public function touch(): void {
$this->updatedAt = new DateTimeImmutable();
}
}
// Abstract class: shared structure with extension points
abstract class Model implements Cacheable {
use HasTimestamps;
abstract protected function tableName(): string;
public function getCacheKey(): string {
return $this->tableName() . ':' . $this->id;
}
public function getCacheTTL(): int {
return 3600;
}
}
// Concrete: just fill in the blanks
class User extends Model {
use SoftDeletes; // Another trait — compose freely
protected function tableName(): string {
return 'users';
}
}
The combination is powerful: interfaces ensure interoperability, abstract classes provide sensible defaults, and traits let you compose behavior horizontally without deep inheritance hierarchies. Modern PHP codebases are flat and composable, not deeply nested.
Significance: Architecture
Late Static Binding
Late static binding (static:: vs self::) lets parent classes defer method resolution to the child class. It's the foundation of the fluent factory pattern used by every modern PHP framework.
class Model {
protected static string $table;
// self:: would always resolve to Model — wrong!
// static:: resolves to whatever class called the method
public static function find(int $id): static {
$table = static::$table;
$row = DB::query("SELECT * FROM {$table} WHERE id = ?", [$id]);
return static::hydrate($row); // Returns User, not Model
}
public static function create(array $data): static {
// static:: ensures the child class is instantiated
$instance = new static();
foreach ($data as $key => $value) {
$instance->$key = $value;
}
$instance->save();
return $instance;
}
}
class User extends Model {
protected static string $table = 'users';
}
class Post extends Model {
protected static string $table = 'posts';
}
$user = User::find(1); // Returns User instance, queries 'users' table
$post = Post::create([ // Returns Post instance
'title' => 'Hello',
]);
The static return type (PHP 8.0) completed this feature by letting you declare that a method returns an instance of the called class, not the declaring class. This is what makes fluent builder patterns type-safe.
Significance: Polymorphism
Anonymous Classes
PHP 7.0 added anonymous classes — inline, disposable class definitions. They're perfect for one-off implementations, testing, and anywhere you'd create a class that's only used once.
// Perfect for tests — no need to create a file for a stub
$logger = new class implements LoggerInterface {
public array $logs = [];
public function log($level, $message, array $context = []): void {
$this->logs[] = compact('level', 'message', 'context');
}
// ... other PSR-3 methods
};
$service = new MyService($logger);
$service->doWork();
$this->assertCount(3, $logger->logs);
// Inline adapters
function createCacheAdapter(array &$store): CacheInterface {
return new class($store) implements CacheInterface {
public function __construct(private array &$store) {}
public function get(string $key, mixed $default = null): mixed {
return $this->store[$key] ?? $default;
}
public function set(string $key, mixed $value, int $ttl = 0): bool {
$this->store[$key] = $value;
return true;
}
};
}
// They support everything normal classes do:
// constructors, interfaces, traits, inheritance, properties
$event = new class('click', ['x' => 100]) extends Event implements Serializable {
use HasMetadata;
public function __construct(public string $type, public array $data) {
parent::__construct();
}
};
Anonymous classes reduce file proliferation. Instead of creating StubLogger.php for a single test, you define it inline where it's used. The class exists only in the scope where it's created — no namespace pollution, no autoloading overhead.
Significance: Pragmatism
Heredoc/Nowdoc Flexibility
PHP 7.3 made heredocs and nowdocs actually usable by allowing the closing marker to be indented, and letting heredocs be used in any expression context. No more breaking your code's indentation for multi-line strings.
// Before PHP 7.3: closing marker must be at column 0
// This breaks indentation in methods, conditions, everywhere
function render() {
$html = <<<HTML
<div class="card">
<h2>{$title}</h2>
<p>{$body}</p>
</div>
HTML; // ← HAD to be here, column 0. Ugly.
}
// After PHP 7.3: indented closing marker, content is de-indented
function render() {
$html = <<<HTML
<div class="card">
<h2>{$title}</h2>
<p>{$body}</p>
</div>
HTML; // ← indented with the code. Clean.
}
// Can now be used in function arguments directly
$response = response(<<<JSON
{
"status": "ok",
"data": {$payload}
}
JSON);
// Nowdoc (no interpolation) works the same way
$query = <<<'SQL'
SELECT users.*, COUNT(orders.id) as order_count
FROM users
LEFT JOIN orders ON orders.user_id = users.id
GROUP BY users.id
SQL;
This seemingly small change had a huge impact on code readability. SQL queries, HTML templates, JSON fixtures, and multi-line strings can now live inside your code without destroying the visual structure.
Significance: Readability
Trailing Commas Everywhere
PHP progressively allowed trailing commas in more places: arrays (always), function calls (7.3), parameter lists (8.0), and closure use lists (8.0). Clean diffs, easy reordering, no syntax errors when adding items.
// Arrays (always supported — PHP was ahead of JS here!)
$config = [
'debug' => true,
'cache' => false,
'log_level' => 'info', // ← trailing comma, always worked
];
// Function/method calls (PHP 7.3)
$result = sprintf(
'%s has %d items worth $%.2f',
$name,
$count,
$total, // ← no more removing this comma when adding a line below
);
// Function/method declarations (PHP 8.0)
function createUser(
string $name,
string $email,
Role $role = Role::User, // ← add new params without touching this line
) {
// ...
}
// Closure use lists (PHP 8.0)
$fn = function () use (
$config,
$logger,
$cache, // ← consistent everywhere
) {
// ...
};
Trailing commas mean cleaner git diffs: adding a new item only shows one changed line, not two (the new line plus the comma added to the previous line). They also make reordering lines trivial — no comma juggling.
Significance: Developer Experience
Nullsafe Operator ?->
The nullsafe operator (PHP 8.0) short-circuits a method chain when any intermediate value is null. No more nested if statements or ternary chains just to safely traverse an object graph.
// Before: defensive null checking at every step
$country = null;
if ($user !== null) {
$address = $user->getAddress();
if ($address !== null) {
$city = $address->getCity();
if ($city !== null) {
$country = $city->getCountry();
}
}
}
// After: one clean expression
$country = $user?->getAddress()?->getCity()?->getCountry();
// Works with properties too
$name = $order?->customer?->profile?->displayName ?? 'Guest';
// Works with method calls and array access
$firstTag = $post?->getTags()?->first()?->name;
// Real-world: Eloquent relationships
$managerEmail = $employee
?->department
?->manager
?->email
?? 'no-manager@company.com';
The nullsafe operator composes perfectly with the null coalescing operator (??). Use ?-> to safely traverse, then ?? to provide a default. It's the complete null-handling toolkit in two operators.
Significance: Expressiveness
??, PHP now has best-in-class null handling.json_validate() — Validate Without Decoding
PHP 8.3 added phpjson_validate — a function that checks if a string is valid JSON without actually decoding it. Before this, you had to call json_decode() and check json_last_error(), which meant allocating memory for a data structure you didn't need.
// Before: decode the entire payload just to check if it's valid
$data = json_decode($input);
if (json_last_error() !== JSON_ERROR_NONE) {
return 'Invalid JSON';
}
// After: validate without allocating a single array or object
if (!json_validate($input)) {
return 'Invalid JSON';
}
// With depth limit — protect against deeply nested payloads
if (!json_validate($input, depth: 32)) {
return 'JSON too deeply nested';
}
// Common pattern: validate first, decode only if valid
if (json_validate($payload)) {
$data = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
processData($data);
}
This is especially valuable for API gateways and middleware that need to validate JSON bodies before routing them — you skip the memory cost of decoding entirely.
Significance: Performance
json_validate() recognizes this: when you only need a yes/no answer, you shouldn't pay the memory cost of building the decoded structure. It's the kind of targeted optimization that shows a maturing standard library.#[\Override] Attribute — Safe Method Overriding
PHP 8.3 added the #[\Override] attribute. When you mark a method with it, PHP guarantees that a parent class or interface actually declares that method. If the parent method is renamed or removed, you get an error immediately instead of silently having dead code.
class Base {
protected function validate(): bool {
return true;
}
}
class Strict extends Base {
#[\Override]
protected function validate(): bool {
// If Base::validate() is ever renamed or removed,
// PHP throws a fatal error here. No silent breakage.
return parent::validate() && $this->extraChecks();
}
}
// Works with interfaces too
interface Logger {
public function log(string $message): void;
}
class FileLogger implements Logger {
#[\Override]
public function log(string $message): void {
file_put_contents('app.log', $message . PHP_EOL, FILE_APPEND);
}
}
This is borrowed from Java's @Override and TypeScript's override keyword — both proven to prevent bugs during refactoring. It's opt-in, so you only add it where correctness matters.
Significance: Refactoring Safety
#[\Override], renaming a parent method silently turns the child method into dead code — a bug that no test will catch until someone notices the override isn't executing. This attribute makes refactoring across class hierarchies safe.Typed Class Constants
PHP 8.3 brought type declarations to class constants — the last place in the language where types were missing. Now constants in classes, interfaces, traits, and enums can declare their type, and child classes can't accidentally change it.
interface HasVersion {
const string VERSION = '1.0'; // Must be a string in all implementations
}
class Config implements HasVersion {
const string VERSION = '2.0'; // OK — still a string
const int MAX_RETRIES = 3; // Typed constant
const array DEFAULT_HEADERS = [ // Array type
'Accept' => 'application/json',
'X-Client' => 'php',
];
}
// Child classes can't violate the type contract
class BadConfig extends Config {
const string MAX_RETRIES = 'three'; // Fatal error: type mismatch
}
// Works with all type declarations
class Limits {
const int|string ID = 42; // Union types
const ?string LABEL = null; // Nullable
const true ENABLED = true; // Literal types (PHP 8.2)
}
Before typed constants, an interface could declare const VERSION = '1.0' but had no way to prevent an implementing class from changing it to const VERSION = 42. Typed constants close this gap.
Significance: Completeness
Property Hooks — get/set Without the Boilerplate
PHP 8.4's headline feature: property hooks let you define get and set behavior directly on a property declaration. No more writing boilerplate getter/setter methods. Properties can now have logic and still be accessed with $object->property syntax.
class User {
public string $fullName {
get => $this->firstName . ' ' . $this->lastName;
}
public string $email {
set(string $value) {
$this->email = strtolower(trim($value));
}
}
public function __construct(
public string $firstName,
public string $lastName,
public string $email,
) {}
}
$user = new User('Rasmus', 'Lerdorf', ' Rasmus@PHP.net ');
echo $user->fullName; // "Rasmus Lerdorf" — computed on access
echo $user->email; // "rasmus@php.net" — normalized on set
// Virtual properties — no backing storage needed
class Temperature {
public float $celsius {
get => ($this->fahrenheit - 32) / 1.8;
set(float $value) => $this->fahrenheit = $value * 1.8 + 32;
}
public function __construct(
public float $fahrenheit,
) {}
}
$t = new Temperature(212);
echo $t->celsius; // 100.0
$t->celsius = 0;
echo $t->fahrenheit; // 32.0
Property hooks work with interfaces (you can require a property to have a get or set hook), with readonly, and with constructor promotion. They're the biggest OOP addition since traits.
Significance: Paradigm Shift
Asymmetric Visibility — public private(set)
PHP 8.4 lets you set different visibility for reading and writing a property. The most common pattern: publicly readable, privately writable. No more writing getters just to expose a value you don't want externally modified.
class BankAccount {
public function __construct(
public readonly string $holder,
public private(set) float $balance = 0, // Read: public. Write: private.
) {}
public function deposit(float $amount): void {
if ($amount <= 0) throw new \InvalidArgumentException('Amount must be positive');
$this->balance += $amount; // Private write — OK
}
}
$account = new BankAccount('Alice', 100);
echo $account->balance; // 100.0 — public read
$account->balance = 0; // Error! — private write
$account->deposit(50); // OK — internal write
echo $account->balance; // 150.0
// Works with protected too
class Entity {
public protected(set) int $id; // Subclasses can write, outside can only read
public private(set) string $createdAt; // Only this class can write
}
Asymmetric visibility combines beautifully with property hooks and constructor promotion. Together, they give PHP one of the most expressive property systems in any language — read visibility, write visibility, get logic, set logic, and immutability, all declared inline.
Significance: Encapsulation
public private(set) says "anyone can read, only I can write" — exactly the intent behind most getter methods — in a single declaration.array_find(), array_any(), array_all()
PHP 8.4 added the array search and predicate functions that every developer has been writing by hand for years. No more array_filter + count hacks or manual loops just to answer "is there any?" or "do all match?"
$users = [
['name' => 'Alice', 'role' => 'admin'],
['name' => 'Bob', 'role' => 'editor'],
['name' => 'Charlie', 'role' => 'viewer'],
];
// Find the first match
$admin = array_find($users, fn($u) => $u['role'] === 'admin');
// ['name' => 'Alice', 'role' => 'admin']
// Find the key of the first match
$key = array_find_key($users, fn($u) => $u['role'] === 'editor');
// 1
// Does ANY element match?
$hasAdmin = array_any($users, fn($u) => $u['role'] === 'admin');
// true
// Do ALL elements match?
$allViewers = array_all($users, fn($u) => $u['role'] === 'viewer');
// false
// Short-circuits — stops as soon as the answer is known
$found = array_any($hugeArray, fn($item) => $item->isExpired());
// Stops at the first expired item, doesn't scan the rest
These functions accept callbacks, short-circuit when possible, and work with keys as well as values. They complement phparray_filter, phparray_map, and phparray_reduce to form a complete functional array toolkit.
Significance: Expressiveness
#[\Deprecated] Attribute
PHP 8.4 lets you mark your own functions, methods, and class constants as deprecated using a native attribute — the same mechanism PHP itself uses internally. When someone calls deprecated code, they get a proper E_USER_DEPRECATED notice with your custom message.
class PaymentService {
// Deprecate with a message and version since
#[\Deprecated("Use processPayment() instead", since: "3.2")]
public function charge(float $amount): bool {
return $this->processPayment($amount);
}
public function processPayment(float $amount): bool {
// New implementation
}
}
$service = new PaymentService();
$service->charge(50.00);
// Deprecated: Method PaymentService::charge() is deprecated since 3.2,
// use processPayment() instead
// Works on functions too
#[\Deprecated("Use generateUuid() instead")]
function createId(): string {
return generateUuid();
}
// And class constants
class Config {
#[\Deprecated("Use TIMEOUT_SECONDS instead")]
const TIMEOUT = 30;
const int TIMEOUT_SECONDS = 30;
}
Before this attribute, library authors had to manually trigger trigger_error() inside deprecated methods — cluttering the implementation and offering no standard format. Now deprecation is metadata, clean and consistent.
Significance: API Evolution
#[\Deprecated] attribute gives library authors a standard, engine-recognized way to guide users toward new APIs — without breaking backward compatibility or cluttering method bodies with trigger_error() calls.new Without Parentheses
PHP 8.4 allows chaining methods and accessing properties on a newly created object without wrapping new in parentheses. A small syntax fix that removes a long-standing annoyance.
// Before PHP 8.4: parentheses required for chaining
$name = (new ReflectionClass($obj))->getName();
$date = (new DateTime('now'))->format('Y-m-d');
$items = (new Collection([1, 2, 3]))->map(fn($n) => $n * 2)->toArray();
// After PHP 8.4: just chain directly
$name = new ReflectionClass($obj)->getName();
$date = new DateTime('now')->format('Y-m-d');
$items = new Collection([1, 2, 3])->map(fn($n) => $n * 2)->toArray();
// Property access too
$length = new SplFixedArray(10)->count();
// Array access
$first = new ArrayObject(['a', 'b', 'c'])[0];
This is the kind of paper-cut fix that makes a language more pleasant to use every day. Every PHP developer has hit this — you create an object, chain a method, get a syntax error, then add parentheses and grumble.
Significance: Fluency
new — was an arbitrary parser limitation that tripped up developers constantly. Removing it makes PHP's syntax do what you'd naturally expect.Pipe Operator |>
PHP 8.5's most anticipated feature: the pipe operator. It takes the value on the left and passes it as the first argument to the callable on the right. No more nested function calls or temporary variables — just clean, left-to-right data flow.
// Before: nested calls — read inside-out
$result = strtolower(str_replace(' ', '-', str_replace('.', '', trim($title))));
// Before: temporary variables — cluttered
$result = trim($title);
$result = str_replace('.', '', $result);
$result = str_replace(' ', '-', $result);
$result = strtolower($result);
// After: pipe — read left to right, like a unix pipeline
$slug = $title
|> trim(...)
|> (fn($s) => str_replace('.', '', $s))
|> (fn($s) => str_replace(' ', '-', $s))
|> strtolower(...);
// Perfect for data transformation pipelines
$report = $rawData
|> array_filter(fn($row) => $row['active'])
|> array_map(fn($row) => $row['revenue'], ...)
|> array_sum(...)
|> (fn($total) => number_format($total, 2));
The pipe operator uses first-class callable syntax (...) for built-in functions and arrow functions or closures when you need to customize argument positions. It's the functional programming primitive that PHP's been missing.
Significance: Readability
Clone With — Modify Properties While Cloning
PHP 8.5 turns clone into a function that accepts property overrides. This is the "wither" pattern that readonly classes desperately needed — create a modified copy without boilerplate.
readonly class Color {
public function __construct(
public int $red,
public int $green,
public int $blue,
public int $alpha = 255,
) {}
public function withAlpha(int $alpha): self {
return clone($this, ['alpha' => $alpha]);
}
public function darken(float $factor): self {
return clone($this, [
'red' => (int)($this->red * $factor),
'green' => (int)($this->green * $factor),
'blue' => (int)($this->blue * $factor),
]);
}
}
$blue = new Color(79, 91, 147);
$transparent = $blue->withAlpha(128);
$darkBlue = $blue->darken(0.5);
// $blue is unchanged — immutability preserved
echo $blue->alpha; // 255
echo $transparent->alpha; // 128
// Works with any class, not just readonly
$modified = clone($request, ['method' => 'POST', 'body' => $payload]);
Before PHP 8.5, creating a modified copy of a readonly object required manually passing every property to the constructor — even the ones that didn't change. clone() with overrides solves this elegantly.
Significance: Immutability
clone() with overrides, every readonly class needed hand-written wither methods that duplicated every property. Now the "with-er" pattern is a one-liner, making immutable design the path of least resistance.array_first() and array_last()
PHP 8.5 added the two most obviously-missing array functions. Get the first or last value of an array without reset() side effects, without array_key_first() + indexing, and without any of the other workarounds developers have been using for 25 years.
$events = ['signup', 'login', 'purchase', 'logout'];
$first = array_first($events); // 'signup'
$last = array_last($events); // 'logout'
// Returns null for empty arrays — compose with ??
$latest = array_last($logs) ?? 'No logs yet';
// Works with associative arrays too
$config = ['debug' => true, 'env' => 'prod', 'version' => '3.0'];
array_first($config); // true (first value)
array_last($config); // '3.0' (last value)
// Compare with the old ways:
$first = reset($array); // Mutates internal pointer!
$first = $array[array_key_first($array)]; // Verbose
$first = current(array_slice($array, 0, 1)); // Allocates a new array
$last = $array[array_key_last($array) ?? 0] ?? null; // Awkward null handling
The old approaches either mutated the array's internal pointer (reset()/end()), required two function calls, or created unnecessary intermediate arrays. array_first() and array_last() do exactly one thing, correctly, with no side effects.
Significance: Completeness
array_key_first() and array_key_last() since 7.3 but inexplicably not the value equivalents. It took six more years, but the standard library is finally complete for this basic operation. Sometimes the best features are the ones that should have existed all along.#[\NoDiscard] — Warn When Return Values Are Ignored
PHP 8.5 introduces the #[\NoDiscard] attribute, which tells the engine to emit a warning if the return value of a function or method is not used. It's essential for functions where ignoring the result is always a bug.
#[\NoDiscard("Validation result must be checked")]
function validate(array $data): ValidationResult {
// ... returns success or failure with error messages
return new ValidationResult($errors);
}
// This is almost certainly a bug — the result isn't checked
validate($formData);
// Warning: The return value of function validate() should either
// be used or intentionally ignored by casting it as (void)
// Correct usage
$result = validate($formData);
if ($result->hasErrors()) {
return response($result->errors(), 422);
}
// Intentionally ignore with (void) cast
(void) validate($data); // Explicit: "I know, I don't care"
// Perfect for immutable operations
readonly class Money {
#[\NoDiscard]
public function add(Money $other): self {
return clone($this, ['amount' => $this->amount + $other->amount]);
}
}
$price = new Money(100, 'USD');
$price->add(new Money(50, 'USD')); // Warning! Result discarded — $price is immutable
This catches a category of bugs that static analyzers already flag but that the engine previously ignored: calling an immutable method and discarding the result, skipping a validation check, or dropping an error code.
Significance: API Safety
#[\NoDiscard] lets API authors encode an important constraint: "you must use this return value." It catches the classic bug of calling a method on an immutable object without capturing the new value — a mistake that silently produces wrong results.Closures in Constant Expressions
PHP 8.5 allows static closures and first-class callables in constant expressions — meaning you can use them as default parameter values, in attribute arguments, and as class constant values. This unlocks patterns that were previously impossible.
// Closures as default parameter values
function process(
array $data,
Closure $transform = static fn($x) => $x, // Identity function default
): array {
return array_map($transform, $data);
}
process([1, 2, 3]); // [1, 2, 3] — default identity
process([1, 2, 3], fn($n) => $n * 2); // [2, 4, 6]
// First-class callables in attribute arguments
#[Attribute]
class Validator {
public function __construct(public Closure $rule) {}
}
class UserForm {
#[Validator(is_string(...))]
public string $name;
#[Validator(static fn($v) => strlen($v) >= 8)]
public string $password;
}
// Closures as class constants
class Transforms {
const Closure NORMALIZE = static fn(string $s) => strtolower(trim($s));
const Closure SLUGIFY = static fn(string $s) =>
preg_replace('/[^a-z0-9]+/', '-', strtolower(trim($s)));
}
This was one of the last restrictions on where closures could appear in PHP. Removing it makes callback-heavy APIs much cleaner — no more "pass null and we'll use a default callback" patterns.
Significance: Composability
Fatal Error Backtraces
PHP 8.5 finally gives you stack traces on fatal errors. Before, a fatal error like "Maximum execution time exceeded" would tell you what happened but not where in your code it happened. Now you get a full backtrace, just like exceptions.
// Before PHP 8.5:
// Fatal error: Maximum execution time of 30 seconds exceeded
// ... that's it. Good luck finding the infinite loop.
// After PHP 8.5:
// Fatal error: Maximum execution time of 30 seconds exceeded in /app/Services/Import.php on line 142
// Stack trace:
// #0 /app/Services/Import.php(142): processRow(Array)
// #1 /app/Services/Import.php(98): importBatch(Array)
// #2 /app/Console/Commands/Import.php(34): App\Services\Import->run()
// #3 /vendor/laravel/framework/src/Illuminate/Console/Command.php(115): handle()
// Also works for:
// - Allowed memory size exhausted
// - Stack overflow from infinite recursion
// - Any other fatal error
This is particularly valuable in production environments where reproducing a fatal error can be difficult. The backtrace tells you exactly which code path triggered the fatal, turning "something timed out somewhere" into an actionable bug report.
Significance: Debuggability
Import::processRow() on line 142" — one is a mystery, the other is a fix waiting to happen.URI Extension — Proper URL Parsing at Last
PHP 8.5 introduces a built-in URI extension with immutable, standards-compliant URL objects. It supports both RFC 3986 and the WHATWG URL Standard — replacing the limited phpparse_url with something that actually handles real-world URLs correctly.
use Uri\Rfc3986\Uri;
// Parse and inspect
$uri = new Uri('https://user:pass@example.com:8080/path?q=php#section');
echo $uri->getScheme(); // "https"
echo $uri->getHost(); // "example.com"
echo $uri->getPort(); // 8080
echo $uri->getPath(); // "/path"
echo $uri->getQuery(); // "q=php"
echo $uri->getFragment(); // "section"
// Immutable modification with fluent interface
$api = $uri
->withScheme('https')
->withHost('api.example.com')
->withPort(null)
->withPath('/v2/users')
->withQuery('page=1');
echo (string) $api; // "https://api.example.com/v2/users?page=1"
// WHATWG URL Standard — how browsers parse URLs
use Uri\WhatWg\Url;
$url = new Url('https://example.com/path/../other');
echo $url->getPath(); // "/other" — resolved, like a browser
// Proper validation — no more silent parse_url failures
try {
$bad = new Uri('not a url');
} catch (Uri\InvalidUriException $e) {
echo $e->getMessage();
}
The old parse_url() was famously unreliable — it silently returned partial results for malformed URLs and couldn't handle many valid URL formats. The new URI classes provide proper validation, normalization, and immutable modification.
Significance: Correctness
JIT Compilation — PHP Has a JIT Compiler
Yes, really. PHP 8.0 ships a tracing JIT compiler. For numeric and CPU-bound code, it delivers 3–10× speedups over interpreted execution. This is the feature PHP critics never mention.
; php.ini — enable tracing JIT (the most aggressive mode)
opcache.enable=1
opcache.jit_buffer_size=100M
opcache.jit=tracing
; Alternatively, function-level JIT for a lighter touch
opcache.jit=function
The JIT works in two modes. Function JIT compiles entire functions ahead-of-time. Tracing JIT profiles hot paths at runtime and compiles only the loops and branches that actually run — the same strategy used by V8 and LuaJIT. For web request workloads, OPcache alone gives most of the gains. Where JIT shines is pure computation: image processing, machine learning inference, mathematical simulations, and game servers written in PHP.
// Benchmark: sum of squares, 10 million iterations
// Without JIT: ~1.8 seconds
// With JIT: ~0.22 seconds (~8× faster)
$sum = 0.0;
for ($i = 0; $i < 10_000_000; $i++) {
$sum += $i * $i;
}
echo $sum; // 3.3333333283267E+20
The JIT is built on top of OPcache and shares its infrastructure. It doesn't change the semantics of PHP at all — code that runs correctly without JIT runs identically with it.
Significance: Performance
OPcache — Bytecode Caching in the Box
PHP compiles source code to bytecode on every request — unless OPcache is active. Bundled since PHP 5.5 and enabled by default in most distributions, OPcache stores compiled bytecode in shared memory so subsequent requests skip parsing and compilation entirely.
; php.ini — production-ready OPcache settings
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; disable in production, reload to pick up changes
opcache.save_comments=1 ; keep docblocks (needed by some frameworks)
opcache.revalidate_freq=0
No Redis. No Memcached. No build pipeline. OPcache is pure shared memory — the fastest possible cache. Setting validate_timestamps=0 in production means PHP never checks the filesystem for changes, which eliminates the most common I/O bottleneck in high-traffic applications.
// Check cache status at runtime
$status = opcache_get_status();
echo 'Cached scripts: ' . $status['opcache_statistics']['num_cached_scripts'];
echo 'Hit rate: ' . round($status['opcache_statistics']['opcache_hit_rate'], 2) . '%';
// Invalidate a single file after a deploy
opcache_invalidate('/var/www/app/src/Controller/HomeController.php', force: true);
// Or reset everything
opcache_reset();
Combined with JIT, OPcache transforms PHP from an interpreted language into something much closer to a compiled runtime — without changing a single line of application code.
Significance: Zero-Config Performance
never Return Type — Functions That Never Return
PHP 8.1 added the never return type for functions that unconditionally throw an exception or call exit(). It's a small addition with outsized benefits for static analysis and control flow clarity.
function redirect(string $url): never
{
header('Location: ' . $url);
exit();
}
function abort(int $code, string $message): never
{
throw new HttpException($code, $message);
}
// Static analysers understand the control flow
function findOrFail(int $id): User
{
$user = User::find($id);
if ($user === null) {
abort(404, 'User not found'); // analyser knows: never returns
}
return $user; // analyser knows: $user is User here, not User|null
}
Before never, static analysers had no way to know that abort() or redirect() would halt execution. They'd warn about missing return statements, or fail to narrow types after an early exit. With never, the entire call graph is understood: dead code after these calls is detected, and type narrowing works across function boundaries.
// PHPStan / Psalm understand this is unreachable:
function process(string|null $value): string
{
if ($value === null) {
abort(400, 'Required'); // never
}
return strtoupper($value); // $value is string here — no null check needed
}
Significance: Control Flow Clarity
never makes implicit contracts explicit. Helper functions that always throw or redirect were always present in PHP codebases — now they carry their contract in the type signature, where static analysers and future readers can rely on it.array_is_list() — Finally Answer "Is This Sequential?"
PHP has one array type that serves as both indexed arrays and associative maps. The question "is this a sequential list?" has been a recurring puzzle for decades. PHP 8.1 answered it definitively.
// An array is a "list" if keys are 0, 1, 2, ... in order
array_is_list([]); // true — empty array
array_is_list(['a', 'b', 'c']); // true — sequential
array_is_list([0 => 'a', 1 => 'b']); // true — same thing
array_is_list(['a' => 1, 'b' => 2]); // false — associative
array_is_list([1 => 'a', 0 => 'b']); // false — wrong order
array_is_list([0 => 'a', 2 => 'c']); // false — gap in keys
Before this, developers resorted to various imperfect heuristics: checking if array_keys($arr) === range(0, count($arr) - 1), or comparing json_encode outputs, or writing custom functions that iterated the entire array. All of them were slow, surprising, or both.
// Real-world use: serialise correctly based on structure
function toJson(array $data): string
{
// array_is_list tells us whether to encode as [] or {}
return json_encode($data, JSON_THROW_ON_ERROR);
// json_encode already does this internally — now you can too
}
// Useful for input validation
function validateItems(array $items): void
{
if (!array_is_list($items)) {
throw new \InvalidArgumentException('Expected a list, got a map');
}
}
Significance: Pragmatism
array_is_list() is the escape hatch: a single, fast, unambiguous check that settles the question at the language level.FFI — Call C Libraries Directly from PHP
PHP 7.4 introduced the Foreign Function Interface, letting you load shared libraries and call C functions directly — with no extension to compile, no PECL, and no separate process. This is the feature that reliably shocks people who dismissed PHP.
// Load the C math library and call cos() directly
$ffi = FFI::cdef(
'double cos(double x);', // C declaration
'libm.so.6' // shared library (Linux); 'libm.dylib' on macOS
);
echo $ffi->cos(M_PI); // -1.0 — called at C speed
FFI can work with C structs, pointers, arrays, and callbacks. You can load any shared library installed on the system — libsodium, libgd, OpenCV, even custom `.so` files you compiled yourself.
// Define and use a C struct
$ffi = FFI::cdef('
typedef struct {
int x;
int y;
} Point;
typedef struct {
Point origin;
int width;
int height;
} Rect;
');
$rect = $ffi->new('Rect');
$rect->origin->x = 10;
$rect->origin->y = 20;
$rect->width = 100;
$rect->height = 50;
echo "{$rect->origin->x}, {$rect->origin->y}"; // 10, 20
For performance-critical inner loops, you can preload FFI definitions at startup via ffi.preload in php.ini, making the binding cost negligible. Projects like phpReactPHP and PHP-ML use FFI for exactly this.
Significance: Systems Access
Random\Randomizer — A Proper CSPRNG API
PHP 8.2 replaced the scattered rand(), mt_rand(), and random_int() functions with a unified, object-oriented randomness API. The key innovation: swappable engines.
use Random\Engine\Secure;
use Random\Engine\Mt19937;
use Random\Engine\Xoshiro256StarStar;
use Random\Randomizer;
// Cryptographically secure — for tokens, passwords, keys
$secure = new Randomizer(new Secure());
$token = $secure->getBytes(32); // 32 random bytes
$hex = bin2hex($token); // 64-char hex token
// Seeded determinism — for reproducible tests or procedural generation
$seeded = new Randomizer(new Mt19937(12345));
echo $seeded->getInt(1, 100); // always the same for seed 12345
// Fast non-secure — for simulations and games
$fast = new Randomizer(new Xoshiro256StarStar(seed: 42));
$shuffled = $fast->shuffleArray(['a', 'b', 'c', 'd', 'e']);
$slice = $fast->pickArrayKeys(['a', 'b', 'c', 'd'], 2);
The Randomizer also handles ranges, string shuffling, and picking elements without repetition — all delegating to the engine you choose. This makes the right choice (CSPRNG for security, seeded for reproducibility) an explicit, visible decision rather than a consequence of which function you happened to call.
// Generate a secure random password
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%';
$rng = new Randomizer(new Secure());
$password = $rng->getBytesFromString($chars, 16);
// Shuffle a string securely
$shuffled = $rng->shuffleBytes('Hello, World!');
Significance: Security by Design
rand() was predictable, mt_rand() was seeded, and finding the secure option required knowing to look for random_int(). Randomizer makes the security properties of your choice visible at the call site.WeakMap — Object Keys That Don't Leak Memory
PHP 8.0 introduced WeakMap, a map where the keys are objects but holding a key doesn't prevent that object from being garbage collected. It's the perfect structure for per-object caches that would otherwise cause memory leaks.
$cache = new WeakMap();
class QueryBuilder
{
public function build(): string { /* ... */ return 'SELECT ...'; }
}
$qb = new QueryBuilder();
// Associate computed data with the object
$cache[$qb] = $qb->build();
echo $cache[$qb]; // "SELECT ..."
echo count($cache); // 1
// When the object goes out of scope, the WeakMap entry is automatically removed
unset($qb);
echo count($cache); // 0 — cleaned up automatically, no memory leak
Compare this to a plain SplObjectStorage or a regular array keyed by spl_object_id(): both keep the object alive as long as the cache exists. With WeakMap, the cache is truly a side-channel — it holds data about objects without claiming ownership of them.
// Real-world: memoize expensive per-object computations
class MetadataRegistry
{
private WeakMap $cache;
public function __construct()
{
$this->cache = new WeakMap();
}
public function getMetadata(object $obj): array
{
if (!isset($this->cache[$obj])) {
$this->cache[$obj] = $this->computeExpensiveMetadata($obj);
}
return $this->cache[$obj];
}
}
Significance: Memory Safety
Named Capture Groups in preg_match
PHP's preg_match supports PCRE named capture groups: (?P<name>...). Instead of juggling numeric match indices, your captures become a named map.
// Parse an ISO 8601 date without counting brackets
$pattern = '/^(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})$/';
preg_match($pattern, '2024-03-15', $matches);
echo $matches['year']; // "2024"
echo $matches['month']; // "03"
echo $matches['day']; // "15"
// Numeric indices still work too — your choice
echo $matches[1]; // "2024"
Named groups shine in complex patterns where positional counting becomes error-prone:
// Parse a log line
$log = '[2024-03-15 14:32:01] production.ERROR: Connection refused {"host":"db1"}';
preg_match(
'/^\[(?P<date>[^\]]+)\]\s+(?P<channel>\w+)\.(?P<level>\w+):\s+(?P<message>[^{]+)/',
$log,
$m
);
echo $m['date']; // "2024-03-15 14:32:01"
echo $m['channel']; // "production"
echo $m['level']; // "ERROR"
echo trim($m['message']); // "Connection refused"
Named groups also work with preg_replace_callback, preg_match_all, and preg_replace using ${name} syntax in replacement strings.
Significance: Maintainability
$m[3].Spaceship Operator <=> — Three-Way Comparison
PHP 7.0 introduced <=>: it returns -1, 0, or 1 depending on whether the left side is less than, equal to, or greater than the right. This is exactly what usort comparators need.
// Before: manual if/else dance
usort($users, function ($a, $b) {
if ($a->age === $b->age) return 0;
return $a->age < $b->age ? -1 : 1;
});
// After: one expression
usort($users, fn($a, $b) => $a->age <=> $b->age);
Works on strings, integers, floats, and arrays. For multi-field sorting, chain comparisons with the || operator — when the first comparison is 0 (equal), fall through to the next:
// Sort by last name, then first name, then age
usort($people, fn($a, $b) =>
$a->lastName <=> $b->lastName ?:
$a->firstName <=> $b->firstName ?:
$a->age <=> $b->age
);
// Sort products: in-stock first, then by price ascending
usort($products, fn($a, $b) =>
$b->inStock <=> $a->inStock ?: $a->price <=> $b->price
);
The spaceship also works with PHP's built-in comparison semantics, so '10' <=> '9' returns 1 (numeric string comparison) and [1, 2] <=> [1, 1] compares element-by-element.
Significance: Ergonomics
usort comparator pattern is one of the most-written pieces of PHP boilerplate. The spaceship operator collapses it to a single expression, and the ?: chaining pattern for multi-key sorts reads almost like a specification.Array Destructuring with Keys — Pattern Matching for Arrays
PHP's short array syntax supports key-based destructuring: pull specific values out of an associative array by name, discarding the rest. It reads like pattern matching and eliminates a class of "which index is that?" bugs.
// Extract specific keys from an associative array
$person = ['name' => 'Alice', 'age' => 30, 'city' => 'London'];
['name' => $name, 'age' => $age] = $person;
echo $name; // "Alice"
echo $age; // 30
// 'city' is ignored — only extract what you need
This shines in foreach loops over result sets, where each row is an associative array:
$rows = [
['id' => 1, 'email' => 'alice@example.com', 'role' => 'admin'],
['id' => 2, 'email' => 'bob@example.com', 'role' => 'user'],
['id' => 3, 'email' => 'carol@example.com', 'role' => 'user'],
];
foreach ($rows as ['id' => $id, 'email' => $email]) {
echo "$id: $email\n";
// 'role' is irrelevant here — just ignore it
}
You can also use the list() form with keys, and nesting works for deeper structures:
// Nested destructuring
$config = [
'database' => ['host' => 'localhost', 'port' => 5432],
'cache' => ['driver' => 'redis'],
];
['database' => ['host' => $host, 'port' => $port]] = $config;
echo "$host:$port"; // "localhost:5432"
Significance: Expressiveness
$row['email'] repeated five times in a loop body, you bind once and read a clean name throughout — and the intent is visible at the top of the block.Stream Wrappers & php:// — Virtual File Handles
PHP's stream system lets you treat memory, network connections, compression filters, and custom protocols as ordinary file handles. The built-in php:// wrappers are especially useful.
// php://memory — a file handle backed by RAM, not disk
$handle = fopen('php://memory', 'r+');
fwrite($handle, "line one\n");
fwrite($handle, "line two\n");
rewind($handle);
echo stream_get_contents($handle); // "line one\nline two\n"
fclose($handle);
// Works with all standard file functions:
rewind($handle);
while (($line = fgets($handle)) !== false) {
echo trim($line);
}
php://temp works like php://memory but transparently spills to a temporary file if the data exceeds a threshold (default 2 MB) — the right default for processing uploads or large responses:
$handle = fopen('php://temp/maxmemory:5242880', 'r+'); // 5 MB threshold
// Use it like any file handle; PHP handles the memory/disk decision
Other built-in wrappers: php://stdin, php://stdout, php://stderr for CLI pipes; compress.zlib://file.gz to read gzipped files transparently; data:// for inline data URIs. You can also register your own stream wrapper with stream_wrapper_register() to implement custom protocols.
// Use php://memory to test I/O code without touching the filesystem
function writeCsv(iterable $rows, $handle): void
{
foreach ($rows as $row) {
fputcsv($handle, $row);
}
}
// In tests — no temp files, no cleanup, runs in microseconds
$out = fopen('php://memory', 'r+');
writeCsv([['a', 'b'], ['c', 'd']], $out);
rewind($out);
$result = stream_get_contents($out);
assert($result === "a,b\nc,d\n");
Significance: Testability
php://memory is the cleanest way to unit-test I/O code in PHP. Any function that accepts a file handle can be tested without touching the disk, without mock objects, and without temporary files to clean up. It's duck-typing for streams.intl MessageFormatter — ICU-Backed Proper i18n
PHP bundles the ICU library via the intl extension. That means real internationalisation: pluralization rules for all languages, gender agreements, locale-aware number and date formatting — the things every web app needs and usually gets wrong.
use MessageFormatter;
// Pluralization that actually works for every language
$fmt = new MessageFormatter('en_US',
'{count, plural, one{# item} other{# items}}'
);
echo $fmt->format(['count' => 1]); // "1 item"
echo $fmt->format(['count' => 5]); // "5 items"
echo $fmt->format(['count' => 0]); // "0 items"
// Russian has four plural forms — ICU handles them automatically
$fmt_ru = new MessageFormatter('ru_RU',
'{count, plural, one{# товар} few{# товара} many{# товаров} other{# товара}}'
);
echo $fmt_ru->format(['count' => 1]); // "1 товар"
echo $fmt_ru->format(['count' => 3]); // "3 товара"
echo $fmt_ru->format(['count' => 11]); // "11 товаров"
The same extension provides locale-aware number, currency, and date formatting through NumberFormatter and IntlDateFormatter:
// Format numbers correctly for the locale
$nf = new NumberFormatter('de_DE', NumberFormatter::DECIMAL);
echo $nf->format(1234567.89); // "1.234.567,89" — German conventions
$cf = new NumberFormatter('en_US', NumberFormatter::CURRENCY);
echo $cf->formatCurrency(9.99, 'USD'); // "$9.99"
echo $cf->formatCurrency(9.99, 'EUR'); // "€9.99"
// Collation — sort strings correctly for the locale
$coll = new Collator('fr_FR');
$words = ['éclair', 'apple', 'été', 'banane'];
$coll->sort($words);
// Sorted according to French alphabetical rules, not ASCII byte order
Significance: Correctness at Scale
intl means your app handles pluralization and formatting correctly in languages you don't know, without writing a single locale-specific branch.PHPStan & Psalm — Static Analysis as a First-Class Citizen
Run PHPStan at its strictest level on a modern PHP codebase and you get TypeScript-grade type safety — with zero runtime overhead and no transpile step. This is one of the most important things PHP developers don't know they have.
# Install
composer require --dev phpstan/phpstan
# Run at max strictness (level 9)
vendor/bin/phpstan analyse src --level=9
At level 9, PHPStan catches bugs that would otherwise only surface at runtime:
// PHPStan catches this at analysis time, not in production:
function processUser(?User $user): string
{
return strtoupper($user->name);
// ^^^^^^^^^^^^
// Error: Cannot access property $name on null.
// Add a null check or change the type to User.
}
// After fix — PHPStan is satisfied:
function processUser(?User $user): string
{
if ($user === null) {
return 'Guest';
}
return strtoupper($user->name);
}
At higher levels, PHPStan enforces return types, detects dead code, validates array shapes, and understands generics via PHPDoc. Psalm (the alternative from Vimeo) goes even further with its type inference engine and taint analysis for security vulnerabilities.
# phpstan.neon — project configuration
parameters:
level: 9
paths:
- src
strictRules:
disallowedBacktick: true
disallowedConstructs:
- 'empty' # empty() masks bugs; be explicit
Significance: Confidence Without Ceremony
Numeric Literal Separators — 1_000_000
PHP 7.4 lets you insert underscores anywhere in numeric literals. The compiler ignores them; your eyes don't have to count digits.
// Before: count the zeros
$population = 8000000000;
$diskSize = 1099511627776;
$maxUpload = 10485760;
// After: immediately readable
$population = 8_000_000_000; // 8 billion
$diskSize = 1_099_511_627_776; // 1 TiB in bytes
$maxUpload = 10_485_760; // 10 MiB
Works in every numeric context — integers, floats, hex, octal, binary, and scientific notation:
// Hex colour / bitmask constants become self-documenting
const PERMISSION_READ = 0b0000_0001;
const PERMISSION_WRITE = 0b0000_0010;
const PERMISSION_EXECUTE = 0b0000_0100;
const PERMISSION_ALL = 0b0000_0111;
// RGB hex values
$red = 0xFF_00_00;
$green = 0x00_FF_00;
$blue = 0x00_00_FF;
$white = 0xFF_FF_FF;
// Financial precision
$price = 1_299.99;
$taxRate = 0.07_5; // 7.5%
$threshold = 1_000_000.00; // one million
Underscores can go between any two digits, so you can group by your domain's conventions: thousands in decimal, bytes in binary, nibbles in hex. The runtime value is identical — this is purely a readability aid.