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
Warningand some becameError. 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.