← Blog

Joomla

Joomla 2.5 to SvelteKit: a six-week shadow cutover

An 18-year-old Joomla 2.5 portal still ran 3,800 dealer-tier prices and live Buckaroo iDEAL orders. Here is the six-week shadow cutover that replaced it without a downtime window.

Jacob Molkenboer· Founder · A Brand New Company· 14 Jun 2026· 9 min
Open leather ledger with yellowed price columns, brass tag on twine, green silk ribbon bookmark, cracked red wax seal on ivory paper.

Six twenty in the morning, an Apeldoorn warehouse in late October. The supervisor opens a Firefox tab to a Joomla portal that looks exactly the same as it did in 2008. Behind it, a cron job has just pulled the night's standing orders for composite filling kits, aligners and sterilisation pouches from 312 dealer practices across Benelux. The page renders in 1.4 seconds. The order CSV is 96 lines. Everything works.

The site has been running since November 2007. It is built on Joomla 2.5.28, last released in March 2014, on PHP 5.6.40, last released in January 2019. The pricing engine is a single 4,100-line PHP file. The B2B login layer was hand-rolled by a former intern. The Buckaroo iDEAL integration speaks BPE 3.0 over SOAP. And in the last year it took 9.1 million euro of standing orders without a single failed transaction.

Our brief was to replace the whole stack, keep every euro of revenue in flight, and not break the standing-order cron the warehouse depends on.

What we inherited

The audit took four days. We did it before quoting, because Joomla 2.5 audits are where projects go to die. What we found:

  • Joomla 2.5.28 with eight third-party extensions, three of them no longer published.
  • A heavily forked VirtueMart 2 with custom price-rule joins.
  • PHP 5.6 running on a Debian 8 box behind nginx, kept upright by a series of register_globals-era hacks.
  • MySQL 5.5 with 412 tables. About a hundred of them empty.
  • 3,800 active rows in dental_price_matrix covering dealer tier, SKU, volume break and contract end date.
  • 12,400 SKUs, 87 of which sold more than once a week. The long tail still mattered.
  • A Buckaroo BPE 3.0 SOAP integration for one-off iDEAL and a homegrown standing-order engine that mailed dealers a PDF mandate once a year.

Both Joomla 2.5 and PHP 5.6 reached end of life years ago. PHP 5.6 has been unsupported since January 2019, so the box was running with no upstream security patches and a long list of known CVEs in the third-party extensions. Insurance was about to refuse cover at renewal. That, more than performance, was the trigger.

Warning

If you are still on Joomla 2.5 or 3.x with PHP 5.x, your cyber-insurance renewal is the real deadline, not the EOL date. Underwriters now ask for the PHP minor version on the application form and flag anything below 8.1.

Mapping the price matrix out of MySQL

The first real engineering step was getting the price matrix out of the old database without losing semantics. Three things made it hard. First, the matrix used soft-deleted rows with a valid_until column that was sometimes NULL, sometimes 0000-00-00, and sometimes a real date. Second, dealer tier was encoded as a single character (A through F), but two SKU categories silently overrode it. Third, the matrix was joined at runtime to a contract_addendum table that nobody had documented.

We pulled the active matrix with one query, ran it past the sales lead, and got written sign-off on the row count before we touched anything:

SELECT  d.tier_code,
        p.sku,
        m.volume_break_units,
        m.unit_price_eur,
        COALESCE(NULLIF(m.valid_until, '0000-00-00'), '2099-12-31') AS valid_until,
        a.addendum_factor
FROM    dental_price_matrix m
JOIN    dealers d  ON d.id = m.dealer_id
JOIN    products p ON p.id = m.product_id
LEFT JOIN contract_addendum a
       ON a.dealer_id = m.dealer_id
      AND a.sku_category = p.category
      AND CURDATE() BETWEEN a.starts_on AND a.ends_on
WHERE   m.is_deleted = 0
ORDER BY d.tier_code, p.sku, m.volume_break_units;

3,812 rows. We froze the export, version-controlled it, and treated it as the source of truth for the rest of the project. Every diff after that was measured against this CSV.

The shape of the new stack

We picked SvelteKit for the storefront and dealer portal, Payload 3 for the catalogue and CMS surface, and Postgres 16 underneath both. The reasoning was boring and we like it that way.

SvelteKit's form actions map cleanly to a B2B portal that is 80% forms. Server-side rendering keeps the dealer login flow inside the firewall path we need it on, and the storefront ships less JavaScript than the old Joomla front end did with extensions disabled. Payload runs on the same Postgres database as the storefront, so the price engine and the catalogue share a transaction boundary. We did not want a separate commerce service and a separate CMS sync job. Postgres gave us proper check constraints, partial indexes on valid_until, and the ability to run the pricing rules as a MATERIALIZED VIEW refreshed once an hour.

We deliberately did not migrate to a hosted commerce platform. Shopify Plus, BigCommerce and the rest model price lists in ways that do not survive 3,800 overlapping rules with contract-date validity. Forcing that fit would have meant explaining to the sales lead why a dealer paid four cents more on a box of articulating paper. That conversation does not end well.

The Payload schema was four collections (Products, Dealers, PriceRules, Contracts) and two globals (Catalogue settings, Buckaroo merchant config). The price engine itself was a single Postgres function that took (dealer_id, sku, quantity, ordered_at) and returned (unit_price, source_rule_id). Every quote in the new portal called that function and stored the source rule ID on the order line. Six months in, that one column has already settled three pricing disputes.

Keeping Buckaroo iDEAL alive on both stacks

Payments were the part we lost sleep over. The old system used Buckaroo's legacy BPE 3.0 SOAP. The new system would speak the Buckaroo JSON API with HMAC-signed requests. Both had to work at the same time for six weeks, because dealers had open mandates against the old merchant key.

The plan:

  1. Provision a second Buckaroo merchant account on the same legal entity. Buckaroo will do this in 48 hours if you ask politely and the entity matches.
  2. Point new-portal traffic at merchant key B. Leave existing TokenCheckout mandates pointing at merchant key A.
  3. Write a webhook router on the SvelteKit side that accepts both push formats and writes them into a single payments table with a source column.
  4. Replay every payment notification from a 30-day window into a staging database to confirm the router produced identical ledger entries.

The replay step caught two bugs we would not have found any other way. One was a timezone bug in the old SOAP push (Buckaroo sends Amsterdam time, the old code assumed UTC). The other was a rounding mismatch on partial refunds, hidden for years because nobody had ever issued one.

Six weeks of shadow traffic

This was the part of the project that bought us the calm cutover. From week three of the build, we mirrored every real read request from the old portal to the new one and threw away the responses. Then we promoted a closed pilot of nine dealers to write traffic, still backed by the old database as the source of truth, with the new database trailing as a slave.

The shadow rig ran a diff on every /api/price-quote response. Same dealer, same SKU, same cart, both stacks. Anything that did not match within half a cent went to a queue. The diff ignored ordering of line items, normalised currency precision to four decimals, and tagged each mismatch with the source rule ID from the new engine, so we could walk straight to the offending row in the matrix CSV.

Over six weeks the queue received 11 mismatches across 4,200 dealer logins. Eight were legitimate bugs in the new pricing engine. Two were rounding differences we decided the new system was correct about, with a note to sales. One was a single dealer with a one-off addendum that nobody on the client side remembered signing in 2017. Each mismatch became a regression test before the fix landed, so we ended the project with a price-engine test suite that documents the business rules better than any wiki ever did.

The cutover weekend

We cut over on a Saturday morning in March, not a Sunday night. The reasoning: if it broke, the warehouse supervisor and the Buckaroo support desk would both be at their desks. The hour by hour:

  • 06:00. Freeze old portal writes. Read-only banner up. Sales lead in the room.
  • 06:15. Final delta export from MySQL into Postgres. Nine minutes for the matrix, 24 minutes for the order history.
  • 06:50. Buckaroo standing-order mandates re-issued against merchant key B for the 38 dealers due in the next 30 days. Older mandates left on key A and allowed to run out naturally.
  • 07:20. DNS TTL on the portal domain, dropped to 60 seconds two days earlier, swung over to the new A record.
  • 07:35. First real dealer login on the new stack. Standing-order cron paused.
  • 09:10. Standing-order cron resumed against the new database. First batch processed clean.
  • 14:00. Read-only banner removed from old portal. Old portal kept warm but read-only.

No emergency rollback. Two dealers called the office about a layout change. One was annoyed about the new login button colour and was correct.

What we kept on purpose

Two things we did not modernise. The dealer order ID sequence kept going from 184,209 upwards, because the dealers' own accounting systems referenced it. We just added 10,000 to leave room for any straggler from the old box. And we kept one legacy URL pattern, /index.php?option=com_virtuemart&view=productdetails&product_id=…, with a permanent 301 to the new SKU URL. Google had indexed several thousand of those, and the dealer practices still bookmarked them.

The old PHP file is still on a cold server somewhere, untouched. We refuse to delete it for another twelve months. Insurance can take that one up with us at the next renewal.

A five-minute audit you can run today

If you suspect you are sitting on a similar portal, run this from a shell on the box and read the output to your insurer:

php -v
cat administrator/manifests/files/joomla.xml | grep version
mysql --version
grep -rE 'register_globals|mysql_query\(' --include='*.php' . | wc -l
openssl x509 -in /etc/ssl/site.crt -noout -enddate

Five lines. PHP version, Joomla version, MySQL version, count of pre-PDO code paths, and the cert expiry. If any of them returns something from before 2020, the conversation with the underwriter is already overdue.

When we rebuilt this dental distributor's portal, the part we underestimated was the price-matrix replay. We caught it with the shadow-traffic rig, which we now reuse on every legacy migration we take on. Six hundred lines of TypeScript, one weekend of setup, and it pays for itself the first time it catches a mispriced quote.

Key takeaway

Shadow traffic is the cheapest insurance on a commerce migration: build cost up about 12%, risk of a wrong-price invoice on cutover weekend close to zero.

FAQ

How long did the full Joomla 2.5 to SvelteKit migration take?

Fourteen weeks from kick-off to cutover, including a four-day audit, an eight-week build, and six weeks of overlapping shadow traffic before the Saturday morning switch.

Why not migrate the B2B portal onto Shopify Plus or BigCommerce?

The pricing model had 3,800 overlapping rules with contract-date validity and dealer-tier overrides. Hosted commerce price lists could not represent that without rounding errors the sales team would have noticed.

How did you keep Buckaroo iDEAL live during the cutover?

Two Buckaroo merchant accounts on the same legal entity ran in parallel. New orders used the JSON API on key B. Existing TokenCheckout mandates kept firing against key A until they expired naturally.

What happens to the old Joomla 2.5 server after the cutover?

It is kept cold, read-only, and disconnected from the public internet for twelve months. After that we archive the database to S3 and decommission the box. We never delete on cutover day.

Is six weeks of shadow traffic always necessary?

Not always. We use it when the pricing or billing logic is non-trivial and the cost of a wrong invoice is high. For a brochure-site migration, two weeks of read-only mirroring is usually enough.

joomlamigrationlegacy sitese-commercephpcase study

Building something?

Start a project