Joomla
Joomla 2.5 migratie: een shadow cutover van zes weken
Het dealerportaal van een Antwerpse groothandelsbakker draaide op Joomla 2.5, PHP 5.4 en 9.800 BTW-vrijgestelde prijsafspraken. Zo verhuisden we het zonder er één te verliezen.

Het dealerportaal van deze Antwerpse groothandelsbakker was ouder dan drie van zijn verkopers. Joomla 2.5, PHP 5.4, één MySQL 5.5 instance op een Hetzner-bak die niemand had herstart sinds de stroomstoring van 2022. Drieëndertig medewerkers gebruikten het elke ochtend. Tweehonderdveertig bakkerijen, restaurants en hotels logden in vanuit hun eigen admin om facturen te downloaden en de bestelling voor volgende week te bevestigen. De data binnenin telde: 9.800 prijsafspraken (BTW-vrijgesteld, elk de afgelopen elf jaar afzonderlijk met een dealer onderhandeld), en een nachtelijke journaalexport die rechtstreeks doorging naar Exact Online voor de boekhouder.
De site werkte. Patchen kon ook niet meer. Joomla 2.5 bereikte end-of-life in december 2014. PHP 5.4 volgde in 2015. De custom dealerportaal-extensie was geschreven door een bureau dat in 2019 de deuren sloot. Toen wij het dossier oppakten, was de opdracht simpel: van deze stack af zonder ook maar één prijsafspraak te verliezen en zonder de Exact Online export te breken. Zes weken. Geen onderhoudsvenster langer dan vijftien minuten.
Shadow traffic boven big-bang cutover
Big-bang migraties werken prima voor marketingsites. Ze werken niet voor portalen waar 240 klanten op inloggen om BTW-correcte facturen te downloaden. Het risico is niet "de nieuwe site ziet er lelijk uit". Het is "de dealer in Hasselt krijgt 21% BTW op brood dat hij negen jaar lang vrijgesteld heeft afgenomen, en zijn boekhouder ontdekt het eerder dan wij".
Het patroon dat we kozen is bekend uit payment systems: schrijf naar beide, lees uit één, switch de reads als de pariteit klopt. De nieuwe stack draait naast de oude zolang als nodig is om te bewijzen dat ze het eens zijn over elke factuur, elke prijsafspraak, elke journaalregel. Wij noemen het shadow traffic. Hoe je het ook noemt, de regel is dezelfde: het nieuwe systeem mag het oude nooit platleggen.
De target stack
We kozen SvelteKit voor de dealer-UI, Hono op Bun voor de JSON API, en Postgres 16 voor opslag. De keuze werd door drie dingen gestuurd, in deze volgorde.
Eerst: het portaal bestaat vooral uit formulieren, tabellen en PDF-downloads. De progressive enhancement en ingebouwde form actions van SvelteKit zijn het kleinste wat login, server-rendered tabellen en offline-tolerante orderinvoer aankan zonder dat je voor de UI alleen een aparte API-laag tegenaan moet schroeven.
Twee: Hono is klein genoeg om in één middag door te lezen. De accountant van de klant wilde de code van de journaalexport met ons doorlopen voordat hij ging tekenen. Dat had hij nooit gekund met de originele Joomla-extensie; met 600 regels TypeScript wel.
Drie: Postgres handelt de constraints op prijsafspraken native af. Partial unique indexes, exclusion constraints op date ranges, transactionele DDL als we halverwege de migratie kolommen moeten toevoegen. De tabel met prijsafspraken is het hart van het hele portaal, en in Postgres beschrijf je "twee prijzen voor dezelfde dealer en hetzelfde product mogen niet in de tijd overlappen" als constraint in plaats van als stukje applicatiecode dat iedereen vergeet bij te werken.
Week 1: snapshot, schema map en het woordenboek
De eerste week was lezen, niet schrijven. We trokken een volledige mysqldump van de productiedatabase (4,2 GB), restoreden die naar een staging Postgres via pgloader, en begonnen te mappen. Het Joomla-schema telde 184 tabellen. Eenenveertig werden daadwerkelijk gebruikt. De rest waren Joomla-core resten, twee mislukte forumextensies, en een onafgemaakt nieuwsbriefsysteem uit 2018.
Het woordenboek is het saaie artefact dat het project redt. Eén blad per legacy tabel: kolomnaam, type, wat er werkelijk in staat (vaak niet wat de kolomnaam zegt), en waar het in het nieuwe schema landt. Het dealerportaal had een jos_dealers_prijs tabel waar prijs_eur soms de stuksprijs was, soms het regeltotaal, afhankelijk van of line_count null was. Dat soort dingen.
Kost je woordenboek minder dan een week op een PHP-codebase van elf jaar oud, dan ben je niet klaar met lezen. De ongedocumenteerde kolommen zijn waar de volgende vier weken aan bugs zitten.
De nieuwe price-agreement tabel is kort, en de exclusion constraint onderaan is de clou:
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 &&
)
);
Toen we de backfill voor het eerst draaiden, weigerde Postgres 47 rijen. De legacy tabel had jarenlang stilletjes overlappende prijsafspraken voor dezelfde dealer en hetzelfde product bevat. De oude PHP pakte degene met het hoogste id en stuurde die door. Het nieuwe schema weigerde ze te importeren, en dat was het juiste antwoord: we hebben elk van de 47 gevallen met de salesmanager doorgenomen en opgelost. Dat gesprek duurde twee dagen. Anders waren het twee jaar maandelijkse boekhoudmysteries geweest.
Week 2: de Hono read API, gevoed vanuit de legacy DB
Voor er ook maar één rij verhuisde, zetten we een read-only Hono-service voor de bestaande MySQL. Die bood de nieuwe API-vorm aan: /dealers/:id, /agreements?dealer=…, /orders?week=…. Onder de motorkap querydde hij het legacy schema en vertaalde de rijen on the fly. Geen writes. Nog geen nieuwe database. Het idee was om het SvelteKit-team een stabiele, moderne API te geven om tegenaan te bouwen, terwijl het schemawerk parallel doorliep.
// 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,
})))
})
Twee dingen maakten deze stap goedkoop. Hono op Bun betekent geen transpile-stap en een cold start van 30 ms tijdens development. De translation layer bestond uit pure functies, dus unit-testen tegen snapshots van echte legacy rijen kon zonder MySQL ook maar aan te raken.
Week 3: SvelteKit dealer-UI tegen de translation layer
Met een stabiel API-contract bouwde SvelteKit de nieuwe dealerlogin, de prijsafsprakenview en het wekelijkse bestelformulier. We zetten het neer op een staging subdomein achter een basic-auth gate, gaven de salesmanager toegang en vroegen hem het een hele week naast het oude portaal te gebruiken. Hij vond vier bugs. Twee waren van ons. Twee waren latente bugs in de legacy data die de oude PHP stilletjes slikte: een dealer met een NULL btw_nummer die op de een of andere manier toch BTW-vrijgestelde afspraken had; een orderregel met een negatieve hoeveelheid uit een creditnota van 2017 die nooit was opgeruimd.
Week 4: de dual-write switch
Week vier is waar de migratie verandert van bouwen in een systeemverandering. We zetten het Postgres-schema in productie, draaiden een volledige backfill vanuit MySQL met pgloader plus een custom transformatieronde voor de rommelige kolommen, en zetten een write-proxy voor beide databases.
De vorm is eenvoudig. De legacy Joomla-admin schrijft nog steeds naar MySQL. Een kleine PHP-shim, tien bestanden, haakt in op de bestaande model save events en POST't dezelfde wijziging naar de Hono API, die naar Postgres schrijft. Reads blijven op MySQL. Elke nacht vergelijkt een reconciler de twee databases rij voor rij en mailt een diff als er iets afwijkt.
// 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;
}
De comment "do not fail the legacy write" is de hele filosofie in één regel. Tijdens shadow traffic schrijft MySQL nog steeds als Postgres onbereikbaar is, logt de reconciler 's nachts de drift, en spelen we het de volgende ochtend opnieuw af vanuit de MySQL binlog. Het oude portaal merkt nooit dat het nieuwe er staat.
Week 5: pariteit op de Exact Online journaalexport
De journaalexport is het deel van het project dat niemand wil aanraken en dat iedereen nodig heeft. Elke nacht om 02:00 genereerde het oude portaal een CSV met de gefactureerde regels van die dag, mapte elke regel naar een grootboekrekening, en POST'te het naar de Exact Online REST API. De mappinglogica was 1.400 regels PHP met commentaar in drie talen.
In plaats van herschrijven draaiden we beide exports een week parallel. De legacy export liep nog. De nieuwe export, geschreven in TypeScript tegen dezelfde Exact Online endpoints, liep vijftien minuten later in dry-run mode: hij produceerde de JSON-payload maar verstuurde die niet. Elke ochtend diften we de twee payloads. Tegen vrijdag waren ze vijf dagen op rij identiek. We switchten de cron: nieuwe export verstuurt, oude export draait dry-run als back-up.
Zit jouw migratie aan de data van de boekhouder, dan is cutover geen "uitrollen en kijken". Het is "beide parallel draaien tot de diff een hele week leeg is".
Week 6: read cutover, daarna de legacy freeze
Aan het eind van week vijf matchten de twee databases bij elke reconciler-run negen dagen op rij. De totalen op de prijsafspraken waren identiek. De journaalexports waren identiek. Tijd om de reads te switchen.
De read cutover was een DNS-wijziging. Het subdomein van het dealerportaal verhuisde van de Joomla-bak naar de SvelteKit-deployment. De Hono API bleef nog twee weken dual-writen als verzekering. De Joomla-admin lieten we staan, read-only, achter een VPN, zodat het kantoor nog een factuur uit 2019 kon ophalen als een dealer ernaar vroeg.
De laatste stap, twee weken na de read cutover, was de legacy freeze. We trokken een laatste mysqldump, archiveerden die naar cold storage, en begonnen legacy tabellen op te ruimen. Op een drukke database is de enige schaalbare delete DROP TABLE. De 41 gebruikte tabellen bleven. De andere 143 gingen in één transactie via DROP TABLE. Ze rij voor rij opruimen had uren gekost en de bak op slot gegooid. Droppen kostte tachtig milliseconden.
Wat we uit het oude portaal hebben behouden
Drie dingen, bewust. De dealer-login URL's: /index.php?option=com_dealers&view=orders resolvet nog steeds, via een redirect map in SvelteKit's hooks.server.ts, naar de nieuwe /orders route. Bookmarks bleven werken. De factuur-PDF layout, pixel voor pixel: de boekhouder had elf jaar spiergeheugen voor waar het BTW-totaal staat, en daar viel niet over te onderhandelen. De Exact Online grootboekrekening-mappingtabel, letterlijk gekopieerd, omdat herschrijven dé grootste bron van boekhoudfouten zou zijn geweest. Die refactoren we later, als het nieuwe systeem het vertrouwen heeft verdiend.
De rollback die we nooit nodig hadden
Zes weken lang was rollback één DNS-wijziging en een MySQL flag flip. De Joomla-installatie werd niet aangeraakt. Was het SvelteKit-portaal op cutover-dag omgevallen, dan hadden we DNS terug op de oude bak gezet, de read_only flag op de legacy uitgezet, en draaiden we binnen drie minuten weer op de elf jaar oude stack. We hebben het nooit gebruikt. We hebben het toch gebouwd.
Toen we deze dealerportaal-migratie voor de Antwerpse bakkerij bouwden, was het lastige deel niet SvelteKit of Hono. Het was de discipline om beide systemen lang genoeg parallel te draaien om te bewijzen dat ze het eens waren over elke prijsafspraak en elke journaalregel. Dat is het deel dat de meeste legacy migraties overslaan, en het is wat een rustige dinsdagse cutover scheidt van een woedende boekhouder op woensdagochtend.
Zit je op een Joomla 2.5 of 3.x portaal waar echt geld doorheen stroomt, dan is het kleinste wat je vandaag kunt doen het woordenboek. Open een spreadsheet, lijst elke tabel die het live portaal echt uitleest, en schrijf één zin per kolom over wat er werkelijk in staat. De helft van je migratie is dan al klaar.
Kern
Voor een portaal waar geld doorheen stroomt, is cutover geen uitrollen en kijken. Het is beide systemen parallel draaien tot de diff een hele week leeg is.
FAQ
Waarom Joomla 2.5 niet gewoon in place upgraden naar Joomla 5?
De upgrade van 2.5 naar 5 is geen pad. Het zijn drie opeenvolgende rewrites (2.5 naar 3, naar 4, naar 5) en elke custom extensie breekt bij elke stap. Op een portaal van elf jaar oud is dat meer werk dan opnieuw bouwen op een actuele stack, en je eindigt nog steeds op Joomla.
Hoe lang duurt een shadow-traffic cutover in de praktijk?
Voor een portaal met één nachtelijke boekhoudexport is zes weken realistisch. Voeg een payment gateway of een voorraadintegratie toe en reken op acht tot tien. Het schema wordt bepaald door hoe lang de reconciler schoon moet draaien, niet door hoe snel de nieuwe code is gebouwd.
Wat als de legacy database te veel ongedocumenteerde kolommen heeft om te mappen?
Stop met coderen en begin met lezen. Steek een volle week in het woordenboek voor er ook maar één nieuwe schemaregel wordt geschreven. Teams die deze stap overslaan, betalen dat later terug in productiebugs die drie keer zo lang kosten om te fixen.
Trekt SvelteKit een portaal met 240 gelijktijdige dealerlogins?
Ja, ruim. De bottleneck van SvelteKit is bijna nooit het framework. Het is het database query plan en de PDF-generator. We dimensioneerden Postgres voor tien keer de live load en de SvelteKit-node draaide op één bak met 2 vCPU's.