Union types, promotion, and match

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 know

The @ 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 integration

Rector 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.