PHP 5.3 stops getting security fixes this August. The host has already published a date for moving the shared boxes, which makes this an upgrade with a deadline attached rather than a piece of housekeeping. The application is six years old, was written against 5.2, and has never been run on anything newer. This is the order the work went in, which turned out to matter more than any individual change in it.
The symptom
The first measurement was not a benchmark. It was the size of the error log on the one server already running 5.5 for an unrelated reason.
$ wc -l /var/log/php_errors.log
128744 /var/log/php_errors.log
$ grep -c 'Deprecated:' /var/log/php_errors.log
124019
$ grep 'Deprecated:' /var/log/php_errors.log
> | sed 's/.*Deprecated: *//' | cut -c1-60 | sort | uniq -c | sort -rn | head -4
81440 mysql_connect(): The mysql extension is deprecated
38112 mysql_query(): The mysql extension is deprecated
4102 preg_replace(): The /e modifier is deprecated, use
365 Assigning the return value of new by reference isFour thousand deprecation lines a day, in a log nobody read because it had been full of noise since before anyone currently on the project joined. That is worth stating plainly: the log had stopped being a diagnostic instrument, and the upgrade’s first real deliverable was getting it back.
Why it happens
The mysql_* functions are deprecated in 5.5. They still work, they will keep working for years, and every one of them emits E_DEPRECATED when the extension is first used. The code predates PDO by about four years, so there are roughly 400 call sites, none of which are wrong — they are just written against an interface that has been superseded twice.
The rest of the noise is the same story at smaller scale: the /e modifier on preg_replace, which evaluates its replacement as PHP and is deprecated for exactly the reason you would guess; and assigning the return value of new by reference, which was a 5.2 idiom that has been pointless since objects became handles.
None of that is what breaks an upgrade. The breakages come from 5.4, which is skipped over on the way to 5.5 and removes things outright rather than deprecating them.
The fix
Deprecations first, on the version you are already running
Everything that is merely deprecated in 5.5 can be fixed on 5.3, shipped normally, and verified by the existing tests. That is most of the diff, and doing it first means the risky part of the upgrade arrives as a small change rather than a large one.
The mysql_* work does not have to be a rewrite. A thin layer with the same function names, backed by PDO, converts the whole codebase without touching 400 call sites — and it forces prepared statements at the point where the old signature accepted an already-interpolated string.
final class Db
{
private static $pdo;
public static function connect(array $config)
{
self::$pdo = new PDO(
"mysql:host={$config['host']};dbname={$config['name']};charset=utf8",
$config['user'],
$config['pass'],
array(
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
)
);
}
public static function rows($sql, array $params = array())
{
$stmt = self::$pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
}
The call sites then change mechanically, and the change is greppable rather than clever. It is worth resisting the temptation to improve the queries at the same time: a mechanical diff of 400 lines can be reviewed, and a diff where 380 lines are mechanical and 20 are not cannot.
Note
charset=utf8 in the DSN only works from 5.3.6 onwards. Before that it is silently ignored and the connection stays latin1, which produces the mojibake that gets blamed on the database for the next two years. Check the patch version before trusting it.
Then the things 5.4 removed
These are fatal errors, not warnings, and they are findable statically. Two hours with grep and a syntax check against a 5.5 binary produced the entire list before a single file was deployed anywhere.
# call-time pass-by-reference: a fatal error since 5.4
grep -rn --include='*.php' -E 'w+(s*&$' .
# the /e modifier: deprecated in 5.5, gone in 7
grep -rn --include='*.php' -E "preg_replace(s*['"][^'"]*e[imsx]*['"]" .
# and the cheapest check of all — parse everything with the new binary
find . -name '*.php' -not -path './vendor/*'
-exec /usr/bin/php5.5 -l {} ; | grep -v 'No syntax errors'
The parse pass found 14 files, all of them the same call-time reference idiom in code written before 2009. The /e replacements are the more interesting half, because the conversion is not mechanical — the replacement string was PHP source, and it has to become a function.
// before: the replacement is PHP source, with the match pasted into it
$out = preg_replace(
'/[user:(d+)]/e',
'render_user("\1")',
$body
);
// after: the match arrives as data and stays data
$out = preg_replace_callback(
'/[user:(d+)]/',
function (array $m) {
return render_user((int) $m[1]);
},
$body
);
Longer, and considerably easier to read six months later. It also closes a real hole: with /e, a crafted match is executed, and at least two of these were interpolating text a customer had typed.
One removal is not findable this way and deserves its own paragraph, because it changes behaviour silently rather than fatally.
Caveat
5.4 changed the default charset of htmlspecialchars() from ISO-8859-1 to UTF-8. Any string that is not valid UTF-8 now returns an empty string rather than mangled output. On a site holding a decade of latin1-encoded product descriptions this shows up as blank fields in the template, with no error anywhere, and it is the single nastiest thing in the whole upgrade.
APC out, OPcache in
There is no working APC build for 5.5. This is not a version-pinning problem to be waited out: opcode caching moved into the core as Zend OPcache, APC’s opcode half became redundant, and its maintenance stopped. The extension has to go.
The replacement is bundled, which is the good news. The bad news is that every setting tuned over five years has a new name, and some have no equivalent at all — so the old apc.ini is a translation exercise rather than a rename.
; apc.ini — five years of tuning, now inert
apc.shm_size=256M
apc.num_files_hint=4000
apc.stat=0
apc.include_once_override=1
apc.write_lock=1
; opcache.ini — the same intent, expressed differently
opcache.memory_consumption=256
opcache.max_accelerated_files=8000
opcache.validate_timestamps=0
opcache.interned_strings_buffer=16
opcache.fast_shutdown=1
apc.stat=0 becomes opcache.validate_timestamps=0, and it carries the same trap: with timestamps unvalidated, a deploy changes files on disk and the cache keeps serving the old opcodes until it is explicitly reset. apc.num_files_hint was a hint; opcache.max_accelerated_files is a hard limit, rounded up to the next prime internally, and files past it are simply not cached. Setting it from the actual file count rather than from the old hint is the difference between a warm cache and a mystery.
Warning
There is no equivalent of apc.include_once_override, and none is needed — but there is also no warning that the setting has been dropped. An unknown opcache.* key is ignored in silence, so a typo in this file produces a default rather than an error. Confirm each value through opcache_get_configuration(), not by reading the file back.
And the user cache half
APC provided two things: an opcode cache and a shared-memory key-value store reached through apc_fetch() and apc_store(). OPcache replaces the first and deliberately does not provide the second. The user cache moves to APCu, which is the same code split into its own extension and keeps the function names.
// the compatibility layer, so the call sites do not care which is loaded
if (!function_exists('apc_fetch')) {
throw new RuntimeException('Neither APC nor APCu is available.');
}
final class Cache
{
public static function remember($key, $ttl, callable $compute)
{
$value = apc_fetch($key, $found);
if ($found) {
return $value;
}
$value = call_user_func($compute);
apc_store($key, $value, $ttl);
return $value;
}
}
The &$found out-parameter rather than a comparison against false, because a cached false or 0 is otherwise indistinguishable from a miss and turns a hot key into a permanent recompute. That bug was already present against APC; it was only noticed while moving the code.
Worth knowing for later: an APCu cache is per-process-pool and disappears on an FPM restart, so it is only ever appropriate for data that is cheap to recompute. Anything that must survive a deploy belongs somewhere else.
Verifying it worked
Tests covered about a third of this application, which is not enough to authorise a version bump. The check that carried the decision was a replay: the same set of requests through both versions, comparing output and timing.
#!/usr/bin/env bash
# replay.sh — the 200 most-requested URLs of the last week, both versions
set -eu
while read -r url; do
slug=$(echo "$url" | md5sum | cut -c1-8)
curl -s "http://old.shop.internal$url" > "/tmp/53/$slug.html"
curl -s "http://new.shop.internal$url" > "/tmp/55/$slug.html"
done < top-200-urls.txt
diff -rq /tmp/53 /tmp/55 || true
$ ./replay.sh
Files /tmp/53/9c1f4a2b.html and /tmp/55/9c1f4a2b.html differ
Files /tmp/53/e07db311.html and /tmp/55/e07db311.html differ
# both were the htmlspecialchars charset change: latin1 descriptions
# rendering as empty strings on 5.5
$ ab -n 500 -c 10 http://old.shop.internal/catalogue/frames
Time per request: 214.882 [ms] (mean)
$ ab -n 500 -c 10 http://new.shop.internal/catalogue/frames
Time per request: 138.401 [ms] (mean)Two differing pages out of two hundred, both the same root cause, both found before anyone saw them. The timing improvement is real but should not be oversold — most of it is OPcache being better tuned than the APC configuration it replaced, not the interpreter being faster.
What this costs
A fortnight of unglamorous edits with nothing to show at the end. No feature ships, the site looks identical, and the honest summary for anyone asking is that the application now runs on a version that will still receive security fixes in six months. That is worth saying out loud at the start rather than defending at the end.
One extension had no 5.5 build and no source that would compile against it: a vendor-supplied binary for a card-reader SDK, shipped as a 5.3 .so and abandoned. The workaround is a separate 5.3 CLI process behind a small local socket, which is exactly as pleasant as it sounds — one machine in the estate is now pinned to an unsupported PHP for one function, and the ticket to replace it will be open for a long time.
The last cost is the log. It is now readable, which means the twelve real warnings it had been hiding are visible, and they are all genuine bugs that have been in production for years — an undefined index in a checkout branch, a division by zero in a report, a file handle never closed. None of them were introduced by the upgrade. All of them are now somebody’s to fix, and the log will fill up again the moment that stops being true.
Two things I would do differently. The deprecation pass should have been six separate deploys rather than two, because the one that touched 400 call sites was reviewed by nobody in any meaningful sense. And the OPcache configuration should have gone in a week ahead of the version bump on a single server, since the hit rate is the only way to tell whether the memory setting is right, and it takes a day of real traffic to say anything.