TL;DR

Explore PHP 8 features including match expressions, named arguments, union types, nullsafe operator, and constructor promotion.

Key concepts

  • PHP 8 features
  • PHP match expression
  • PHP named arguments
  • PHP union types

PHP 8 Features

PHP 8 was a landmark release that brought the language firmly into the modern era. Released in November 2020, it introduced a collection of features that make code more expressive, safer, and easier to reason about. Whether you are writing a simple script or a complex application, these additions reduce boilerplate and eliminate entire categories of bugs. This lesson walks through the most impactful changes you will encounter and use regularly.

Match Expressions

Before PHP 8, switch was the go-to for multi-branch logic, but it had quirks: loose comparison, fall-through behaviour, and no return value. The match expression fixes all of that.

match uses strict comparison (===), each arm returns a value, and there is no fall-through. It is an expression, so you can assign its result directly to a variable.

<?php

$status = 2;

$label = match($status) {
    1 => "Active",
    2 => "Pending",
    3 => "Suspended",
    default => "Unknown",
};

echo $label . "\n"; // Pending

// Multiple conditions per arm
$code = 404;

$message = match(true) {
    $code >= 500 => "Server Error",
    $code >= 400 => "Client Error",
    $code >= 300 => "Redirect",
    default      => "Success",
};

echo $message . "\n"; // Client Error

// Match throws UnhandledMatchError if no arm matches and no default
// This prevents silent bugs that switch could hide
$role = "editor";

$permissions = match($role) {
    "admin"  => ["read", "write", "delete"],
    "editor" => ["read", "write"],
    "viewer" => ["read"],
    default  => [],
};

echo implode(", ", $permissions) . "\n"; // read, write

Prefer match over switch whenever you need a clean, strict, value-returning branch. Reserve switch only when you intentionally need fall-through.

Named Arguments

Named arguments let you pass values to a function by parameter name rather than position. This eliminates the need to remember argument order and makes call sites dramatically more readable, especially for built-in functions with many optional parameters.

<?php

// Without named arguments — what does true mean here?
$result = array_slice([1, 2, 3, 4, 5], 1, 3, true);

// With named arguments — intent is immediately clear
$result = array_slice(
    array: [1, 2, 3, 4, 5],
    offset: 1,
    length: 3,
    preserve_keys: true
);

print_r($result);

// Named arguments also let you skip optional parameters
function createUser(
    string $name,
    string $role = "viewer",
    bool   $active = true,
    int    $maxProjects = 10
): string {
    $status = $active ? "active" : "inactive";
    return "$name | $role | $status | projects: $maxProjects";
}

// Only supply what you care about, skip the rest
echo createUser(name: "Alice", maxProjects: 25) . "\n";
// Alice | viewer | active | projects: 25

echo createUser(name: "Bob", role: "admin", active: false) . "\n";
// Bob | admin | inactive | projects: 10

Named arguments work especially well when calling native PHP functions that have long, positional-only signatures. They make the code self-documenting at the call site.

Union Types and Nullsafe Operator

PHP 8 lets you declare that a parameter, property, or return value can hold one of several types using a union type — written as TypeA|TypeB. This replaces the informal convention of using mixed or omitting the type hint entirely.

The nullsafe operator (?->) addresses a separate but related pain point: safely traversing a chain of method calls where any link might return null.

<?php

// Union types in function signatures
function formatId(int|string $id): string {
    if (is_int($id)) {
        return str_pad((string)$id, 6, "0", STR_PAD_LEFT);
    }
    return strtoupper(trim($id));
}

echo formatId(42)        . "\n"; // 000042
echo formatId("  abc ")  . "\n"; // ABC

// int|null is so common PHP has ?int shorthand, but you can be explicit
function divide(int $a, int $b): int|null {
    if ($b === 0) return null;
    return intdiv($a, $b);
}

$result = divide(10, 0);
echo ($result ?? "division by zero") . "\n"; // division by zero

// Nullsafe operator: stops the chain and returns null if any step is null
class Address {
    public function __construct(public string $city) {}
}

class User {
    public function __construct(
        public string   $name,
        public ?Address $address = null
    ) {}

    public function getAddress(): ?Address {
        return $this->address;
    }
}

$userWithAddress    = new User("Alice", new Address("Amsterdam"));
$userWithoutAddress = new User("Bob");

// Without nullsafe you would need: $user->getAddress() ? $user->getAddress()->city : null
echo $userWithAddress->getAddress()?->city    . "\n"; // Amsterdam
echo ($userWithoutAddress->getAddress()?->city ?? "no city") . "\n"; // no city

Union types make implicit contracts explicit. The nullsafe operator replaces nested null checks with a clean, readable chain.

Constructor Property Promotion

Defining a class with several properties traditionally required three repetitions of each property name: in the property declaration, in the constructor parameter, and in the assignment. Constructor property promotion collapses all three into one.

<?php

// Old style — lots of repetition
class ProductOld {
    public string $name;
    public float  $price;
    public int    $stock;

    public function __construct(string $name, float $price, int $stock) {
        $this->name  = $name;
        $this->price = $price;
        $this->stock = $stock;
    }
}

// PHP 8 — promoted properties declared directly in the constructor
class Product {
    public function __construct(
        public readonly string $name,
        public float           $price,
        public int             $stock = 0,
    ) {}

    public function summary(): string {
        return "$this->name — \$$this->price ($this->stock in stock)";
    }
}

$item = new Product(name: "Keyboard", price: 89.99, stock: 12);
echo $item->summary() . "\n"; // Keyboard — $89.99 (12 in stock)

// readonly prevents reassignment after construction
// $item->name = "Mouse"; // TypeError: Cannot modify readonly property

// You can mix promoted and non-promoted parameters
class Order {
    public float $total = 0.0;

    public function __construct(
        public readonly int    $id,
        public readonly string $customer,
        private array          $items = [],
    ) {}

    public function addItem(Product $product): void {
        $this->items[] = $product;
        $this->total  += $product->price;
    }

    public function itemCount(): int {
        return count($this->items);
    }
}

$order = new Order(id: 1001, customer: "Alice");
$order->addItem(new Product("Keyboard", 89.99));
$order->addItem(new Product("Mouse", 34.99));

echo "Order #{$order->id} for {$order->customer}\n";
echo "Items: {$order->itemCount()}, Total: \${$order->total}\n";

Try It Yourself

Combine the features you have learned to build a small product catalogue processor. Use match for categorisation, named arguments for clarity, union types for flexibility, and promoted properties for clean class definitions.

<?php

class CatalogueItem {
    public function __construct(
        public readonly string $sku,
        public readonly string $name,
        public float           $price,
        public int             $stock = 0,
    ) {}
}

function categorise(float $price): string {
    return match(true) {
        $price >= 500  => "premium",
        $price >= 100  => "standard",
        $price >= 0    => "budget",
        default        => "unknown",
    };
}

function formatItem(CatalogueItem $item, bool $showStock = false): string {
    $category = categorise(price: $item->price);
    $line = sprintf(
        "[%s] %s — \$%.2f (%s)",
        strtoupper($item->sku),
        $item->name,
        $item->price,
        $category
    );
    if ($showStock) {
        $availability = match(true) {
            $item->stock === 0  => "out of stock",
            $item->stock <= 5   => "low stock ({$item->stock})",
            default             => "in stock ({$item->stock})",
        };
        $line .= " — $availability";
    }
    return $line;
}

$catalogue = [
    new CatalogueItem(sku: "KB-01",  name: "Mechanical Keyboard", price: 149.99, stock: 8),
    new CatalogueItem(sku: "MOU-02", name: "Ergonomic Mouse",     price: 59.99,  stock: 3),
    new CatalogueItem(sku: "MON-03", name: "4K Monitor",          price: 699.00, stock: 0),
    new CatalogueItem(sku: "PAD-04", name: "Desk Mat",            price: 24.99,  stock: 50),
];

foreach ($catalogue as $item) {
    echo formatItem(item: $item, showStock: true) . "\n";
}

Key Takeaways

  • match expressions use strict comparison, return values directly, and throw an error when no arm matches — safer and more concise than switch.
  • Named arguments let you pass values by parameter name, skip optional parameters freely, and make call sites self-documenting.
  • Union types (int|string, int|null) express multiple acceptable types in a single hint, replacing the informal use of mixed.
  • The nullsafe operator (?->) short-circuits a method chain the moment null is encountered, eliminating nested null checks.
  • Constructor property promotion collapses property declaration, constructor parameter, and assignment into a single line, and pairs naturally with readonly for immutable data.
  • Together these features push PHP toward more expressive, self-documenting, and type-safe code without requiring a complete rewrite of existing patterns.

Pro Tip: Adopt these features incrementally. Start with match in any new multi-branch logic you write, and add constructor property promotion the next time you create a new class. You do not need to rewrite existing code — just let the new style spread naturally as you touch files. Most static analysis tools (PHPStan, Psalm) and modern IDEs understand all PHP 8 features natively, so you will get immediate feedback if you misuse a union type or forget a default arm in a match.


## Next Steps

You now understand match expressions, named arguments, union types, the nullsafe operator, and constructor property promotion -- the features that define modern PHP development.

Writing great code is only part of the picture. Real PHP projects depend on dozens of third-party libraries for everything from HTTP clients to testing frameworks, and managing those dependencies by hand would be chaos. **Composer and packages** teaches you PHP's dependency manager -- how to install packages, configure autoloading with PSR-4, manage version constraints, and structure your projects the way the professional PHP ecosystem expects. Composer is a tool you will use on every project from here on.

[Continue to Composer and Packages -->](/lessons/18-composer-and-packages)