PHP 7.0 has been out for two months and the benchmarks are not exaggerating: the same application serving the same pages, roughly twice the requests per second on the same hardware, using around half the memory per worker. This is what it took to get there on a codebase that started life on 5.3, and the parts of it that the upgrade guide does not mention.
The symptom
Two pressures arriving at once. 5.6 goes to security-only support at the end of this year, and the box was at its ceiling — twelve PHP-FPM workers at 43 MB each, on a machine that also had to hold a MySQL buffer pool, and no headroom to add more.
$ ps -ylC php5-fpm --sort:rss | awk 'NR>1 {s+=$8; n++} END {print s/n/1024 " MB avg"}'
43.1 MB avg
$ free -m
total used free buff/cache
Mem: 1994 1810 71 113Seventy megabytes free. Every traffic spike was one request away from swapping, and the answer available before this was a bigger server.
Why it happens
PHP 7 is a rewrite of the engine’s value representation, not a collection of optimisations. A zval went from 24 bytes to 16 and stopped being allocated separately for every value; arrays became a single contiguous block instead of a hash table of pointers to buckets. An application whose working set is mostly arrays — which is every PHP application — gets both the memory and the speed as a consequence of the same change.
The corollary is that the gain is not uniform. Code dominated by database waiting improves hardly at all, because the bottleneck was never the interpreter. Code building large arrays improves dramatically.
The fix
The compatibility audit, before anything is installed
The removals are the whole risk, and they are knowable in advance. A static scan against the 7.0 ruleset lists every one of them without running the code.
$ phpcs --standard=PHPCompatibility --runtime-set testVersion 7.0 src/
FILE: src/legacy/Db.php
14 | ERROR | Extension 'mysql' is removed since PHP 7.0; use mysqli or PDO
62 | ERROR | Function ereg() is removed since PHP 7.0
FILE: src/Report/Builder.php
188 | ERROR | Use of a class constructor named after the class is deprecatedThree categories came out of that scan. The mysql_* extension, gone entirely and mechanical to replace with PDO. The POSIX regex functions, gone, and a straight translation to PCRE apart from one pattern that had been subtly wrong for years. And PHP 4 style constructors, which are quietly ignored now rather than being called — which is the worst of the three, because nothing errors and the object is simply never initialised.
Warning
The old-style constructor is the one to search for by hand as well. A class named Cart with a method Cart() used to have a constructor and now has an ordinary method that nothing calls. There is no error, no warning, and the object comes back with every property at its default.
The behaviour changes that are not removals
Four things changed meaning rather than disappearing, and none of them is reported by a scanner.
// 1. Uncaught errors are Throwable, not fatal.
// Every top-level catch (Exception $e) now misses them.
try {
$order->total();
} catch (Throwable $e) { // not Exception
$handler->report($e);
}
// 2. Uniform variable syntax reversed some evaluation orders.
$$foo['bar']; // PHP 5: ${$foo['bar']} PHP 7: ($$foo)['bar']
// 3. list() assigns left to right now, and does not unpack strings.
list($a, $b) = 'ab'; // PHP 5: 'a','b' PHP 7: null, null
// 4. Integer division by zero throws instead of warning and returning false.
intdiv(1, 0); // DivisionByZeroError
The first is the one that matters operationally. The framework’s exception handler caught Exception, so on 7.0 a TypeError anywhere in the application produced a blank page instead of the error template — and it did so in production, on the paths the test suite did not reach.
Both versions on one server
The useful property of PHP-FPM here is that two versions can run side by side on different sockets, so the switch is an nginx directive rather than a migration.
$ sudo add-apt-repository ppa:ondrej/php
$ sudo apt-get install -y php7.0-fpm php7.0-mysql php7.0-curl php7.0-gd php7.0-mbstring
$ sudo systemctl status php7.0-fpm | head -3
● php7.0-fpm.service - The PHP 7.0 FastCGI Process Manager
Active: active (running)
$ ls /var/run/php*
/var/run/php5-fpm.sock /var/run/php/php7.0-fpm.sockupstream php_backend {
server unix:/var/run/php5-fpm.sock;
}
# flip this line, reload, and the whole site is on 7.0
# server unix:/var/run/php/php7.0-fpm.sock;
location ~ .php$ {
try_files $uri =404;
fastcgi_pass php_backend;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
One line, one reload, and a rollback of the same shape. That is what made it possible to do this during the day rather than at two in the morning: the worst case was ten seconds of the old version, not a restore.
Tip
Point a staging hostname at the 7.0 socket first and leave it there for a fortnight. Most of what surfaces is not in the request path at all — a cron job, an import, a PDF generator using an extension nobody had thought about since it was installed.
The extension that did not exist
Every compiled extension needs a 7.0 build, and this is where a migration stalls. Three of the four were in the PPA. The fourth was a payment provider’s SDK shipped as a binary .so against PHP 5, with no 7.0 version and no source.
The provider had a REST API alongside the SDK, undocumented in the integration guide and perfectly usable, so the resolution was to drop the extension entirely and talk HTTP. That is not always available. It is worth finding out which extensions are compiled rather than bundled, and checking each one, before committing to a date — it is the only part of this with an unbounded worst case.
$ php5 -m > /tmp/m5; php7.0 -m > /tmp/m7; diff /tmp/m5 /tmp/m7
< mcrypt
< provider_sdk
---
> Zend OPcacheVerifying it worked
Two measurements, taken the same way on both versions against the same traffic profile.
# catalogue listing, 500 requests, concurrency 20
# php 5.6
Requests per second: 58.31 [#/sec]
Time per request: 343 [ms]
Memory per worker: 43.1 MB
# php 7.0
Requests per second: 121.44 [#/sec]
Time per request: 164 [ms]
Memory per worker: 22.4 MBA little over twice the throughput and a little under half the memory, which matches what the benchmarks promised closely enough that a much larger claim would have been suspicious.
The memory figure is the one with an operational consequence, and it is routinely left on the table: the pool was still sized for 43 MB workers. Recalculating it turned twelve workers into twenty-two on the same machine, which is a capacity increase that cost nothing.
; /etc/php/7.0/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 22 ; was 12
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 500
What the test suite could not tell us
The suite passed on 7.0 on the second day, which felt like the migration being finished and was not. Tests exercise the paths somebody thought to write a test for, and the failures that arrived over the following fortnight were all in paths nobody had: a CSV import using each(), a PDF generator relying on an extension, and a scheduled task that had not run yet.
The cheapest instrument for that gap turned out to be the error log, read deliberately rather than searched after something broke.
# every deprecation and notice from the staging box, grouped
$ awk -F'] ' '/PHP (Deprecated|Notice|Warning)/ {print $2}' /var/log/php7.0-fpm.log
> | sed 's/ in /var/www.*//' | sort | uniq -c | sort -rn | head
412 PHP Deprecated: The each() function is deprecated
88 PHP Warning: A non-numeric value encountered
31 PHP Notice: Undefined index: shipping_zoneThe each() count is the useful one — 412 occurrences from a handful of loops, all inside an import that runs weekly and would have failed the following Sunday. None of it was in the test suite and all of it was in the log within a day of pointing staging at 7.0.
Tip
Set error_reporting to E_ALL on staging for the duration of a migration, even though it is noisier than anyone wants. The notices are the migration telling you where it is not finished, and turning them down disables the only instrument that sees the untested paths.
The string-to-number comparison that quietly changed
One behaviour change deserves its own section because it is the only one capable of producing a wrong answer rather than an error. PHP 7 altered how a string is compared with a number, and the code that depends on it looks entirely ordinary.
// PHP 5: the string is cast to 0, so this is true
// PHP 7: the comparison is still loose, but the parsing changed
var_dump('abc' == 0); // 5.6: true 7.0: false
var_dump('1abc' == 1); // 5.6: true 7.0: true, with a notice
var_dump('0x1A' == 26); // 5.6: true 7.0: false
The first line is the one that matters. A permission check written as if ( $role == 0 ) against a role of 'admin' was true on 5.6 and is false on 7.0 — which in that particular codebase happened to fail closed, and could just as easily have gone the other way.
There is no scanner for this, because the code is valid on both versions and the intent is unknowable from the syntax. The only defence is to grep for loose comparison against a numeric literal and read each one, which on this codebase was about eighty places and took an afternoon.
$ grep -rnE '[=!]=s*-?[0-9]+b' src/ --include='*.php' | grep -v '===' | wc -l
81Roughly seventy of those were comparisons between two things that were genuinely numbers and could be left alone. Eleven were comparing a database value of unknown type against a literal, and four of those changed behaviour. Converting them to === with an explicit cast is the fix, and it is the only part of this migration that required reading code rather than running a tool.
Rolling it across the fleet
Two web servers behind a load balancer means the switch does not have to be simultaneous, and making it deliberately gradual turns the riskiest ten minutes into an hour of watching a graph.
# take web-02 out, flip it, put it back at reduced weight
$ ssh web-02 'sudo sed -i s/php5-fpm.sock/php/php7.0-fpm.sock/ /etc/nginx/sites-enabled/shop && sudo nginx -t && sudo systemctl reload nginx'
$ cat /etc/nginx/conf.d/upstream.conf
upstream shop {
server web-01:80 weight=9; # 5.6
server web-02:80 weight=1; # 7.0
}Ten per cent of traffic on the new version for an afternoon, with error rate and latency split by upstream in the log. Both numbers have to be read per server rather than in aggregate — averaged across the fleet, a 10% slice behaving badly is invisible.
log_format upstreamed '$upstream_addr $status '
'req=$request_time upstream=$upstream_response_time';
That found one thing worth finding: a page throwing a TypeError on 7.0 and a 500 on web-02 only, at a rate of about one request in four hundred. On a simultaneous switch it would have been one in four hundred across the whole site during the busiest hour of the migration, which is the difference between a quiet fix and an incident.
The weights moved to even the following morning and to 7.0-only the day after. Nothing about that schedule was necessary; it was cheap, and it converted an irreversible moment into three reversible ones.
What this costs
The unbounded risk is the compiled extension with no 7.0 build, and it is not a risk you can size from the code. Audit that list first, before writing any migration plan around it, because everything else in this is predictable and that one item can stop the whole thing indefinitely.
The second cost is that the codebase is now on a version supporting things it does not use — scalar types, return types, the null coalescing operator — and the temptation is to modernise while migrating. Resist it. The upgrade should be provably behaviour-preserving so that a problem afterwards has one possible cause. The refactoring is a separate change, and it is more enjoyable when nobody is watching a graph.