128 —Migration
Symfony 6 intranet migration: eleven weekends, zero downtime
Eleven weekends, one strangler proxy, and a timesheet form that never went dark. How an in-house team moved a 2011 custom-PHP intranet onto Symfony 6 without anyone noticing.
The intranet was built in 2011. A single index.php at the root, a config.inc.php with hardcoded DB credentials, and a folder called /lib/ that everyone had been told never to touch. The brief, eleven weekends before we started, was clean: move it onto Symfony 6, do it in your own time, and the timesheet form has to keep working because the field engineers fill it in on Sunday evenings.
The Dutch operations team that ran it (22 people, mostly schedulers and field engineers) used the system for three things: the timesheet form, an internal directory, and a project tracker that had grown to about 9,400 rows. None of that was going to be rewritten over a long weekend.
The lead engineer messaged us in February. PHP 8.2 EOL was on the horizon, the original developer had retired in 2018, and a recent security audit had flagged 41 instances of unescaped $_GET values being interpolated straight into SQL. Eleven weekends later, the Symfony 6 cutover was done. This is what we did and what we wouldn't do again.
The shape of the 2011 codebase
Before any planning, we spent the first Saturday morning just reading. The intranet was 38,000 lines of PHP across 217 files. There was no router; index.php dispatched on $_GET['page'] through a 600-line switch statement. Authentication was a session variable called $_SESSION['logged_in'] set to the string 'yes'. The MySQL driver was mysql_query(), removed from PHP since the 7.0 release.
The database was healthier than the code. 31 tables, mostly third-normal-form, and the timesheet table (tijdregistratie) had a composite index on (medewerker_id, week_nummer, jaar) that someone had clearly thought about. That was the wedge: rebuild the application layer, leave the schema alone for now.
Strangler, not rewrite
A from-scratch rewrite was the obvious wrong move. The company had tried it twice between 2017 and 2021 and abandoned both attempts. The second one died when nobody could agree on whether medewerker_id should map to a Doctrine entity called Employee or Staff. We weren't going to repeat that.
Instead we used the strangler fig pattern: a thin reverse proxy in front of the old app, with new Symfony 6 routes mounted alongside. Every request hits the proxy first. If a route exists in Symfony, it serves. Otherwise it falls through to the legacy site. Over eleven weekends, we moved routes one at a time from legacy to Symfony, and the proxy quietly shifted with them.
The Apache config that did this was almost embarrassing in how small it was:
RewriteEngine On
# New Symfony routes win
RewriteCond %{REQUEST_URI} ^/(api|directory|admin|reports)
RewriteRule ^(.*)$ /symfony/public/index.php [L]
# Everything else falls through to legacy
RewriteRule ^(.*)$ /legacy/index.php [L]
That was it. No Nginx, no Traefik, no service mesh. Apache 2.4 with mod_rewrite, the same setup the intranet had run on since 2014.
Weekend one: the proxy and the canary
We didn't migrate any real functionality on the first weekend. The whole Saturday and Sunday went to standing up the proxy and proving that fall-through worked under load. The canary route was deliberately silly: a Symfony controller at /api/healthcheck that returned a JSON object containing the legacy session ID. That single endpoint forced us to solve three things at once.
- Symfony reading the legacy PHP session, which meant matching
session.save_pathandsession.nameinphp.ini. - The proxy's routing decision being fast enough that nobody noticed.
- Doctrine connecting to the same MySQL instance with a read-only user, so we couldn't accidentally write through the new code yet.
By Sunday evening we had a Symfony 6.4 app sitting next to the legacy intranet, sharing sessions, sharing the database, and serving exactly one endpoint. The field engineers filed their Sunday timesheets and saw nothing different. That was the whole point.
The eleven-weekend plan
The migration order was decided by two things: how independent each module was, and how badly it would hurt if we broke it.
- Weekend 1. Strangler proxy and healthcheck canary.
- Weekend 2. Internal directory (read-only, no auth changes). Easiest route in the codebase. We built it twice to prove the pattern.
- Weekend 3. Authentication. Replaced the
$_SESSION['logged_in'] = 'yes'check with a Symfony firewall that read the same cookie. Both apps now trusted the same login. - Weekend 4. The project tracker list view. First route to write to the database from Symfony. Tightened the read-only DB user constraints during the week.
- Weekends 5 and 6. Project tracker detail and edit. The first pages where Symfony forms replaced legacy ones.
- Weekend 7. Reports. Pure read, but heavy SQL. Moved everything onto Doctrine DBAL with prepared statements, killed the audit findings on that surface.
- Weekends 8 and 9. Admin pages. Touchy because they wrote across multiple tables. We added a write-through audit trail here that the legacy code never had.
- Weekend 10. Timesheet form. The one route nobody could see go down. Detailed below.
- Weekend 11. Decommission the legacy
/legacy/index.phpfall-through, archive the source, run the final security pass.
Cutting over the timesheet form
The timesheet form was the route the whole project pivoted around. Field engineers filed roughly 180 timesheets between 18:00 and 22:00 on Sundays. We had a four-hour window of zero traffic between 03:00 and 07:00 every weekday, but we couldn't use any of them as a hard cutover because the form occasionally got filled in late, and the engineers got grumpy when it lost their input.
So we shadow-routed. For two weeks before weekend 10, every POST to /timesheet/save went to the legacy handler as normal, but a copy of the request body was also forwarded to a new Symfony endpoint at /api/timesheet/save-shadow. The new endpoint validated, ran the same business logic, and wrote to a parallel table called tijdregistratie_shadow. Nobody saw the shadow path. The legacy path stayed authoritative.
Every Monday morning we diffed the two tables.
SELECT t.id, t.uren, s.uren, t.medewerker_id
FROM tijdregistratie t
JOIN tijdregistratie_shadow s
ON s.legacy_id = t.id
WHERE t.uren != s.uren
OR t.project_id != s.project_id
LIMIT 50;
The first Monday we found 14 mismatches. All of them traced to a single bug in the legacy code where weekend hours were silently rounded down to the quarter hour. The new code wasn't doing that. We decided not to "fix" the new code to match the bug; instead we flagged the diff, told the team, and they were quietly delighted that the new form was going to give them back about 11 minutes a week each.
By weekend 10 the shadow path had matched the legacy path for nine consecutive days. We flipped the proxy rule, and the legacy POST handler started returning a redirect to the Symfony route. No engineer noticed.
What the cutover actually cost
The eleven weekends were not heroic. Most were two engineers, four to six hours each on Saturday, and a shorter Sunday morning session to verify Monday wouldn't burn. The longest single weekend was weekend 6, which ran to about 22 working hours combined because Doctrine kept timing out on the project tracker's heaviest query (a left join across five tables that resolved to a NOT IN subquery against 9,400 rows). The fix in the end was a covering index on three columns, not anything clever about the ORM.
Total billable engineering: roughly 88 hours across two people. Compared with either rewrite attempt the company had abandoned (each quoted around 600 hours, neither shipped), the strangler approach paid for itself before weekend 4.
What we'd do differently
Three things stand out.
First, we should have stood the shadow path up earlier. We did it for the timesheet form because the stakes were obvious, but the project tracker would have benefited from the same approach in weekend 5. We took one short outage on a Sunday afternoon because the new edit form was 200ms slower than the old one and somebody's browser timed out mid-save. A shadow week would have caught it.
Second, we should have wired up a version history earlier. We didn't have one until weekend 7. Before that, every code change on a Saturday afternoon was an exercise in remembering what we'd touched. A per-file snapshot in Git would have been enough; we eventually wired up an SFTP-aware tool that made the snapshots automatic.
Third, we should have written the diff query first, not last. The tijdregistratie versus tijdregistratie_shadow diff caught the rounding bug, but it would also have caught two earlier issues in the directory and the reports if we'd had the discipline to write it for every migrated table from weekend 2 onwards.
The work, and the tool that lived alongside it
Migrations like this one are mostly mechanical: read a route, port it, point the proxy. The hard parts are the bits where you have to edit a live codebase while it's still serving real users, and where you need a one-keystroke way back when a Sunday change breaks Monday morning. When we built Pier we ran into this exact thing on three client migrations in a row, so we put the FTP server, a per-file version history, a one-click undo, and a MySQL editor behind a single workspace, which meant the shadow-diff queries lived next to the code that produced them.
If you're staring at a 2011 codebase right now and the EOL clock is ticking, the smallest useful thing you can do today is grep for mysql_query across the project and count the hits. That number is your first weekend's scope.
— Questions —
Why strangler instead of a full rewrite?
Two earlier rewrites at this company had failed. The strangler pattern let us ship value every weekend instead of building toward one high-risk cutover nobody trusted.
How do you keep a form online while migrating it?
Shadow routing. The old endpoint stays authoritative, but every POST is also forwarded to the new endpoint and written to a parallel table. Diff the two tables until they agree.
What if the old and new database writes disagree?
That's the point of running them side by side. Mismatches surface real bugs, sometimes in the old code, sometimes in the new. Decide case by case which behaviour is correct before flipping.
How long should each migration weekend be?
Small enough that you can roll back by 22:00 on Sunday. We aimed for one logical module per weekend and tracked Monday's first hour of traffic as the success metric.