PHP 5.5 to 8.4 Migration on a Live Store, and the Revenue Bug It Uncovered

The site was a hand-built stock photo storefront. Login, admin area, bulk image importer, basket, checkout, and client download accounts. About 4,500 lines of PHP across 28 files, written and extended over roughly two decades, running in production and taking real orders every week. It was also running on PHP 5.5, which reached end of life on July 21, 2016. The host had set a shutdown date for that version, so a PHP 5.5 to 8.4 migration was not optional and could not be stretched out.

Three constraints made it interesting. There was no test suite. There was no staging environment yet. And the price was fixed with a hard ceiling, quoted before anyone had opened the code.

Here is the thing about a jump like that. Eleven major versions sounds terrifying, and the instinct is to price it like a rewrite. But crossing eleven versions only matters if the code uses what those versions removed. Most old code does not.

The Sweep That Turned a Budget Threat Into a Half-Day Estimate

Before writing a line I swept every removal and behavior change from 5.6 through 8.4 against the actual codebase. Not a guess and not a compatibility scanner. A direct search for each removed function, each deprecated pattern, each syntax change, file by file.

The blocker list came back small and concentrated:

The list of what was not there mattered more. No ereg. No create_function. No curly-brace string offsets. No PHP 4 style constructors. No short open tags. None of the register_globals-era patterns that turn a legacy port into an archaeology project.

That is what changed the number. Going in, this looked like a threat to the fixed-price ceiling. After the sweep, the honest estimate was four to six hours of real work. Old code is not automatically bad code. This one was written conservatively and it aged far better than its version number suggested.

Scoping a migration by version distance is how you either overcharge a client or scare them into a rebuild they do not need. Scoping it by what the code actually uses is how you land it.

Why I Wrote a Compatibility Layer Instead of Rewriting 39 Call Sites

Thirty-nine database calls is thirty-nine chances to change behavior by accident, on a live store with paying customers and nothing to catch a regression. So I did not rewrite them. I wrote a small mysqli compatibility layer that reproduces the old ext/mysql semantics, then renamed every mysql_* call to its db_* equivalent. That turned the entire database half of the PHP 5.5 to 8.4 migration into a mechanical rename.

Four decisions inside that layer did the actual work. The code below is the pattern, not a verbatim paste from the client file.

Pinning mysqli Back to Silent Mode

PHP 8.1 changed mysqli's default error mode from MYSQLI_REPORT_OFF to MYSQLI_REPORT_ERROR|MYSQLI_REPORT_STRICT, which means mysqli now throws an exception on error instead of returning false. According to PHP.Watch, that is a straight breaking change for any codebase written against the old behavior.

This codebase checks if (!$result) everywhere. Leave the new default in place and every one of those checks becomes unreachable, because the exception fires first. So the layer pins the old mode explicitly. That is not laziness, it is the whole reason the rename stays mechanical.

Guarding the Fetch Helpers Against a Boolean

When a query fails, the old extension handed back false and the next fetch emitted a warning and returned nothing. On PHP 8 that same false is a TypeError and the page stops. The layer checks the type before it fetches, which converts a hard fatal into the clean no-op the surrounding code already expects.

Two smaller choices went in alongside it. The connection lives in a static, so no call site has to pass a link handle. And the connection charset is pinned to latin1 to match what ext/mysql defaulted to. Get that one wrong and every accented character in the catalog quietly re-encodes.

Click to see the PHP code: the compatibility layer's connection and fetch guard (28 lines)
// dbcompat.php - reproduce the old ext/mysql semantics on top of mysqli.

// PHP 8.1 made mysqli throw on error by default. This codebase checks
// if (!$result) everywhere, so exceptions would bypass every one of them.
mysqli_report(MYSQLI_REPORT_OFF);

function db_connect($host, $user, $pass, $name) {
    static $link = null;
    if ($link !== null) {
        return $link;
    }
    $link = mysqli_connect($host, $user, $pass, $name);
    if (!$link) {
        return false;
    }
    // ext/mysql defaulted to latin1. Match it or the catalog re-encodes.
    mysqli_set_charset($link, 'latin1');
    return $link;
}

function db_fetch_assoc($result) {
    // A failed query hands this a boolean. That was a warning on PHP 5
    // and it is a TypeError on PHP 8.
    if (!$result instanceof mysqli_result) {
        return false;
    }
    return mysqli_fetch_assoc($result);
}

Proving the Diff Was Only a Rename

Claiming a change is mechanical is easy. Proving it is the part that lets you sleep. I un-renamed the converted file programmatically and paired every changed line against the original. Every line matched. No behavior slipped in under cover of a find-and-replace.

On a site with no tests, that kind of self-check is the only review you get.

The Two Bugs a Removed-Function Search Would Never Catch

The functions PHP deleted are the easy part. They show up in a search in ten minutes. The failures that actually break a migration are behavioral, and you only find them by reading.

A Getter That Returned NULL Into 54 count() Calls

Two table getters returned NULL when no rows matched, and 54 count() call sites consume those returns. On PHP 5 that passed quietly, because count() accepted whatever you handed it and returned a number. PHP 8.0 made passing an invalid countable type to count() a TypeError.

Read that again in terms of the store. Every empty result set on the site, every search that matched nothing, every category with no photos in it, was now a fatal error. Not a warning in a log. A blank page. The fix was one line in each getter: return an empty array.

A false That Got Saved Into the Session

The add-to-basket function returned false on a validation failure. Both callers assign that return value to the basket variable, and the basket variable goes into the session. So on PHP 8 the next count() against it is fatal, and because the false was persisted, it keeps being fatal on every page load until the session expires.

One bad add poisons the entire browsing session. The customer cannot recover by navigating away. That bug was already live on PHP 5.5 in a milder form, where it renders a phantom empty row in the basket instead of killing the page.

Click to see the PHP code: the one-line basket fix (19 lines)
// Before: a validation failure returned false, the caller assigned
// it to $basket, and $basket went straight into the session.
function addToBasket($basket, $item) {
    if (!isValidItem($item)) {
        return false;
    }
    $basket[] = $item;
    return $basket;
}

// After: hand the basket back unchanged, so nothing downstream ever
// receives a boolean where it expects an array.
function addToBasket($basket, $item) {
    if (!isValidItem($item)) {
        return $basket;
    }
    $basket[] = $item;
    return $basket;
}

The Find That Was Costing Real Money

This one had nothing to do with the version bump, and it is the reason I am writing the post.

The order confirmation email attaches a thumbnail for every item in the basket. The mailer's attachment method calls die() when a file is missing or unreadable.

Trace that through. A customer checks out. One thumbnail is missing from the repository, which happens on any catalog that has been edited for twenty years. PHP stops mid-request. The customer sees a cryptic error. No email is sent. The order is written nowhere, because checkout does not touch the database until the mail goes out. The order is simply gone, and it takes every other item in that basket with it.

I confirmed this on the live PHP 5.5 site, not just on the staging copy. It is production behavior today and it has been for a long time. Nobody knew, because the failure mode is silence. There is no error the owner reads, no failed-order queue, no bounce notification. A customer who tried to buy and got a wall of text just never comes back, and the business never learns it happened.

That is how one defensive line in a mailer becomes months of quiet revenue loss. It is also the argument for reading old code line by line instead of running a scanner over it. I have made this point before on a WooCommerce checkout that failed intermittently, and the pattern holds: the expensive bugs are the ones that fail politely.

What the Compatibility Layer Costs Me Later

The layer is a bridge, not a destination, and I want to be straight about what it leaves behind. The site now runs on PHP 8.4 through a shim that emulates an extension PHP deleted a decade ago.

The call sites still read like 2005. Nobody maintaining this later learns modern mysqli or PDO from it. The technical debt moved, it did not leave. There are still no prepared statements, because SQL is assembled by string interpolation throughout. The layer added real driver-level escaping where the original used a single-quote string replace, which is a genuine improvement, but escaping is not parameterization and I am not going to pretend otherwise. And anyone who picks this up after me has to read my layer before they can read the application.

ApproachWhat it buysWhat it costs
Compatibility layer plus renameDiff is mechanical and reviewable. Behavior cannot drift. Fits a fixed-price ceiling.Call sites still read like 2005. No prepared statements. A future maintainer has to learn the layer.
Full rewrite to PDOParameterized queries, modern code, no shim to explain.39 chances to change behavior by accident, on a live store with no test suite. A different job at a different price.

Would I build it the other way with a bigger budget? Yes. PDO with prepared statements, file by file, behind a test harness. That is a different engagement at a different price. What I was hired to do was get a working store onto a supported PHP version without changing what customers see, before a host deadline. A data-layer rewrite would have introduced risk the client was not paying to take, on a codebase with nothing to catch it.

How I Proved It Landed

No test suite means you build the test. The whole case for a PHP 5.5 to 8.4 migration done this way rests on being able to show the client that nothing visible changed, so output comparison was the test.

I picked 18 URLs covering the paths that matter: category filters, pagination, keyword search, empty result sets, individual photo detail pages, the print view, the basket, and the download accounts. Then I fetched each one from the live 5.5 site and from the 8.4 copy and compared the bytes.

Every one came back byte-identical once you strip two warning blocks the live site prints. The delta was exactly 916 bytes, every single time, across all 18. That consistency is the actual signal. A real behavioral difference anywhere in the port would have moved that number on at least one page, and it never moved.

Those two warning blocks were their own finding. The live site had display_errors on and a file existence probe that reached above the account root, so it was printing its absolute server path to every visitor on every page load. Both are fixed in the ported copy.

Then the part that actually settles it. I pushed a real order through checkout on the ported site, with order mail routed to me by a hostname-keyed switch that physically cannot fire on the production domain. The email arrived with the client block, the order block, the thumbnail URL, size, price, subtotal, and total all intact.

Byte-identical output across 18 pages, plus one completed order with the confirmation in hand. That is evidence I am comfortable handing a client.

What I'd Tell Myself at the Start of This One

Sweep before you quote. The estimate before the breaking-change sweep and the estimate after it were describing two different jobs. Version distance is not scope. Actual usage is scope, and it takes an hour to find out which one you are dealing with.

The bugs that hurt are behavioral, not syntactic. Every deleted function turned up in a search almost immediately. The NULL flowing into 54 count() calls, the false persisted into the session, and the die() in the mailer were all found by reading, and all three were live bugs before anybody typed the words PHP 5.5 to 8.4 migration.

Byte comparison is underrated. On a codebase with no tests, diffing rendered output against the version that already works is the cheapest safety net you can build, and it takes an afternoon. Nine hundred and sixteen bytes, eighteen times, told me more than any assertion I could have written by hand.

The last one is what I keep coming back to. The client hired me to move PHP versions. What they got was the version bump plus three fixed production bugs, one of which had been quietly eating orders for months. That is usually how legacy work goes. The thing you were hired for is rarely the most valuable thing you find. If you are sitting on a site that is a few versions behind and nobody wants to touch it, that is the work I do, and the first hour of it is always just reading.

Sources

0 Comments

Submit a Comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Post Search

Follow Us

Feel free to follow us on social media for the latest news and more inspiration.

Related Content