PHP 8.0: match, named arguments, attributes, and JIT
PHP 8.0 shipped November 26th. I’ve been running it for six weeks on a side project and a greenfield service at work. It’s the most significant PHP release since 7.0, and in some ways more impactful, because the changes pile on top of each other in useful ways.
JIT
The Just-In-Time compiler was the headline announcement. The reality in production is more nuanced: for typical web apps (database queries, HTTP calls, template rendering) the gains are modest, because those workloads are I/O bound, not compute bound. Where JIT actually shines is CPU-intensive code: image manipulation, data transformation, mathematical computation.
For most web apps, the performance improvement comes from the overall engine work in 8.0, not JIT specifically. Still worth enabling though: it costs nothing on I/O-bound work.
Match expressions
switch has three problems: it uses loose comparison, it falls through by default, and it can’t be used as an expression. match fixes all three:
$result = match($status) {
'active', 'pending' => 'processing',
'done' => 'finished',
default => throw new \UnexpectedValueException($status),
};
Strict comparison. No fall-through. Expression that returns a value. Non-exhaustive match throws. After one week with match I stopped writing switch.
Named arguments
array_slice(array: $users, offset: 0, length: 10, preserve_keys: true);
Named arguments let you pass arguments in any order and skip optional ones. The obvious win is readability on functions with multiple boolean flags. The less obvious win: named arguments survive PHP version upgrades even when parameter order changes, because you’re naming what you mean.
Attributes
Out with docblock annotations (the @Route, @ORM\Column style that frameworks have relied on for years), in with first-class PHP syntax:
#[Route('/users', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function list(): Response { ... }
Attributes are validated by the engine, not parsed from strings. IDE support just works, no plugin magic needed. For Symfony and Doctrine users, this is the real daily win of PHP 8.0.
Constructor promotion
class User {
public function __construct(
public readonly int $id,
public string $name,
private ?string $email = null,
) {}
}
Properties declared and assigned in one line in the constructor signature. The most immediate refactoring win in 8.0: every data class I’ve touched since upgrading is half the lines it used to be.
Nullsafe operator
$city = $user?->getAddress()?->getCity()?->getName();
null at any point in the chain short-circuits the rest and returns null. The alternative was nested null checks or a chain of early returns. This composes naturally.
Union types
Named arguments make function signatures more explicit at the call site. Union types make them more honest at the declaration site:
function processInput(int|float|string $value): string|int
{
if (is_string($value)) {
return strlen($value);
}
return (int) round($value);
}
The union int|float|string is a literal OR. The engine enforces it on entry and exit. Before 8.0, “this parameter accepts int or float” lived in a docblock that nothing enforced. There’s also null as a type component: ?string is just syntactic sugar for string|null, both are valid.
One special case: false. PHP has a bunch of built-in functions that return a typed value on success and false on failure. The 8.0 type system accommodates that: array|false, string|false. It’s an honest acknowledgment that the codebase can’t be rewritten overnight.
static return type
static as a return type was possible informally through docblocks, but 8.0 makes it official. The distinction between self and static matters in inheritance:
class Builder {
protected array $config = [];
public function set(string $key, mixed $value): static {
$this->config[$key] = $value;
return $this;
}
}
class SpecialBuilder extends Builder {}
$result = (new SpecialBuilder())->set('foo', 'bar');
// $result is SpecialBuilder, not Builder
With self as the return type, that chain would return Builder, breaking fluent interfaces in subclasses. static makes fluent APIs work correctly across inheritance hierarchies without manual overrides.
mixed type
mixed was a docblock convention for years. 8.0 makes it a real type that shows up in signatures:
function debug(mixed $value): void {
var_dump($value);
}
It accepts everything: null, objects, resources, scalars, arrays. Semantically it’s the same as having no type declaration, but it’s explicit rather than absent. The difference between “this parameter is untyped” and “this parameter intentionally accepts anything.” Worth using when you’re writing a general-purpose utility that would be dishonest with a narrower type.
throw as expression
Before 8.0, throw was a statement. Sounds like a pedantic distinction until you hit the places where you actually want an expression:
// In a ternary:
$value = $input ?? throw new \InvalidArgumentException('input required');
// In an arrow function:
$getId = fn(User $u) => $u->id ?? throw new \RuntimeException('no id');
// In a match arm (which is already an expression):
$status = match($code) {
200 => 'ok',
404 => 'not found',
default => throw new \UnexpectedValueException("unknown code: $code"),
};
The last one is particularly useful: match without a default will throw UnhandledMatchError automatically, but sometimes you want to control the exception type and message.
catch without a variable
Small quality-of-life fix. When you catch an exception but don’t actually use the object, 8.0 lets you omit the variable:
try {
$result = $cache->get($key);
} catch (CacheMissException) {
$result = $this->compute($key);
}
Before 8.0, you had to write catch (CacheMissException $e) and then either use $e or live with the IDE warning about an unused variable. Neither was satisfying.
String functions that should have existed years ago
Three functions that every PHP developer has written manually at least once:
str_contains('hello world', 'world'); // true
str_starts_with('hello world', 'hell'); // true
str_ends_with('hello world', 'world'); // true
Before 8.0, the go-to approaches were strpos() !== false, strncmp(), or substr() ===, all of which require stopping to remember the semantics. These new functions are just direct and readable. No regex, no offset arithmetic.
Stable sort
PHP’s sorting functions weren’t stable before 8.0. “Not stable” means elements that compare as equal could end up in any order relative to each other. In practice this caused subtle bugs in UI code that needed consistent ordering, pagination that shifted between loads, and tests that only passed by luck.
8.0 guarantees stability across all sorting functions: sort(), usort(), array_multisort(), and the rest. Equal elements keep their original relative position. This is the behavior most people assumed was already there.
WeakMap
7.4 brought WeakReference for single objects. 8.0 brings WeakMap: a map where both the keys (objects) and their associated data can be garbage collected when no other reference to the key object exists:
class RequestCache {
private WeakMap $cache;
public function __construct() {
$this->cache = new WeakMap();
}
public function get(Request $request): Response {
return $this->cache[$request] ??= $this->compute($request);
}
}
The moment $request is no longer referenced anywhere else, the entry disappears from the map. No manual cleanup needed. It’s the right pattern for memoization and computed property caches where you don’t want to be the sole reason an object stays alive.
New exception types
ValueError is thrown when a function gets the right type but an invalid value, as opposed to TypeError which fires on wrong types:
array_chunk([], -5); // ValueError: array_chunk(): Argument #2 ($length) must be greater than 0
Before 8.0, many of these were warnings that returned false or null. Now they throw. The engine is stricter, which means you catch problems earlier instead of getting weird results somewhere downstream.
get_debug_type() and fdiv()
Two utility functions worth knowing.
get_debug_type() returns a normalized string representation of any value, handy for error messages:
get_debug_type(1); // "int"
get_debug_type(1.0); // "float"
get_debug_type(null); // "null"
get_debug_type(new Foo()); // "Foo" (not "object")
get_debug_type([]); // "array"
The difference from gettype(): it returns class names for objects and uses normalized names ("int" not "integer"). Exactly what you want when building an exception message that says what you got versus what you expected.
fdiv() performs floating-point division following IEEE 754, meaning division by zero returns INF, -INF, or NAN instead of a warning:
fdiv(10, 0); // INF
fdiv(-10, 0); // -INF
fdiv(0, 0); // NAN
The changes that break things
8.0 also ships a few changes that aren’t features, they’re corrections.
The big one: 0 == "foo" is now false. In PHP 7, comparing an integer to a non-numeric string would cast the string to 0, so 0 == "anything-non-numeric" evaluated to true. That was a persistent source of bugs and security headaches. PHP 8 flips it: the integer gets cast to a string instead:
var_dump(0 == "foo"); // bool(false) in 8.0, bool(true) in 7.x
var_dump(0 == ""); // bool(false) in 8.0, bool(true) in 7.x
var_dump(0 == "0"); // bool(true) in both ("0" is numeric)
If you relied on this intentionally, you already knew it was sketchy. If you didn’t know you relied on it, 8.0 will find those code paths for you.
Several functions that used to return resources now return proper objects: curl_init() returns a CurlHandle, imagecreate() returns a GdImage, xml_parser_create() returns an XMLParser. Code that checks is_resource($curl) will break, because is_resource() returns false for these objects. The fix is to check against false (the return value on failure) rather than checking the type of the success case.
PHP 8.0 is the kind of release where the features reinforce each other. Attributes play well with constructor promotion. Match pairs naturally with union types. The string functions cut noise that was hiding intent. The corrections are occasionally breaking, but they push the language toward consistency it should have had years ago.