01 · Topworktops
From a custom PHP framework to Symfony 8, without a rewrite
The system
An internal back-office application for a UK kitchen worktop supplier. Staff use it every day to run the whole order lifecycle: quotes and invoices, clients, products and warehouse stock, delivery scheduling with vans and live maps, customer SMS, PDF delivery and collection notes, and sales statistics. Customers receive signed links to their invoices from it. If it stops, deliveries stop.
The history
The first version was written more than twelve years ago on a custom in-house PHP framework. In 2015 I moved it to Symfony 3. That version then ran in production for ten years and over a thousand commits. That is the part people forget about modernization: the goal is a system that needs nothing dramatic for the next decade.
By 2026 Symfony 3.4 was long out of support, PHP had moved to 8.4, the front end was still Bower and Bootstrap 3, and PDFs depended on a wkhtmltopdf binary that no longer existed on any server.
The approach
Read before I change. I mapped the application first: 111 routes, around 25 entities, raw SQL that keeps denormalized invoice totals in sync, hardcoded VAT-inclusive math, invoice numbering that must not be "improved", and customer links signed with a checksum. Those went on a do-not-touch list before the first dependency was bumped.
Then the framework layer was replaced in small, validated phases, with business behaviour kept byte for byte:
- Symfony 3.4 to Symfony 8.1, PHP 8.4, Doctrine ORM 3 on PostgreSQL. Annotations became attributes, controllers got constructor injection, parameters.yml became .env. Table and column names never changed.
- FOSUserBundle to native Symfony Security. The existing user table and bcrypt hashes were kept, so nobody has to reset a password. Two idempotent migrations convert the legacy serialized roles to JSON and drop the dead columns.
- Bower and hand-vendored assets to AssetMapper and importmap. No Node build. Bootstrap 3 to 5. jQuery stays, because the legacy plugins that depend on it still earn their place.
- Unmaintained packages replaced: wkhtmltopdf to dompdf, Swiftmailer to Mailer, Guzzle to HttpClient, dev-master bundles to current releases.
What was added
An application nobody tests is an application nobody dares to upgrade, so this time tests came with the migration. A smoke suite renders login and every main page against a test database. A dedicated test checks every importmap bundle and CSS reference, so a missing asset fails the test run instead of a customer's browser. New integrations are built with tests from day one, against mocked HTTP: live van positions from the vehicle tracker drawn on the delivery map, and nightly database backups compressed and shipped to Cloudflare R2 with automatic retention and a size check on every upload.
AI-assisted tooling was part of the work: exploring the code, generating tests, documenting behaviour. The next step, now in progress, is bringing AI tools into the application itself.
Results
| Area | Before | After |
|---|---|---|
| Framework | Symfony 3.4 (EOL) | Symfony 8.1 |
| PHP | 7.3 | 8.4 |
| Auth | FOSUserBundle | Native Security, same users, same passwords |
| Assets | Bower, hand-vendored | AssetMapper + importmap, no Node |
| PDFs | wkhtmltopdf binary | dompdf |
| Automated tests | 0 | 86 |
| Backups | manual | nightly, off-site, verified, pruned |
Business rules unchanged. Same invoices, same totals, same VAT, same customer links. A documented codebase with an architecture guide, the migration plan and a written caution list that the next developer, human or AI, can pick up without guessing.