haPHPiness

These are things in PHP which make me mass_happy();

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

#4 str_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 #11 match 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.5

Functional Joy

#19 array_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 #23 DateTime 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

#48 never 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.4

Chaos Under Control

#51 Random\Randomizer — a proper CSPRNG API 8.2 #52 WeakMap — object keys that don't leak memory 8.0

Pattern Matching

#53 Named capture groups in preg_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

#57 intl MessageFormatter — ICU-backed proper i18n

Trust but Verify

#58 PHPStan & Psalm — static analysis as a first-class citizen

Elegance 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

Code is read far more often than it is written. Named arguments make function calls self-documenting, reducing the cognitive load of understanding existing code and eliminating an entire class of positional bugs.

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

A strong type system doesn't just catch bugs — it makes refactoring safe, enables IDE autocompletion, and serves as living documentation. PHP's gradual typing lets you adopt types incrementally without rewriting your entire codebase.

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

Enums eliminate an entire category of bugs: invalid state. When a function accepts 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

Consistent naming and behavior let developers write code from memory instead of constantly checking documentation. These three functions fixed one of PHP's most-cited inconsistencies and proved that the language is willing to evolve based on real developer pain.

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

Small syntactic improvements compound. The spread operator for arrays saves a function call, reads more naturally, and brings PHP's array handling in line with modern JavaScript and Python. It's one less reason to reach for 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

By adding Fibers to the language core, PHP gave the community a standard concurrency primitive. This prevented ecosystem fragmentation (no competing coroutine implementations) and enabled frameworks to offer async features without requiring developers to learn a fundamentally different programming model.

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

A great package manager transforms a language's ecosystem. Composer turned PHP from a language where you copy-pasted libraries into your project into one with a thriving, interoperable package ecosystem. It's the single most important tool in modern PHP development.

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

The fastest path from idea to working prototype wins. PHP's built-in server means a beginner can go from installing PHP to seeing their first webpage in under a minute. No web server configuration, no infrastructure knowledge required. This is the power of batteries-included design.

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

When the secure approach is also the most convenient approach, developers choose security by default. PDO's prepared statements are easier to write than concatenated SQL, making SQL injection a conscious choice rather than an accidental oversight.

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

Reducing syntactic overhead for common patterns makes functional-style code practical. When a callback fits on one line, it should only take one line to write. Arrow functions made PHP's array functions genuinely pleasant to use.

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

Every 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

PHP's shared-nothing architecture means every request starts fresh, and default values are everywhere. The null coalescing operator turns a three-line 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

Typed properties turn classes into enforceable contracts. An object with typed properties is always in a valid state — you can trust that $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

Boilerplate isn't just annoying — it's a source of bugs (forget to assign one property, misspell a name, mismatch types). Constructor promotion eliminates the boilerplate entirely, making simple value objects and DTOs a joy to define.

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

Immutability eliminates entire categories of bugs: unexpected mutation, shared state corruption, order-dependent initialization. When the engine enforces that a value can't change, you can reason about your code with confidence.

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

String-based callables were PHP's weakest link in functional programming: unanalyzable, unrenamable, and error-prone. First-class callables bring PHP in line with languages where functions are values, enabling safer and more composable code.

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

Docblock annotations were a community hack that required third-party parsers and couldn't be validated by the engine. Native attributes are faster, safer, and standardized — every framework can rely on the same mechanism without shipping their own annotation parser.

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

Named arguments turn cryptic function calls into self-documenting expressions. They're especially powerful for PHP's large standard library, where many functions have accumulated optional parameters over decades. You no longer need to memorize parameter positions.

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

PHP's array is the Swiss Army knife of data structures. Its combination of ordered keys, mixed types, and a massive standard library of manipulation functions means you rarely need external collection libraries. With arrow functions, the functional style is now as concise as any language.

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

Implicit variable capture (like JavaScript's) can lead to subtle bugs and memory leaks. PHP's explicit 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

Generators make memory efficiency the default rather than an optimization. Processing large datasets with generators uses the same clean syntax as processing small arrays — no pagination logic, no batch callbacks, no manual iterator implementations.

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

PHP is a web language, and JSON is the web's data format. Having fast, reliable JSON support built into the language core means every PHP application can speak the lingua franca of web APIs without any additional dependencies.

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

Date and time handling is one of programming's hardest problems (time zones, daylight saving, leap seconds). PHP's DateTime classes handle these correctly out of the box, and DateTimeImmutable prevents the mutation bugs that plagued the mutable DateTime class.

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

Before 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

PHP's three-tool OOP approach encourages composition over inheritance. You can define behavior contracts (interfaces), share default implementations (abstract classes), and mix in cross-cutting concerns (traits) — all without the complexity and fragility of multiple inheritance.

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

Late static binding is the mechanism that makes framework base classes work. Without it, factory methods, fluent builders, and the Active Record pattern would be impossible to implement with proper type safety. It's a quiet feature that powers some of PHP's most elegant patterns.

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

Not every class deserves a file. Anonymous classes let you create focused, single-use implementations without the overhead of naming, filing, and autoloading a class you'll never reference again. They're especially powerful in tests, where stub proliferation is a real maintenance burden.

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

Code formatting isn't cosmetic — it's a communication tool. When multi-line strings forced you to break indentation, they made the surrounding code harder to read. Flexible heredocs let your string literals live harmoniously within your code's visual structure.

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

Trailing commas are a small syntax feature with outsized impact on daily workflow. They eliminate an entire class of syntax errors, produce cleaner version control diffs, and make copy-pasting and reordering lines effortless. It's the kind of thoughtful quality-of-life improvement that shows PHP listens to its developers.

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

Null handling is one of the most common sources of both bugs and boilerplate. The nullsafe operator reduces a pyramid of null checks to a single fluent expression, making the happy path and the null path equally readable. Combined with ??, 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

Validation and parsing are different operations with different costs. 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

Without #[\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

With typed class constants, every part of a PHP class can now be typed: properties, parameters, return values, and constants. The type system is complete. This means static analyzers can verify your entire class contract, not just the parts that had type support.

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

Property hooks change the economics of PHP class design. You no longer need to choose between "public property (simple but no control)" and "private property + getter/setter (control but verbose)". You get both: clean property access syntax with full control over behavior. This eliminates thousands of lines of boilerplate in any OOP codebase.

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

The getter-for-a-readable-property pattern was PHP's most common boilerplate. Asymmetric visibility eliminates it entirely: 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

"Find the first X" and "do any/all match?" are among the most common array operations in any codebase. Dedicated functions for these patterns replace hand-rolled loops, make intent explicit, and short-circuit for performance — the standard library doing what a standard library should.

#[\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

Every library needs to evolve its API. The #[\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

Language design is about defaults. The old behavior — requiring parentheses to chain on 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

Deeply nested function calls are one of the hardest things to read in any language. The pipe operator inverts the reading order to match the execution order: data flows left to right, top to bottom. It's how humans naturally think about transformations, and it makes complex data pipelines as readable as a bullet list.

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

Immutable objects are only practical if creating modified copies is easy. Without 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

PHP had 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

When closures can appear anywhere a value can, functional patterns become first-class citizens in PHP's type system. Default callback parameters, strategy constants, and attribute-based configuration all become more natural and expressive.

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

Fatal errors are the hardest to debug because they kill the process before you can inspect it. Adding backtraces to fatal errors closes one of PHP's oldest debugging gaps. It's the difference between "the server timed out" and "the server timed out in 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

URL handling is security-critical: redirect validation, SSRF prevention, and OAuth flows all depend on parsing URLs correctly. The new URI extension replaces a 30-year-old function with a standards-compliant, immutable API that makes correct URL handling the default.

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

PHP is no longer "fast enough for web." With JIT, PHP is a credible language for CPU-bound workloads that used to require a compiled language. This changes what PHP can be used for, not just how fast existing code runs.

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

OPcache is the single highest-impact PHP configuration change, yet it requires no code changes and no extra infrastructure. Most PHP applications 2–5× their throughput by simply ensuring OPcache is enabled with sane settings.

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

The dual-nature of PHP arrays is a source of endless subtle bugs in serialization, API responses, and data manipulation. 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

FFI erases the boundary between PHP and the native world. Image processing, cryptography, hardware interfaces, and high-performance numerics are all accessible without leaving PHP. It turns PHP into a scripting layer for the entire C ecosystem.

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

The old PHP randomness functions were a minefield — 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

Caches that hold strong references to objects are a common source of memory leaks in long-running PHP processes (queues, servers, CLI commands). WeakMap makes object-keyed caches correct by default — no manual cleanup, no lifecycle management required.

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

Numbered capture groups are an implementation detail that leaks into your calling code. Named groups make regex patterns self-documenting — the name describes what the group captures, and your code reads the intent rather than $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

The 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

Destructuring with keys bridges the gap between PHP's associative arrays and pattern matching. Instead of $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

Hardcoded plural rules ("1 item, N items") break for Arabic (six plural forms), Polish (four), and many others. ICU's CLDR data covers 700+ locales. Using 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

Static analysis turns PHP's optional type system into a mandatory one — without changing the language. A codebase with PHPStan at level 9 has fewer runtime surprises than many statically-typed languages, because the analyser understands nullability, generics, and control flow in ways that simple type declarations can't express.

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.

Significance: Readability

Misreading a number's magnitude is a real bug category. Off-by-a-power-of-ten errors in timeouts, file size limits, and financial calculations have real consequences. Numeric separators make the magnitude visible at a glance, in the same way that formatting a number for display would.