Two years of deprecations, and the upgrade we could no longer defer

PHP 8.0 reaches end of life on the twenty-sixth of November. The application runs on it, has run on it since 2021, and the two upgrades that were deferred in between are now one upgrade with two years of accumulated change in it.

The symptom

$ php81 vendor/bin/phpunit 2>&1 | grep -c Deprecated
4128

$ php81 vendor/bin/phpunit 2>&1 | grep -oP 'Deprecated: K[^ ]+ [^ ]+' 
    | sort | uniq -c | sort -rn | head -6
   2841 Passing null
    612 Implicit conversion
    308 strftime() is
    188 utf8_encode() is
     94 Return type
     41 dollar-brace string
// and the line in the production configuration that had
// been hiding all of it since 2021
'deprecations' => [
    'channel' => null,      // discard
],

// error_reporting(E_ALL & ~E_DEPRECATED) in php.ini,
// added "temporarily" for the 8.0 upgrade.

Four thousand notices is not four thousand problems — it is six causes, one of which accounts for seventy per cent. The discarding channel is the more interesting finding: the information had been available for two years and was being thrown away by configuration.

Why it happens

An upgrade deferred once is the same work later. Deferred twice it is a different kind of work: the deprecations of two releases interact, the dependency graph has moved, and the packages that would have upgraded cleanly a year ago have since been abandoned.

The fix

Grouping the notices, which turns 4,100 into eleven decisions

  2,841  passing null to a non-nullable internal
         parameter — 8.1. mostly one helper:
         trim($row['name']) where name is nullable.
         → 14 call sites, one shared fix.

    612  implicit float-to-int conversion losing
         precision — 8.1. all in one report, casting
         a computed average to an id.
         → a genuine bug, found by a deprecation.

    308  strftime() — 8.1. one date formatter.
         → IntlDateFormatter, which we already use
           elsewhere.

    188  utf8_encode()/utf8_decode() — 8.2.
         → mb_convert_encoding, 4 call sites.

     94  return type not declared on an internal
         interface implementation — 8.1.
         → #[ReturnTypeWillChange] or declare it.

     41  ${var} string interpolation — 8.2.
         → mechanical.

eleven distinct decisions. one afternoon of grouping.

The six hundred implicit conversions were the find. An average cast to an integer identifier had been silently truncating for two years, in a report that reconciles against a supplier — the numbers had been wrong and nobody had checked them against the source.

The dependency matrix

$ composer why-not php 8.2
turkerdev/app  requires php ^8.0
  vendor/pdf-writer 2.4  requires php ^8.0
  vendor/legacy-soap 1.9 requires php ^7.4|^8.0
  ...

$ ./bin/dep-matrix
  package               8.1   8.2   maintained
  laravel/framework      ok    ok   yes
  league/flysystem       ok    ok   yes
  vendor/pdf-writer      ok    --   last release 2021-08
  vendor/legacy-soap     --    --   abandoned 2022-03
  ... 84 more, all ok

2 of 88.

Building the matrix before touching any code is what turns the upgrade from open-ended into two known problems. Eighty-six packages needed nothing, which is the usual distribution and is not obvious until it is measured.

The abandoned package

vendor/legacy-soap, abandoned March 2022, used in one
place: a nightly file transfer to a supplier.

options:
  fork and maintain      a SOAP client. no.
  find a replacement     two exist, both heavier
  remove the need        the supplier has offered a
                         REST endpoint since 2021 and
                         nobody had noticed the email

we read the email. 90 lines replaced 400, and the
dependency went away entirely.

Checking whether the integration still needs to exist in its current form is the step that gets skipped, and it resolved this one completely. The PDF writer was the other case and was replaced with a maintained alternative over two days, which is the ordinary answer.

Both versions in CI, for six weeks

strategy:
  fail-fast: false
  matrix:
    php: ['8.0', '8.2']
    include:
      - php: '8.0'
        experimental: false
      - php: '8.2'
        experimental: true     # allowed to fail, at first

steps:
  - uses: shivammathur/setup-php@v2
    with: { php-version: ${{ matrix.php }} }
  - run: composer update --prefer-stable
  - run: vendor/bin/phpunit
week 1   8.2 job: 412 failures
week 2   118
week 3    41
week 4    12
week 5     2
week 6     0  → experimental removed, 8.2 required

the two that lasted longest:
  a test asserting on a float formatted by strftime
  a mock expecting a nullable parameter that 8.2
    types differently

Running both for six weeks means every ordinary change is validated against the target version while the migration proceeds, rather than a big-bang cutover at the end. The experimental flag is what makes that possible without blocking everybody on day one.

The deprecation that was a behaviour change

// 8.0: null is coerced to ''
trim(null);        // ''
strlen(null);      // 0
explode(',', null); // ['']

// 8.1: deprecated, same result
// 9.0: TypeError

// which means the deprecation is a WARNING about a
// future break, and the current behaviour is unchanged.
// so this is safe to defer — except where the null was
// itself the bug:

$name = trim($row['customer_name']);   // null for 88 rows
if ($name === '') { /* treated as "no name given" */ }
// the row had a name. the JOIN was wrong.

This is the reason the notices are worth reading rather than silencing: most of them describe a coercion that is working as intended, and a few of them describe a null that should never have been there. The eighty-eight rows were a left join that should have been an inner one.

Rector for the mechanical part, reviewed anyway

$ vendor/bin/rector process src --config=rector-php82.php --dry-run
  188 files would be changed

# what it did well:
#   the deprecated brace interpolation form    41
#   utf8_encode → mb_convert_encoding           4
#   #[ReturnTypeWillChange]                    94
#
# what it got wrong, both found in review:
#   a string containing that form LITERALLY, in a
#     shell command, rewritten and broken
#   a return type added to a method whose subclass
#     returned something narrower, which then failed

Two wrong hunks out of a hundred and eighty-eight files is a good rate and is not zero, which is the argument against merging a tool’s output unreviewed. The shell command one would have been a runtime failure in a deploy script, discovered at the worst possible time.

The canary, one worker at a time

the rollout, over four days:

  day 1   one queue worker on 8.2. 6 hours. error rate
          compared against the other five.
  day 2   all six workers. the web tier untouched.
  day 3   one of four web servers, 20% of traffic by
          weight. p95 and error rate on a graph next
          to the 8.0 servers.
  day 4   all four.

what day 3 found: a 4% increase in p95, traced to
opcache.jit being on by default in the new image and
off in the old one. turned off. difference gone.

The JIT difference is the sort of thing a staging environment does not surface, because it only appears under production traffic patterns. Running the two side by side with the same traffic is the only way to see a four per cent regression, and four per cent is enough to matter and small enough to miss.

Verifying it worked

$ php -v
PHP 8.2.7 (cli)

$ vendor/bin/phpunit 2>&1 | grep -c Deprecated
0

$ composer audit --locked
No security vulnerability advisories found

$ grep -c 'deprecations' config/logging.php
1        # and the channel is 'daily', not null

# a month of production
$ grep -c Deprecated /var/log/app/*.log
0

# and the report that had been wrong since 2021
$ php artisan report:reconcile --month=2023-05
  variance: £0.00        # was £412.88

The reconciliation variance going to zero is the outcome somebody outside engineering cared about, and it needed explaining: the figure had been wrong for two years, which means the historical reports do not match the corrected ones. That conversation is the real cost of finding an old bug.

What this costs

A deadline that arrives again in two years, and the only defence against repeating this is a calendar entry and a deprecation channel that is not null. Both of those are in place now and both are one configuration change away from being undone by somebody clearing noise during an incident.

The six weeks of two CI jobs also doubled the pipeline cost and slowed every merge, which is a tax everybody paid for a migration most of them were not doing. That is the right trade and it is worth stating, because the pressure to shorten it comes from people who are experiencing only the cost.

The honest summary is that deferring twice roughly tripled the work. Two separate upgrades would have been two weeks each with no interaction between them; this was five weeks of elapsed time, a rewritten integration, and a report that had been producing wrong numbers throughout.