027 —Migration
Custom PHP intranet migration: off Windows 2008 in a weekend
An undocumented custom PHP intranet, a Windows 2008 box being pulled Monday, and one weekend. Here is the inventory, the breakages, and the cutover.
The Loom came in at 23:41 on a Thursday. A two-person ops team at a logistics company we work with had just been told their Windows Server 2008 R2 box was leaving the rack on Monday morning. The building landlord was decommissioning the comms room, there was no extension, and on that box sat a custom PHP intranet built around 2011 that every dispatcher used to log loads, print run sheets and check driver certifications. No vendor. No documentation. The original developer had been gone for six years. They had a weekend to migrate it off Windows entirely.
This is a walkthrough of that PHP intranet migration from start to cutover, including the things Windows had been quietly hiding for a decade. Nothing here is exotic. The reason it fits in a weekend is that the work is mostly mechanical once you know where the bodies are buried, and the only step that can lose data with no way back is the one almost everyone underestimates.
The Friday inventory
You cannot plan a PHP intranet migration you have not measured. The first two hours were RDP in, no edits, just look. The shape that came back was the shape of a thousand internal apps from that era:
- PHP 5.4 running through FastCGI on IIS 7.5, app root at
C:\inetpub\wwwroot\intranet. - MySQL 5.5, single database, tables declared
latin1but visibly holding UTF-8 bytes. - Authentication against Active Directory over plain LDAP to a domain controller on the LAN.
- Three Windows Task Scheduler jobs calling
php.exeon a timer for nightly syncs. - Uploaded documents written to a mapped drive, referenced in code as
D:\shares\intranet-docs. - URL routing handled by a stack of
rewriterules insideweb.config.
Microsoft ended extended support for Windows Server 2008 R2 in January 2020, so none of this was a surprise. The point of the inventory is not judgement, it is the migration checklist writing itself. Every item above is a thing that breaks on Linux and PHP 8 in a specific, predictable way.
Capturing that list is itself part of the work, because half of it is invisible from the file system. The scheduled jobs in particular do not live in the codebase at all; they live in the Task Scheduler tree, and the only safe way to read them off a box you are about to lose is to export them before you touch anything:
schtasks /query /fo LIST /v > C:\tasks-dump.txt
schtasks /query /tn "\IntranetSync" /xml > C:\task-sync.xmlThe XML matters more than it looks. It records the working directory each task ran in, and a surprising amount of old PHP assumes the directory Task Scheduler happened to hand it rather than an absolute path. That assumption is the single most common reason a job that “is the same command” silently does nothing on the new box.
Standing up the Linux target
We provisioned a clean Debian 12 VM on infrastructure the team already paid for, and chose Apache plus PHP-FPM rather than nginx. The reason was narrow and practical: the web.config rewrite logic maps almost line for line onto mod_rewrite, and on a 48-hour clock you do not want to be re-deriving routing semantics in a new engine.
apt update
apt install -y apache2 php8.2 php8.2-fpm php8.2-mysql \
php8.2-ldap php8.2-mbstring php8.2-curl php8.2-gd mysql-server
a2enmod rewrite proxy_fcgi setenvif
a2enconf php8.2-fpm
systemctl restart apache2Two php.ini values were set before any code ran, because their absence produces noise that masks the real errors: date.timezone = Europe/Amsterdam and display_errors = Off with log_errors = On pointing at a file we could tail -f. From PHP 7.0 onward an unset timezone is a warning on every date() call, and a screen full of warnings hides the one fatal that actually matters.
One more thing before any code runs: ownership. IIS executed the app as a service identity nobody had thought about in years; on the new box PHP-FPM runs as www-data, and the upload directory has to be writable by exactly that user and nothing wider. A chown -R www-data:www-data /srv/intranet-docs plus a find . -type f -exec chmod 644 {} + sweep is thirty seconds of work that prevents the class of “it works over SSH but not in the browser” confusion that otherwise eats an hour at the worst possible time.
Porting the PHP that Windows had been hiding
This is the part of any custom PHP intranet migration that eats the most clock, and almost all of it is one of three failures.
Case sensitivity, the silent one
Windows does not care whether you wrote require 'Includes/Config.php' or includes/config.php. Linux cares absolutely. The first page load on the new box returned:
PHP Warning: include(includes/Config.php): Failed to open stream:
No such file or directory in /var/www/intranet/bootstrap.php on line 12The fix is not to rename files at random until it loads. Find every include and require, then reconcile the literal string against the real filename on disk:
grep -rnoE "(include|require)(_once)?[^;]+" /var/www/intranet \
| grep -iE "\\.php" | sort -uOn this app there were eleven mismatches, all created over years of someone on Windows not noticing the casing drift. We fixed the call sites, not the filenames, so the version control history stayed readable.
Dead extensions and dead functions
The intranet was full of mysql_query(). The ext/mysql extension was deprecated in PHP 5.5 and removed entirely in PHP 7.0, so on PHP 8.2 you get a hard stop:
PHP Fatal error: Uncaught Error: Call to undefined function mysql_connect()There were 340 call sites. Rewriting each by hand over a weekend is how you introduce SQL injection at 04:00. Instead we wrote a thin compatibility layer over mysqli that preserved the old signatures, dropped it into the bootstrap, and changed nothing else:
<?php
// compat/mysql.php — temporary shim, scheduled for removal post-cutover
$GLOBALS['__db'] = null;
function mysql_connect($h, $u, $p) {
$GLOBALS['__db'] = mysqli_connect($h, $u, $p);
return $GLOBALS['__db'];
}
function mysql_select_db($name) {
return mysqli_select_db($GLOBALS['__db'], $name);
}
function mysql_query($sql) {
return mysqli_query($GLOBALS['__db'], $sql);
}
function mysql_fetch_assoc($r) { return mysqli_fetch_assoc($r); }
function mysql_num_rows($r) { return mysqli_num_rows($r); }
function mysql_real_escape_string($s) {
return mysqli_real_escape_string($GLOBALS['__db'], $s);
}
function mysql_error() { return mysqli_error($GLOBALS['__db']); }A shim is a debt, not a fix, and we logged it as one. But it bought a working app on Saturday afternoon instead of Sunday night, which on a hard deadline is the entire game. The same triage applied to a mcrypt_encrypt() fatal (the extension went in PHP 7.2): one token-signing function rewritten on openssl, the rest left alone. Hardcoded dirname(__FILE__) . '\\config.php' path joins were swept to forward slashes, which Linux accepts and Windows always did too.
The PHP 8 changes that throw no warning
Removed functions announce themselves: the page dies with a clear fatal and you go fix it. The migrations that hurt are the behavioural ones that change what working code does without raising anything you would think to grep for. Two on this app were worth the time they cost.
The first was mysqli error reporting. As of PHP 8.1 the default report mode became MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT, which means a failed query throws a mysqli_sql_exception instead of returning false. A 2011 codebase never expected that; it checked return values by hand and carried on. Pages that had limped along for years on a query that quietly failed now died outright. The fix is not to chase every call site but to make one deliberate choice at the shim boundary:
mysqli_report(MYSQLI_REPORT_OFF); // restore pre-8.1 return-value behaviourThat is a debt entry too, written down next to the shim: the old code's error handling is genuinely wrong, but a migration weekend is not when you rewrite three hundred call sites' worth of it. The second class was string and array semantics. Curly-brace string access ($s{0}) was removed in PHP 8.0, and each() and create_function() went with it. Those are fatals, so they surface fast under a full click-through, which is exactly why the Saturday-afternoon dispatcher walkthrough exists: a human exercising every screen finds the fatals that no static grep will, because they only fire on the one report nobody thought to run in testing.
Moving the data without double-encoding it
The database looked like the easiest step and is the one that quietly ruins migrations. The tables were declared latin1 but the application had been writing UTF-8 into them for years, so the bytes on disk were already correct UTF-8 sitting inside a column that claimed to be something else.
The wrong move is to dump with --default-character-set=utf8mb4 and import into a utf8mb4 schema. MySQL helpfully converts on the way out, and you get the textbook double-encoding artefact: every é renders as é, every ö as ö. The safe path is to dump in the charset the data was physically stored as, import as-is, then convert the schema in place once the bytes are home:
# on the Windows box, dump in the charset the bytes actually are
mysqldump --default-character-set=latin1 --skip-set-charset \
-u root -p intranet > intranet.sql
# on Linux, import untouched, then convert each table
mysql -u root -p intranet < intranet.sqlALTER TABLE drivers CONVERT TO CHARACTER SET utf8mb4;
ALTER TABLE loads CONVERT TO CHARACTER SET utf8mb4;
ALTER TABLE certs CONVERT TO CHARACTER SET utf8mb4;Rewrite rules, cron and Active Directory on the new box
The web.config routing translated almost mechanically. An IIS rule like this:
<rule name="Report">
<match url="^report/([0-9]+)/?$" />
<action type="Rewrite" url="report.php?id={R:1}" />
</rule>becomes a single line of mod_rewrite in a .htaccess at the app root:
RewriteEngine On
RewriteRule ^report/([0-9]+)/?$ report.php?id=$1 [L,QSA]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?route=$1 [L,QSA]The three Task Scheduler jobs became three crontab lines, with output captured to a log instead of vanishing the way scheduled-task output tends to:
*/15 * * * * /usr/bin/php8.2 /var/www/intranet/cron/sync.php \
>> /var/log/intranet-sync.log 2>&1That redirect is not cosmetic. Task Scheduler swallowed stdout and stderr, so for years the nightly sync could fail and nobody saw it; capturing both streams is the first time anyone on this team could answer “did last night's run work” without logging in. The other half of the fix is the absolute path. The cron environment has almost no PATH and a home directory you did not choose, so anything the old job got for free from its Task Scheduler working directory has to be made explicit, which is why sync.php got a single chdir(__DIR__) at the top rather than trust wherever cron dropped it.
Active Directory was the one place we improved rather than ported. The old code did ldap_connect('dc01') in cleartext on the LAN. The new box talks to the same domain controller over TLS, which is two changed lines and the difference between credentials on the wire and not:
$ds = ldap_connect('ldaps://dc01.corp.local', 636);
ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3);Two changed lines is the happy version. The one that bites is certificate trust: ldaps:// means the PHP process now validates the domain controller's certificate, and an internal CA that every domain-joined Windows machine trusts implicitly is not in the Linux box's trust store. Either add the corporate root to /usr/local/share/ca-certificates and run update-ca-certificates, or, if you genuinely cannot get the CA bundle before Monday, set the trust requirement deliberately and write it down as the debt it is rather than discovering a silent ldap_bind failure mid-cutover:
// only if the corporate CA cannot be installed in time — logged as debt
ldap_set_option(NULL, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);The document share moved from a Windows mapped drive to a mounted path, and the single hardcoded D:\shares\intranet-docs constant in config.php became /srv/intranet-docs. One line, because the original developer had at least centralised that.
The cutover, and what we kept
Sunday afternoon we ran the intranet on the new box behind a hosts-file override on two laptops, walked a dispatcher through a full shift of real tasks, and watched the error log stay quiet. The DNS A record flipped at 18:00 with a TTL that had been lowered to 300 seconds on the Friday, which is the cheapest thing on this entire list and the one people forget until they are waiting on a stale record at 22:00. The old box was left powered but firewalled off and read-only, untouched until the team had two clean working days, then it left the rack on schedule.
The rollback plan was one sentence, written before the DNS change rather than improvised after it: the old box stayed powered, firewalled to the two ops laptops only, with its MySQL pinned read-only via SET GLOBAL read_only = ON so nothing could write to the dead copy by accident and leave two diverging databases. If the new box had failed on Monday morning the recovery was flipping one A record back and clearing a 300-second cache, not a restore. A migration without a stated rollback that someone could execute while panicking is not finished, it is just untested.
When we built Pier we kept running into this exact weekend: the part that makes an undocumented legacy site migration frightening is not the work, it is not knowing whether the change you just made to a live database is reversible. The way we ended up handling it was making every edit pass through a version history and giving the MySQL editor the same one-click undo as the files, so a 04:00 decision is never a one-way door.
The smallest thing you can do today, before anyone hands you a deadline, is run two greps over your own intranet: one for mysql_ calls and short <? open tags, and one reconciling every include and require string against the real filenames on disk. Those two outputs are most of the weekend, and you can read them on a calm Tuesday instead of finding them on a Saturday.
— Questions —
Can a custom PHP intranet really be migrated off Windows Server 2008 in one weekend?
If the app is a single codebase with one database, yes. The work is mechanical: case-sensitive includes, removed mysql_ functions, charset handling, and rewrite-rule translation. The risk is the data step, not the time.
Why does moving the MySQL database double-encode accented characters?
Tables declared latin1 but holding UTF-8 bytes get converted on dump. Verify with HEX() on the source first, dump in the charset the bytes actually are, then ALTER TABLE to utf8mb4 after import.
Is a mysql_ to mysqli compatibility shim safe to ship?
As a temporary measure under deadline, yes, provided it preserves escaping behaviour. Treat it as logged technical debt and remove it after cutover. It is a stopgap, not a fix.