PHP
Legacy PHP rescue: anatomy of a €1.8M booking platform
A custom PHP 5.6 booking platform, €1.8M in yearly bookings, three undocumented payment providers, and a session table that grew 40GB a month. The handover took forty minutes.

The handover meeting lasted forty minutes. The previous developer had built the platform in 2014, kept it running for nine years on his own, and now lived three time zones away. His answer to most of our questions was a long pause, followed by some version of "I think that runs every night, but I cannot remember why."
The platform took €1.8M a year in bookings for a Dutch tour operator. Custom PHP, custom MVC, custom session handling, custom everything. No tests. No CI. One staging server that had not been updated since 2019. The contract with the previous developer ended on a Friday. High season started in four weeks.
This is the story of what we found, what we touched first, and what we left alone. If you are sitting on top of a similar codebase, the order matters more than the tooling.
The first thing we opened
We did not open the application code. We opened the database.
The sessions table was 40GB and growing by roughly 1.3GB a day. PHP's default session garbage collector had been disabled in php.ini years ago, presumably to stop it locking the table during a peak traffic spike. Nobody had written a replacement.
We ran a count.
SELECT COUNT(*) AS expired_rows
FROM sessions
WHERE last_activity < UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY);
The answer was 47 million rows. The booking platform processed about 3,000 active sessions a day. The other 46,999,000 rows were ghosts of carts abandoned during the 2020 lockdowns and never collected.
The fix was a five-line cron. The harder question was why nobody had noticed. The answer, like most legacy answers, was that someone had noticed once, written a Jira ticket in 2020, and then left the company before they got to it.
Before you delete anything from a session table on a live booking platform, take a mysqldump and test the restore. Old PHP code sometimes serialises half the auth state into the session blob. A careless DELETE can log out every active customer mid-checkout. We staged the cleanup as DELETE ... LIMIT 50000 chunks at 03:00 and watched the binlog before each chunk.
Three payment providers, three regrets
The booking platform talked to three payment providers. None of the integrations were documented. None of them used the same flow.
- One was iDEAL via a Dutch PSP that had rebranded twice since the integration was written. The brand name in the code was the 2015 name.
- One was a direct credit card integration against a provider's now-deprecated REST v1 API. The provider had been emailing the founder's old address for two years asking him to migrate.
- One was PayPal Classic, which PayPal has been politely asking everyone to leave since 2017.
We found the live credentials in three different places: an .env file, a config.inc.php, and a hardcoded string inside lib/payment/cc.class.php. The hardcoded key was a production key. It was in git history. The repo was on a shared GitLab where four ex-employees still had access.
We rotated everything that week.
Then we counted the traffic. We pulled twelve months of access logs, grepped for the three payment callback URLs, and produced a per-provider transaction count.
zcat access.log.*.gz \
| awk '$7 ~ /\/payments\/(provider_a|provider_b|paypal)\/callback/ {print $7}' \
| sort | uniq -c | sort -rn
One provider was doing 94% of the volume. One was doing 5%. One had been silently failing since a TLS change in 2022 and had processed zero successful payments in fifteen months. Nobody had reported it because the platform's fallback logic quietly routed errors to a different provider, and the finance team reconciled by total, not by source.
The boring lesson: before you migrate anything, count what actually runs. The vendor choice matters less than the diligence.
For most teams the question is not Adyen versus Stripe versus Mollie. The question is whether you can name the providers in production from memory, and whether the answer matches the logs. For our client we eventually consolidated onto one PSP that supported iDEAL, Bancontact, cards, and PayPal under one contract, so they would never again discover a fourth integration nobody mentioned. The PSP we picked was the one whose webhook signature verification was a single function call in their official SDK, not the one with the prettiest pricing page. The full payment migration took three engineering days, not three weeks, because we had already deleted the dead provider and learned the live one before writing any code.
The PHP 5.6 problem
PHP 5.6 reached end of life on 31 December 2018. There has been no security patching for seven and a half years. You can confirm this on the official PHP supported versions page; 5.6 is not on it and has not been for a long time.
The booking platform was still running on it in 2026. Apache 2.2 with mod_php, prefork MPM, sized for an era where servers had less RAM than your laptop. Every Composer dependency in the project was older than the EOL date. Two of them had unpatched CVEs older than the youngest engineer on our team.
We ran the codebase through Rector with the PHP 7.0, 7.4, and 8.0 rule sets, one set at a time, in a branch we did not merge for the first three weeks. Rector handled about 60% of the syntax migration on its own: short array syntax, scalar type hints, null coalescing, removing mysql_* calls in favour of PDO.
The 40% Rector did not handle was where the war story really lived.
What Rector cannot fix
Three categories of code change behaviour silently between PHP 5.6 and 8.2. None of them throw at parse time. All of them throw at 03:00 on a Saturday.
- SQL built by concatenating
$_REQUESTvalues into strings. PHP 5.6 was the last version where you could ship this and mostly survive. PHP 8 still runs it. You should still not let it. - Code that depends on PHP 5's silent type juggling. There was a function that compared booking IDs as strings against integers using
==. On PHP 8 the comparison rules were tightened so that0 == "foo"is finallyfalse. Good for sanity, bad for an authorisation check that happened to rely on the old behaviour. - A homemade autoloader that called
eval()on a generated PHP file containing every class path. We replaced it with PSR-4 and a generatedcomposer.jsonentry.
The Rector branch became our shadow codebase. We ran the existing test suite against it (one PHPUnit file, fourteen tests, four of them skipped with no comment) and added eighty more tests as we found things that broke. By week three the shadow branch had more tests than the previous nine years of development combined.
The migration plan we actually wrote
We did not migrate to Laravel. We do not believe in rewriting €1.8M of working booking flow during high season.
The plan, in order:
- Move the infrastructure first. From a single ageing VPS to a small Hetzner cluster with managed MySQL 8 and Redis. Run the existing PHP 5.6 code inside a containerised image during the move, just to decouple the OS from the application.
- Move sessions out of MySQL and into Redis. This alone took the database from constantly on fire to merely warm.
- Upgrade PHP to 8.2 with the Rector-cleaned codebase, behind a feature flag on a parallel hostname. Run both versions in parallel for two weeks. Compare error rates and booking conversion daily.
- Consolidate the three payment providers behind one PSP, with the old endpoints kept as read-only routes so the support team could still pull refund history.
- Add observability. The platform had none. We bolted on Sentry, a Loki log pipeline, and a single Grafana dashboard with four panels: bookings per hour, payment success rate, p95 response time, error rate.
- Then, and only then, talk about CMS, framework, and rewrite. Most clients never get to step six, because by step five the pain that triggered the call is gone.
Steps 1 to 5 are where almost all the rescue value lives. The rewrite usually does not pay back, and during high season it is actively dangerous. We have rescued seven platforms on this pattern over the last four years; on six of them the client decided after step five that they no longer wanted the rewrite they had originally hired us to scope.
What we shipped in week one
Week one was sessions, secrets, and observability. Nothing flashy. No customer-facing change.
We rotated every secret we could find: the live payment key from the hardcoded string, the database password (which had been the company name plus 2014), the SSH keys (root login was still enabled on port 22), the SMTP credentials, the Google Maps API key that was billing €180 a month against a four-year-old card.
We installed acme.sh and got real Let's Encrypt certificates on the four subdomains that had been running on self-signed certs pinned in 2019. The booking confirmation emails had been going through a relay that was silently dropping them since March; we found that by reading the Postfix logs nobody had looked at. Customers had been calling support all year asking where their confirmation was, and support had been replying by hand.
We also turned on basic monitoring: a Uptime Kuma instance pinging the booking endpoint every minute, and a single alert routed to the operations lead's phone when payment success rate dropped below 90% over a fifteen-minute window. The alert fired on day three of week two. It turned out to be a rate-limit on the new PSP sandbox, not production, but the operations lead said it was the first time in nine years she had heard about a payment problem before a customer did.
By the end of week one the platform was less likely to leak data and more likely to deliver an email. We had not added a single feature. The client was happier than they had been in two years.
The smallest thing worth doing today
If you are sitting on top of a custom PHP application older than three years, the smallest useful thing you can do this afternoon takes two SQL queries and a grep.
Open the database and ask which tables are eating the disk:
SELECT table_name,
ROUND((data_length + index_length) / 1024 / 1024 / 1024, 2) AS gb
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY (data_length + index_length) DESC
LIMIT 10;
Then go to the access logs and count the traffic per payment callback URL over the last 90 days. Compare the result against what your finance team reconciles each month. If the numbers do not match, you have a story to chase before anyone touches the codebase. If you are not sure which logs hold the answer, that is also a finding: write it down, because it is the first thing your migration partner will ask.
When we did the legacy migration for the tour operator, the thing we ran into was not the PHP version or the framework choice. It was that nobody on the current team could name which payment provider was actually live, and the silent fallback logic had hidden that for two years. Our legacy rescues now start with that audit, in writing, before anyone opens an editor.
Key takeaway
Before you migrate a legacy PHP system, count what actually runs. Most rescues fail because the team trusts the docs over the access logs.
FAQ
How long does a PHP 5.6 to PHP 8 migration usually take?
For a custom codebase of around 80k lines with no tests, plan eight to twelve weeks of focused work. Rector handles roughly 60% of the syntax. The remaining 40% is silent behaviour changes that need tests written first.
Should we rewrite the application or upgrade in place?
Upgrade in place. A rewrite during a working revenue stream almost always loses money for the first 18 months. Stabilise the infrastructure, move the PHP version, add observability, then decide whether a rewrite is still worth it.
Can Rector do the whole PHP version upgrade automatically?
No. Rector handles syntax and obvious API changes. It cannot catch behaviour changes like the stricter string-to-number comparison rules in PHP 8, or homemade autoloaders that use eval. You still need tests.
Why is a 40GB session table a problem if MySQL can handle it?
MySQL handles it fine. Backups, replication, ALTER TABLE, and disaster recovery do not. A 40GB table makes every infrastructure move slower and more dangerous, and most of the rows are abandoned carts nobody will ever see again.