Modern PHP

Written for somebody who knows PHP and last looked closely around 5.6 or 7.x. What the syntax gained, and what quietly became an error.

What this page covers

  • Classes, shorter

    Constructor promotion, readonly, enums and first-class callables.

  • Control flow

    match, nullsafe, named arguments and the spread operator.

  • Types

    Union and intersection types, never, and what strict_types buys.

  • Strings and arrays

    str_contains and friends, arrow functions, and array unpacking with keys.

  • What became an error

    The warnings that are now exceptions, and the functions that are gone.

  • Errors and null

    Typed exceptions, and where null handling actually improved.

Classes, with far less ceremony

// Constructor promotion - declares, assigns and types in one place (8.0)
final class Invoice
{
    public function __construct(
        public readonly string $reference,   // readonly: 8.1
        public readonly int $amountPence,
        private LoggerInterface $log,
    ) {}
}

// Enums are real, and can carry behaviour (8.1)
enum Status: string
{
    case Draft = 'draft';
    case Sent = 'sent';
    case Paid = 'paid';

    public function isFinal(): bool
    {
        return $this === self::Paid;
    }
}

Status::from('sent');        // throws if unknown
Status::tryFrom('nope');     // returns null instead

Constructor promotion is the single biggest reduction in PHP boilerplate in a decade: no property declaration, no assignment, no docblock repeating the type.

Control flow and calls

// match: strict comparison, returns a value, no fallthrough,
// and THROWS if nothing matches rather than silently doing nothing (8.0)
$label = match($status) {
    Status::Draft => 'Not sent yet',
    Status::Sent, Status::Paid => 'With the customer',
};

// Nullsafe - stops at the first null instead of erroring (8.0)
$country = $order?->customer?->address?->country;

// Named arguments - skip optionals, and read at the call site (8.0)
str_pad(string: $ref, length: 12, pad_string: '0', pad_type: STR_PAD_LEFT);

// First-class callable syntax (8.1)
$fn = strlen(...);
$names = array_map($user->name(...), $users);

match throwing on no match is the point of it. A switch with a missing case does nothing and you find out later; match tells you immediately.

Types

declare(strict_types=1);   // first line, before anything else

function totalOf(int|float $a, int|float $b): int|float   // union: 8.0
{
    return $a + $b;
}

function fail(string $why): never    // never returns: 8.1
{
    throw new RuntimeException($why);
}

// Intersection types are TYPE DECLARATIONS, not an implements clause.
// `class X implements A&B` is a parse error; the ampersand belongs on a
// parameter, return or property type.
function persist(Countable&IteratorAggregate $rows): void {}   // 8.1

class Config
{
    public function __construct(
        private readonly array $values = [],
    ) {}
}

strict_types=1 is per file and does not inherit. Without it PHP coerces "5" to 5 silently, which is exactly the class of bug types were meant to catch. It has to be the first statement in the file.

Strings and arrays

// Readable replacements for strpos() !== false (8.0)
str_contains($haystack, $needle);
str_starts_with($path, '/api/');
str_ends_with($file, '.webp');

// Arrow functions capture the outer scope automatically (7.4)
$totals = array_map(fn($row) => $row->qty * $unitPrice, $rows);

// Spread with string keys (8.1)
$merged = [...$defaults, ...$overrides];

// Trailing commas in parameter lists (8.0) and closure `use` (8.0)
// Null coalescing assignment (7.4)
$config['timeout'] ??= 30;

What became an error

This is the half that breaks an upgrade, and it is worth reading before the syntax above.

  • Passing null to a non-nullable internal parameter is deprecated in 8.1 and will be an error. strlen(null) used to be fine.
  • {} string offset access is removed. Use $s[0].
  • Most "undefined" warnings became Warning and some became Error. Reading an undefined array key or property is no longer quietly null in the way it used to be.
  • create_function(), each(), money_format() and the mysql_* extension are gone. If a codebase still calls them it never left 5.x.
  • String-to-number comparison changed in 8.0. 0 == "foo" was true and is now false, which is the single most likely silent behavior change in old code.

That last one is worth repeating: 0 == "foo" used to be true. Any code comparing a numeric value against unvalidated string input may have changed meaning without changing shape.