PHP 8.0 arrived on the twenty-sixth of November. Three of its features change what a class looks like — union types, constructor promotion and match — and one change nobody asked for alters how == compares a string to a number, which is the one that will break something.
What the code looked like before
final class Money
{
/** @var int */
private $cents;
/** @var string */
private $currency;
/**
* @param int|float $amount
*/
public function __construct($amount, string $currency)
{
if (! is_int($amount) && ! is_float($amount)) {
throw new InvalidArgumentException('amount must be numeric');
}
$this->cents = (int) round($amount);
$this->currency = $currency;
}
}
Twenty lines, of which four are docblocks restating a type the engine could have checked and four more are a runtime check enforcing what the docblock claims. All of that is boilerplate compensating for a missing language feature.
Union types
final class Money
{
private int $cents;
private string $currency;
public function __construct(int|float $amount, string $currency)
{
$this->cents = (int) round($amount);
$this->currency = $currency;
}
}
The union is enforced by the engine, so the manual check and the docblock both disappear and the error message improves — a TypeError naming the parameter and the given type is more useful than an InvalidArgumentException saying “amount must be numeric”.
what a union can and cannot express:
int|float|null fine
?int still shorthand for int|null
int|string fine, and usually a design smell
void|int error. void is not a type, it is an
absence of one.
int|INT error, and the duplicate check is
resolved at COMPILE time — so a union
of two aliased class names is allowed
and is not deduplicated.The compile-time deduplication detail matters when a union references classes through use aliases: the engine does not load the classes to check whether two names are the same type, so A|B where both resolve to the same class is accepted silently.
The absence of intersection types is the real limitation in 8.0 — expressing “something that is both Countable and Traversable” still needs an interface extending both, which means every implementing class has to be changed. That arrives in 8.1.
Constructor promotion
final class Money
{
public function __construct(
private int $cents,
private string $currency,
) {
}
}
// the trailing comma in the parameter list is also new,
// and it is what makes the diff on adding a parameter
// one line rather than two.
Promotion removes the declare-assign-repeat triple that every value object and every service class contained. A constructor with six injected dependencies goes from twenty-four lines to eight, and the twenty-four contained no information the eight do not.
where it does NOT apply:
a constructor with logic before assignment — validation,
normalisation, a derived field. promotion assigns first.
abstract constructors, and interfaces. no body, no promotion.
a property whose visibility differs from what you want the
parameter to look like — they are the same declaration.
variadics. `private int ...$parts` is an error.
and it can be MIXED: promote three, declare the fourth.The mixing is what makes it practical on a real class. A service with five injected dependencies and one computed field promotes the five and declares the sixth, which is the common shape and is not obvious from any of the examples in the announcement.
final class OrderTotal
{
private int $vatCents; // derived, so not promoted
public function __construct(
private int $netCents,
private int $vatRateBasisPoints,
) {
if ($netCents < 0) {
throw new InvalidArgumentException('net cannot be negative');
}
$this->vatCents = intdiv($netCents * $vatRateBasisPoints, 10000);
}
}
Validation after promotion works because the assignment happens before the body runs, so the check reads the parameter and the property interchangeably. That ordering is worth knowing: a promoted property is already set by the time the first line of the constructor executes.
match
// switch: loose comparison, fall-through, no value
switch ($status) {
case 'pending':
case 'authorised':
$label = 'In progress';
break;
case 'shipped':
$label = 'Sent';
break;
default:
$label = 'Unknown';
}
// match: strict, an expression, and exhaustive
$label = match ($status) {
'pending', 'authorised' => 'In progress',
'shipped' => 'Sent',
default => 'Unknown',
};
The three differences are strict comparison, being an expression rather than a statement, and throwing UnhandledMatchError when nothing matches and there is no default. The third is the one that changes behaviour: a switch with no default silently does nothing and a match with no default fails loudly.
Omitting the default deliberately is the good use of this. A match over a closed set of statuses that throws when a new status is added finds every place needing updating at the moment the new status first occurs, which is far better than a label reading “Unknown”.
// the conditional form, which is the one people miss:
// match(true) replaces an if/elseif chain
$band = match (true) {
$cents >= 100000 => 'enterprise',
$cents >= 10000 => 'business',
$cents >= 1000 => 'standard',
default => 'starter',
};
The strictness is what makes match a hazard on data from a database or a request, where a numeric column arrives as a string. match ($id) with integer arms does not match "3" and throws, which is correct and is a behaviour change from the switch it replaced.
The change that breaks things
string-to-number comparison, before 8.0:
0 == 'foo' TRUE ← 'foo' cast to 0
0 == '' TRUE
'abc' == 0 TRUE
'1' == '01' true
100 == '1e2' true
from 8.0, the NUMBER is cast to a string when the string
is not numeric:
0 == 'foo' FALSE
0 == '' FALSE
'abc' == 0 FALSE
'1' == '01' true (both numeric, unchanged)
100 == '1e2' true (both numeric, unchanged)This is the correct behaviour and it is the single most likely thing to break an application on upgrade, because the old semantics were load-bearing in code nobody remembers writing. in_array($needle, $haystack) without the strict flag is the classic case: a needle of 0 previously matched every non-numeric string in the array.
// the code that changes behaviour silently
if (in_array(0, ['admin', 'editor'])) {
// true before 8.0. false now.
}
// and the one worth searching for across the codebase
$key = array_search($id, $ids); // no strict flag
switch ($status) { case 0: ... } // loose comparison
The tractable audit is a grep for in_array and array_search without a third argument, which is a finite list and mostly mechanical to fix — passing true is correct in nearly every case and should have been there anyway.
the rest of the 8.0 breaking list, by how often it bites:
string/number comparison the one above
@ no longer silences fatals an error that was hidden
becomes a crash
match/readonly/etc reserved `match` as a method name
default error reporting E_ALL more notices, in logs
required after optional params now a deprecation
ext/xmlrpc, ext/wddx removed if you used them, you knowThe @ change is the second most likely to surface, and it surfaces as a fatal error in production rather than as a test failure — a suppressed call that was quietly failing now stops the request. That is an improvement and it is not a pleasant way to discover it.
What the upgrade actually took
$ vendor/bin/phpcs --standard=PHPCompatibility
--runtime-set testVersion 8.0 app/
FOUND 41 ERRORS AND 118 WARNINGS
$ vendor/bin/rector process app/ --dry-run
--config rector-php80.php
214 files would be changed
# and what could NOT be automated:
# the loose comparisons (41)
# two libraries with no 8.0 release
# an ext/xmlrpc call in a 2013 integrationRector handles promotion, match and null-safe conversion mechanically and produces a very large diff that has to be reviewed rather than trusted. Running it one rule at a time, in separate commits, is the difference between a reviewable series and a two-hundred-file change nobody reads.
The dependencies without 8.0 releases are what actually determine the timeline, and in December 2020 there were still several — a month after release is early, and an application with forty dependencies will have two that are not ready. Neither the compatibility checker nor Rector says anything about that.
$ composer why-not php 8.0
laravel/framework v8.12 requires php (^7.3|^8.0) ok
some/package 2.1.0 requires php (^7.2) BLOCKS
# the single most useful command of the whole upgrade,
# and it should be run first rather than last.What all this costs
The features are close to free — promotion and match make code shorter and clearer with no runtime cost, and union types replace checks that were already being written by hand. The cost of 8.0 is entirely in the comparison change and the ecosystem timing, neither of which has anything to do with the features people upgrade for.
The honest risk is that the comparison change is undetectable by a test suite that does not exercise the path. A loose comparison in an authorisation check behaves differently and the tests pass, because the tests use realistic values and the bug needs an unrealistic one — which is why the grep for the two array functions is worth doing exhaustively rather than sampling.
Adopting the syntax is also a one-way door on the minimum version, and that is worth deciding deliberately rather than discovering. A library using promotion cannot support 7.4, and a promoted constructor is not something that can be conditionally applied — so an internal application should use all of it immediately and a package with external users should not.