← Blog

Joomla

Joomla 2.5 migration: a six-week shadow cutover playbook

A 33-person Antwerp bakery's dealer-portal ran on Joomla 2.5, PHP 5.4, and 9,800 BTW-vrijgestelde prijsafspraken. Here is how we moved it without losing one.

Jacob Molkenboer· Founder · A Brand New Company· 15 Jun 2026· 9 min
Leather logbook with brass key on cream card, green ribbon, red wax seal on linen desk by window.

The dealer-portal at this Antwerp wholesale bakery was older than three of its sales reps. Joomla 2.5, PHP 5.4, a single MySQL 5.5 instance on a Hetzner box that no one had rebooted since the 2022 power outage. Thirty-three staff used it every morning. Two hundred and forty bakeries, restaurants and hotels logged in from their own admin to download invoices and confirm next-week orders. The data inside mattered: 9,800 prijsafspraken (BTW-vrijgestelde price agreements, each one negotiated with a specific dealer over the last eleven years), and a nightly journal export that fed straight into Exact Online for the bookkeeper.

The site worked. It also could not be patched. Joomla 2.5 reached end-of-life in December 2014. PHP 5.4 followed in 2015. The custom dealer-portal extension was written by an agency that closed in 2019. When we picked up the file, the brief was simple: get off this stack without losing a single price agreement and without breaking the Exact Online export. Six weeks. No service window longer than fifteen minutes.

Shadow traffic over big-bang cutover

Big-bang migrations work fine for marketing sites. They do not work for portals that 240 customers log into to download VAT-correct invoices. The risk is not "the new site is ugly." It is "the dealer in Hasselt gets billed 21% VAT on bread he has been buying tax-free for nine years, and his bookkeeper finds out before we do."

The pattern we picked is well-trodden in payment systems: write to both, read from one, swap reads when parity holds. The new stack runs alongside the old one for as long as it takes to prove they agree on every invoice, every price agreement, every journal line. We call it shadow traffic. Whatever you call it, the rule is the same: the new system is never allowed to take down the old one.

The target stack

We picked SvelteKit for the dealer-facing UI, Hono on Bun for the JSON API, and Postgres 16 for storage. The choice was driven by three things, in order.

First, the portal is mostly forms, tables and PDF downloads. SvelteKit's progressive enhancement and built-in form actions are the smallest thing that handles login, server-rendered tables and offline-tolerant order entry without bolting on a separate API layer for the UI alone.

Second, Hono is small enough to read in a single afternoon. The client's accountant needed to walk through the journal-export code with us before sign-off. He could not have done that with the original Joomla extension; he could do it with 600 lines of TypeScript.

Third, Postgres handles the price-agreement constraints natively. Partial unique indexes, exclusion constraints on date ranges, transactional DDL when we have to add columns mid-migration. The price-agreement table is the heart of the whole portal, and Postgres lets you describe "two prices for the same dealer and product cannot overlap in time" as a constraint instead of as a piece of application code that everyone forgets to update.

Week 1: snapshot, schema map, and the dictionary

The first week was reading, not writing. We took a full mysqldump of the production database (4.2 GB), restored it to a staging Postgres via pgloader, and started mapping. The Joomla schema had 184 tables. Forty-one were actually used. The rest were Joomla core leftovers, two failed forum extensions, and an unfinished newsletter system from 2018.

The dictionary is the boring artefact that saves the project. One sheet per legacy table: column name, type, what it actually contains (often not what the column name says), and where it lands in the new schema. The dealer-portal had a jos_dealers_prijs table where prijs_eur was sometimes the unit price, sometimes the line total, depending on whether line_count was null. That sort of thing.

Warning

If your dictionary takes less than a week on an eleven-year-old PHP codebase, you have not finished reading it. The undocumented columns are where the next four weeks of bugs live.

The new price-agreement table is short, and the exclusion constraint at the bottom is the punchline:

CREATE TABLE price_agreements (
  id              BIGSERIAL PRIMARY KEY,
  dealer_id       BIGINT NOT NULL REFERENCES dealers(id),
  product_code    TEXT   NOT NULL,
  unit_price_eur  NUMERIC(10,4) NOT NULL,
  vat_exempt      BOOLEAN NOT NULL DEFAULT FALSE,
  valid_from      DATE   NOT NULL,
  valid_to        DATE,
  legacy_id       BIGINT UNIQUE,  -- jos_dealers_prijs.id, the bridge
  CONSTRAINT no_overlapping_prices EXCLUDE USING gist (
    dealer_id    WITH =,
    product_code WITH =,
    daterange(valid_from, COALESCE(valid_to, 'infinity'), '[]') WITH &&
  )
);

When we first ran the backfill, Postgres rejected 47 rows. The legacy table had silently held overlapping price agreements for the same dealer and product, sometimes for years. The old PHP picked the one with the highest id and shipped it. The new schema refused to import them, which was the right answer: we walked each of the 47 cases through the sales manager and resolved them. That conversation took two days. It would have taken two years of monthly bookkeeping mysteries otherwise.

Week 2: the Hono read API, fed from the legacy DB

Before any data moved, we put a read-only Hono service in front of the existing MySQL. It exposed the new API shape: /dealers/:id, /agreements?dealer=…, /orders?week=…. Under the hood it queried the legacy schema and translated rows on the fly. No writes. No new database yet. The point was to give the SvelteKit team a stable, modern API to build against while the schema work continued in parallel.

// apps/api/src/routes/agreements.ts
import { Hono } from 'hono'
import { z } from 'zod'
import { legacyDb } from '../db/legacy'

export const agreements = new Hono()

agreements.get('/', async (c) => {
  const dealer = z.coerce.number().int().parse(c.req.query('dealer'))
  const rows = await legacyDb.query(
    `SELECT id, product_code, prijs_eur, btw_vrij, valid_from, valid_to
       FROM jos_dealers_prijs
      WHERE dealer_id = ?
        AND (valid_to IS NULL OR valid_to >= CURDATE())`,
    [dealer],
  )
  return c.json(rows.map((r) => ({
    id: r.id,
    productCode: r.product_code,
    unitPriceEur: Number(r.prijs_eur),
    vatExempt: r.btw_vrij === 1,
    validFrom: r.valid_from,
    validTo: r.valid_to,
  })))
})

Two things made this step cheap. Hono on Bun meant no transpile step and a 30 ms cold start during development. The translation layer was pure functions, so we unit-tested it against snapshots of real legacy rows without touching MySQL at all.

Week 3: SvelteKit dealer UI against the translation layer

With a stable API contract, SvelteKit built the new dealer login, the price-agreement view, and the weekly order form. We deployed it to a staging subdomain behind a basic-auth gate, gave the sales manager access, and asked him to use it for one full week alongside the old portal. He found four bugs. Two were ours. Two were latent bugs in the legacy data that the old PHP silently swallowed: a dealer with a NULL btw_nummer who somehow still had VAT-exempt agreements; an order line with a negative quantity from a 2017 credit note that never got cleaned up.

Week 4: the dual-write switch

Week four is where the migration becomes a system change rather than a build. We moved the Postgres schema into place, ran a full backfill from MySQL using pgloader plus a custom transformation pass for the messy columns, and put a write proxy in front of both databases.

The shape is straightforward. The legacy Joomla admin still writes to MySQL. A small PHP shim, ten files, hooks into the existing model save events and POSTs the same change to the Hono API, which writes to Postgres. Reads stay on MySQL. Every night, a reconciler compares the two databases row-by-row and emails a diff if anything drifts.

// components/com_dealers/models/dealer.php (shim)
public function save($data) {
    $result = parent::save($data);
    if ($result) {
        try {
            $this->mirror->post('/internal/dealers/' . $data['id'], $data);
        } catch (Exception $e) {
            JLog::add('mirror failed: ' . $e->getMessage(),
                     JLog::WARNING, 'dealers');
            // Do not fail the legacy write. Reconciler will catch it.
        }
    }
    return $result;
}

The "do not fail the legacy write" comment is the whole philosophy in one line. During shadow traffic, if Postgres is unreachable, MySQL still writes, the reconciler logs the drift overnight, and we replay it from the MySQL binlog the next morning. The old portal never knows the new one is there.

Week 5: Exact Online journal parity

The journal export is the part of the project that no one wants to touch and everyone needs to work. Every night at 02:00 the old portal generated a CSV of the day's invoiced lines, mapped each line to a grootboekrekening, and POSTed it to the Exact Online REST API. The mapping logic was 1,400 lines of PHP with comments in three languages.

Rather than rewrite it, we ran both exports in parallel for the full week. The legacy export still ran. The new export, written in TypeScript against the same Exact Online endpoints, ran fifteen minutes later in a dry-run mode that produced the JSON payload but did not send it. Every morning we diffed the two payloads. By Friday they were identical for five days in a row. We switched the cron: new export sends, old export dry-runs as the backup.

Takeaway

If your migration touches the bookkeeper's data, cutover is not "deploy and watch." It is "run both in parallel until the diff is empty for a full week."

Week 6: read cutover, then the legacy freeze

By the end of week five the two databases had matched on every reconciler run for nine consecutive days. The price-agreement totals were identical. The journal exports were identical. Time to swap reads.

Read cutover was a DNS-level switch. The dealer-portal subdomain moved from the Joomla box to the SvelteKit deployment. The Hono API kept dual-writing for two more weeks as insurance. The Joomla admin was left in place, read-only, behind a VPN, so the office could still pull a 2019 invoice if a dealer asked.

The last step, two weeks after the read cutover, was the legacy freeze. We took a final mysqldump, archived it to cold storage, and started removing legacy tables. On a busy database, the only scalable delete is DROP TABLE. The 41 used tables stayed. The other 143 went via DROP TABLE in a single transaction. Trying to clean them up row-by-row would have taken hours and locked the box. Dropping them took eighty milliseconds.

What we kept from the old portal

Three things, deliberately. The dealer login URLs: /index.php?option=com_dealers&view=orders still resolves, via a redirect map in SvelteKit's hooks.server.ts, to the new /orders route. Bookmarks did not break. The invoice PDF layout, pixel-for-pixel: the bookkeeper had eleven years of muscle memory for where the BTW total sits, and we did not negotiate that. The Exact Online grootboekrekening mapping table, copied verbatim, because rewriting it would have been the single biggest source of bookkeeping errors. We will refactor that later, when the new system has earned the trust.

The rollback path we never used

For the full six weeks, rollback was one DNS change and a MySQL flag flip. The Joomla install was never touched. If the SvelteKit portal had failed on cutover day, we would have pointed DNS back at the old box, flipped the legacy read_only flag off, and been back on the eleven-year-old stack inside three minutes. We never used it. We still built it.

When we built this dealer-portal migration for the Antwerp bakery, the hard part was not SvelteKit or Hono. It was the discipline of running both systems in parallel long enough to prove they agreed on every price agreement and every journal line. That is the part most legacy migration projects skip, and it is what separates a calm Tuesday cutover from a furious bookkeeper on Wednesday morning.

If you are sitting on a Joomla 2.5 or 3.x portal with real money flowing through it, the smallest thing you can do today is the dictionary. Open a spreadsheet, list every table the live portal actually reads from, and write one sentence per column about what it really contains. Half your migration is already done.

Key takeaway

For a portal with money flowing through it, cutover is not deploy and watch. It is run both systems in parallel until the diff is empty for a full week.

FAQ

Why not just upgrade Joomla 2.5 to Joomla 5 in place?

The upgrade path from 2.5 to 5 is not a path. It is three sequential rewrites (2.5 to 3, to 4, to 5) and every custom extension breaks at each step. On an 11-year-old portal that is more work than rebuilding on a current stack, and you still end up on Joomla.

How long does a shadow-traffic cutover really take?

For a portal with one nightly accounting export, six weeks is realistic. Add a payment gateway or a stock-management integration and budget eight to ten. The schedule is set by how long the reconciler has to run clean, not by how fast the new code is built.

What if the legacy database has too many undocumented columns to map?

Stop coding and start reading. Spend a full week on the dictionary before any new schema work begins. The teams that skip this step pay it back later in production bugs that take three times as long to fix.

Can SvelteKit handle a portal with 240 concurrent dealer logins?

Yes, comfortably. SvelteKit's bottleneck is almost never the framework. It is the database query plan and the PDF generator. We sized Postgres for ten times the live load and the SvelteKit node ran on a single 2 vCPU box.

joomlamigrationphpmysqllegacy sitescase study

Building something?

Start a project