— Article — № 030

030 —PHP

PHP 5.6 to PHP 8.2 in one weekend: the realistic version

The Loom landed at 23:41 on a Friday. Forty-eight hours to take a 14-year-old PHP codebase from 5.6 to 8.2. Here is the actual order it broke in.

Flat-lay of a paper ledger titled PHP 5.6 to 8.2, stencilled printouts with red strike-throughs, brass paperweight on oak.
Hero · staged still№ 030

The Loom landed at 23:41 on a Friday. A Dutch agency we work with had inherited a 14-year-old PHP application for a logistics client, and their managed host had just sent the standard "PHP 5.6 will be disabled on Monday at 09:00 CET" email. The agency had two business days to move the codebase to PHP 8.2, restore feature parity, and not invoice a panic. The 5.6 to 8.2 jump skips four major versions. Most blog posts about that jump pretend you can plan for it. Real forced migrations rarely give you the option.

This is a case study of what actually happened over that weekend. Names are anonymised. The code patterns are typical of every long-lived custom PHP application we have opened in the last five years.

What the codebase looked like at 23:42

The application was a custom freight-booking portal sitting on a single VPS. Apache 2.2, PHP 5.6.40, MySQL 5.5, and 71,000 lines of PHP across three loosely-coupled modules. Half of it had been written in 2012. The other half was a 2019 attempt to modernise parts of it that had stopped halfway. No tests. One cron file. A config.inc.php that contained the database credentials in plain text.

The first thing we did was list what would categorically not survive the cutover. The PHP manual's backward incompatible changes pages are the single best document for this. We walked them in order: 5.6 to 7.0, 7.0 to 7.4, 7.4 to 8.0, 8.0 to 8.2. Each one is short. Read them in sequence and you have a working mental map of every breaking pattern between you and Monday morning.

The greps that mattered most:

grep -rEn "mysql_(query|connect|fetch_|num_rows|real_escape)" .
grep -rEn "ereg_?|split\(|each\(|create_function" .
grep -rEn '\$[A-Za-z_]+\{[0-9]+\}' .
grep -rEn "function +[A-Z][A-Za-z_]*\s*\(" .   # PHP4-style constructors (heuristic)

It found 318 hits across 47 files. About 80% in two files. That was the encouraging discovery of the night. Legacy PHP rarely distributes its sins evenly. Two files own most of the technical debt and you can usually find them with one weekend's grep.

Saturday 02:00. The plan we wrote on a napkin

We were not going to refactor anything. We were going to apply the smallest change that makes each pattern run on the target runtime, deal with the resulting warnings on Sunday, and ship. The list:

  1. Replace all mysql_* calls with a thin mysqli shim. Same call sites, prefixed function names.
  2. Convert PHP4-style constructors to __construct. Mechanical.
  3. Replace ereg and split with preg_ equivalents.
  4. Fix curly-brace string offsets ($s{0} becomes $s[0]).
  5. Run the application on PHP 8.2 locally with error_reporting(E_ALL) and watch what burns.

We did not touch the database schema. We did not touch templates. We did not tidy up anything. Every change that wasn't required to boot was deferred to a follow-up sprint. The single biggest mistake people make on a forced PHP migration is treating it as the moment to finally fix the code. It is not. It is the moment to make the code run.

The shim, in full:

function db_query($sql) {
    global $db;
    $r = mysqli_query($db, $sql);
    if ($r === false) {
        error_log("SQL: " . mysqli_error($db) . " :: " . $sql);
    }
    return $r;
}
function db_fetch_assoc($r) { return $r ? mysqli_fetch_assoc($r) : false; }
function db_num_rows($r)    { return $r ? mysqli_num_rows($r) : 0; }
function db_escape($s) {
    global $db;
    return mysqli_real_escape_string($db, $s);
}

Then a sed pass against every PHP file:

find . -name "*.php" -print0 | xargs -0 sed -i '' \
    -e 's/mysql_query(/db_query(/g' \
    -e 's/mysql_fetch_assoc(/db_fetch_assoc(/g' \
    -e 's/mysql_num_rows(/db_num_rows(/g' \
    -e 's/mysql_real_escape_string(/db_escape(/g'

The point of the shim is that we are not yet trying to fix SQL injection or refactor to prepared statements. That is a Q3 project. The shim buys the application a runtime on the new interpreter without changing call sites.

Saturday 11:00. What actually broke on PHP 8.2

By late Saturday morning the application booted. About half of it returned 500. The errors fell into five categories.

Dynamic property deprecations

PHP 8.2 deprecated assigning to undeclared properties (see the RFC). The legacy code assigned things like $user->cached_orders = [...] from outside the class, on a class that had never declared cached_orders. In production this should have been a quiet log line, but the host had display_errors=On in the shared php.ini, so the deprecation banner rendered inside the JSON the iOS app was consuming, and the iOS app stopped parsing responses.

Two options: declare every property properly, or add #[\AllowDynamicProperties] to the offending classes. We did the second one. It is the documented escape hatch and it took fourteen minutes.

String interpolation with curly braces

"Hello ${name}" is deprecated. "Hello {$name}" is fine. The fix is a regex pass and a code review.

grep -rEn '\$\{[A-Za-z_]' --include="*.php" .

utf8_encode and utf8_decode

Both deprecated in 8.2. The application used utf8_encode on column values coming back from a latin1 MySQL table (charming). Replacement: mb_convert_encoding($s, 'UTF-8', 'ISO-8859-1'). Same result, no deprecation, requires mbstring, which most current host builds ship by default.

Implicit float-to-int conversions

A reporting module did $pages = $total / $per_page and then used $pages as an array index. PHP 8.1 made the implicit float-to-int conversion a deprecation warning. We wrapped the assignment in (int) ceil(...). Three places.

Constructor signature mismatches

Child classes overriding a parent constructor with a different signature now throw. We hit this once, in a payment-gateway abstraction. The fix was adding ...$args to the parent signature and re-running the suite. We did not have a suite, so we re-ran the booking flow manually.

Saturday 17:00. The cron jobs nobody had logged in

Two cron jobs ran nightly: one generated invoices, one synced a stock feed to a partner over SFTP. Both had been written against PHP 5.6 CLI. On the new CLI the invoice job failed because it called create_function, removed in 8.0. Two lines, replaced with an anonymous function. The stock-feed job failed because ssh2_* functions were not compiled into the host's PHP build. We swapped to phpseclib, which is pure-PHP and required nothing from the host. The composer install took two minutes. The job ran clean on the next tick.

The lesson, if there is one: when you grep for breaking patterns, grep the cron files too. They tend to live in /etc/cron.d/ or in a separate directory under the application root and they get missed by default.

Sunday 03:00. The .htaccess change nobody warned us about

The host's new stack ran under PHP-FPM instead of mod_php. The agency's deploy was written assuming mod_php. Two things broke immediately.

First, the application set php_value upload_max_filesize 64M inside .htaccess. Under FPM, Apache cannot set PHP ini values that way. The Apache error is unambiguous once you know to look for it:

Invalid command 'php_value', perhaps misspelled or defined by a module not included in the server configuration

The replacement lives in a .user.ini file in the document root. The directives carry over; the file format changes:

upload_max_filesize = 64M
post_max_size = 80M
memory_limit = 256M

The php.net docs describe the syntax. The gotcha worth writing down: .user.ini is cached for 300 seconds by default. If you are testing changes and they appear not to apply, that is why. Set user_ini.cache_ttl = 0 in php.ini for the test domain.

Second, the RewriteRule block that put the PHP session id in URLs for cookie-disabled clients (yes, in 2026) broke because the FPM front end stripped PHPSESSID from the query string. We removed the URL-session fallback entirely. None of the live clients had cookies disabled. We checked the access log before removing it.

Sunday 14:00. The rollback we did not need

By Sunday afternoon the application booted, the iOS client parsed responses, both cron jobs ran clean, and PDF invoices generated. We had built a rollback plan at the start, a single git checkout plus a mysqldump restore. We never used it. The closest we came was reverting one composer install because PHPMailer 6.10 dropped support for an embedded font the invoice template used. Reverting to 6.9.3 took 90 seconds.

The two things that mattered for not needing the rollback:

  • The shim. By not refactoring SQL access during the migration, the surface area of the change stayed small enough that the bugs that did appear were small ones.
  • Database left alone. The new interpreter happily talks to MySQL 5.5. The database upgrade landed six weeks later, separately, with its own rollback plan and its own weekend.

What four weeks would have changed

Honestly, very little structurally. The PHP 8.2 migration order is the same either way: PHP first, database second, dependencies third, refactor fourth. What four weeks buys you is staging-environment parity and a real test plan. With 48 hours, you replace tests with a tail of the production error log and a paid intern clicking through the booking flow on a Saturday morning. Both work; one is calmer.

The other thing we would have done is version-controlled the .htaccess and .user.ini from the start. The single highest-pain moment of the weekend was at 04:11 on Sunday, when a directive had been changed inside the live .htaccess for debugging and nobody could remember the original value. A backup taken at 23:50 on Friday saved us thirty minutes of guessing.

The smallest thing this is worth saying out loud

When we built Pier we ran into this exact thing repeatedly. The pattern: an agency takes over a legacy site, gets a host-EOL email, and spends a weekend grepping for mysql_ and editing .htaccess by FTP. The way we ended up handling it was making every save snapshot itself automatically, so the "what was the original value" question has a one-click answer in the version history, and the database side of the cutover lives inside the same MySQL editor as the file side.

If you have a PHP 5.6 or 7.x site still in production, the smallest thing you can do today is run the four grep commands at the top of this post against the codebase. The output predicts your weekend.

— Questions —

Can you really go from PHP 5.6 to PHP 8.2 in one weekend?

Sometimes. It depends on how many of the breaking patterns concentrate in how few files. The greps at the top of this post tell you within ten minutes whether your codebase is realistic for a weekend cutover.

Should I go to PHP 7.4 first as an intermediate step?

Only if you have weeks to spare. PHP 7.4 is already unsupported and most hosts will stop offering it. Going straight to 8.2 means one round of breakage, not two, and one weekend of work instead of two.

What about the database during a PHP version jump?

Leave it. PHP 8.2 talks to MySQL 5.5, 5.6, 5.7, 8.0 and MariaDB 10.x without complaint. Coupling a database upgrade to a PHP upgrade doubles the surface area of the change without doubling the value.

Do I need to replace mysql_* calls with prepared statements during the cutover?

No. A thin shim that maps mysql_* to mysqli_* keeps the application running on 8.2 without changing call sites. Prepared statements and SQL injection hardening are a separate project, done after.