Spaceship operator (<=>) and null coalescing assignment (??=)
Concept
The spaceship operator <=> and the null coalescing assignment ??= are two PHP operators that address specific readability and correctness pain points. While unrelated in purpose, they are both part of the PHP 7.x language improvements that made common patterns more expressive.
The spaceship operator <=> (introduced in PHP 7.0) is a combined comparison operator that returns -1 if the left side is less than the right, 0 if equal, and 1 if greater. It applies the same comparison semantics as the < and > operators for each type: numeric comparison for numbers, lexicographic for strings, size-then-element for arrays. It is right-associative and has a lower precedence than arithmetic operators but higher than assignment.
The canonical use case for <=> is writing sort comparators for usort, uasort, and uksort. These PHP functions require a callable that returns a negative integer, zero, or positive integer — exactly what <=> produces. Before PHP 7, developers wrote return $a < $b ? -1 : ($a > $b ? 1 : 0) or the dangerous return $a - $b (which breaks on floats and on integer overflow). The spaceship operator replaces all of that with return $a <=> $b. For multi-field sorts, you chain with ??: ($a->age <=> $b->age) ?: ($a->name <=> $b->name).
The null coalescing assignment ??= (introduced in PHP 7.4) is shorthand for $var = $var ?? $expr. It evaluates the right-hand side only if the left-hand variable is null or undefined, and then assigns the result. This is the standard lazy initialization idiom. It also works safely on undefined array keys without triggering notices: $cache['key'] ??= expensiveCompute() only calls expensiveCompute() if $cache['key'] is not set.
A subtle distinction: ??= checks for null and undefined (using isset semantics), not for falsy values. So $x = 0; $x ??= 99; leaves $x as 0, not 99. If you want to replace any falsy value, you need $x = $x ?: 99 instead. This distinction is critical when dealing with numeric user input where zero is a valid value.
Code Example
<?php
declare(strict_types=1);
// <==> basics
var_dump(1 <=> 2); // int(-1)
var_dump(2 <=> 2); // int(0)
var_dump(3 <=> 2); // int(1)
var_dump('b' <=> 'a'); // int(1)
var_dump([1,2] <=> [1,3]); // int(-1) — element-by-element
// usort with <=>
$users = [
['name' => 'Charlie', 'age' => 30],
['name' => 'Alice', 'age' => 25],
['name' => 'Bob', 'age' => 30],
];
// Sort by age ASC, then name ASC
usort($users, function (array $a, array $b): int {
return ($a['age'] <=> $b['age'])
?: ($a['name'] <=> $b['name']);
});
// Result: Alice(25), Bob(30), Charlie(30)
// Reverse sort: flip operands
usort($users, fn($a, $b) => $b['age'] <=> $a['age']);
// Sorting objects
class Product
{
public function __construct(
public readonly string $name,
public readonly float $price,
) {}
}
$products = [
new Product('Banana', 0.5),
new Product('Apple', 1.2),
new Product('Cherry', 1.2),
];
usort($products, fn(Product $a, Product $b): int =>
($a->price <=> $b->price) ?: ($a->name <=> $b->name)
);
// ??= — null coalescing assignment
class Router
{
private ?array $routes = null;
public function routes(): array
{
$this->routes ??= $this->loadRoutes(); // compute only once
return $this->routes;
}
private function loadRoutes(): array
{
return ['/', '/about', '/contact'];
}
}
// ??= on array keys (no undefined-key notice)
$cache = [];
$cache['expensive'] ??= json_decode(file_get_contents('/tmp/data.json'), true);
// Only reads file on first access
// Critical distinction: ??= vs ?:=
$count = 0;
$count ??= 99; // $count stays 0 — 0 is not null
$count ?: 99; // evaluates to 99 but does NOT assign (?: is not an assignment operator)
// Correct way to replace falsy with default:
$count = $count ?: 99; // now $count = 99
// Chaining <=> for complex multi-key sorts
$data = [
['dept' => 'Engineering', 'level' => 3, 'name' => 'Zara'],
['dept' => 'Engineering', 'level' => 3, 'name' => 'Abel'],
['dept' => 'Marketing', 'level' => 1, 'name' => 'Mike'],
];
usort($data, fn($a, $b): int =>
($a['dept'] <=> $b['dept'])
?: ($a['level'] <=> $b['level'])
?: ($a['name'] <=> $b['name'])
);Interview Q&A
Q: Why is return $a - $b a dangerous sort comparator, and what should you use instead?
return $a - $b seems to return negative, zero, or positive correctly for integers, but it has two failure modes. First, for floats: usort requires the comparator to return an int, but $a - $b returns a float when either operand is a float. PHP casts the float to int by truncation, so 1.9 - 1.1 = 0.8 becomes 0, incorrectly declaring the values equal. Second, for large integers: PHP_INT_MAX - PHP_INT_MIN overflows to a negative value, reversing the sort order. The correct replacement is always return $a <=> $b — it uses the same comparison semantics as < and >, returns exactly -1, 0, or 1, handles all types correctly, and is immune to overflow.
Q: How does multi-column sorting with <=> work using the short-circuit ?: chaining pattern?
When chaining ($a->field1 <=> $b->field1) ?: ($a->field2 <=> $b->field2), the Elvis operator ?: returns the right operand only when the left is falsy (i.e., 0, which means equal). So if field1 values differ, the comparison returns -1 or 1 and the remaining terms are never evaluated. If field1 values are equal (result is 0, which is falsy), the next comparison is evaluated. This correctly implements "sort by field1, break ties with field2, then field3" without any if statements. The pattern is explicit, readable, and extends linearly — add another ?: ($a->field3 <=> $b->field3) for a third sort key. This is the idiomatic PHP approach to composite sorting.
Q: What is the difference between ??= and the older isset() ternary pattern, and are there any cases where they behave differently?
$var ??= expr is semantically equivalent to if (!isset($var)) { $var = expr; } or the older $var = isset($var) ? $var : expr. The behaviour is identical for undefined variables and null values. A subtle difference: ??= is an expression and returns the final value, so you can write $result = ($cache['key'] ??= compute()) and get the value regardless of whether it was cached. The old ternary pattern is a statement in most usage and returns the ternary result, not the modified variable, so the equivalent expression form is less natural. There are no cases where ??= and $var = $var ?? expr differ in outcome — they are exact equivalents, but ??= does not evaluate $var twice, which matters if $var is a computed property with a side-effecting getter.