← Blog

Joomla

Joomla 2.5 naar SvelteKit: zes weken shadow cutover

Een Joomla 2.5-portaal van 18 jaar oud verwerkte nog steeds 3.800 dealerprijzen en live Buckaroo iDEAL-orders. Dit is de shadow cutover van zes weken die hem verving zonder downtime.

Jacob Molkenboer· Oprichter · A Brand New Company· 24 jun 2025· 9 min
Open leren grootboek met vergeelde prijskolommen, koperen label aan touw, groen zijden lint, gebarsten rode lakzegel op ivoor papier.

Twintig over zes 's ochtends, een loods in Apeldoorn eind oktober. De magazijnchef opent een Firefox-tab naar een Joomla-portaal dat er precies hetzelfde uitziet als in 2008. Op de achtergrond heeft een cron-job net de nachtelijke vaste orders opgehaald voor composiet-vulkits, aligners en sterilisatiezakjes van 312 dealerpraktijken in de Benelux. De pagina rendert in 1,4 seconden. De order-CSV is 96 regels. Alles werkt.

De site draait sinds november 2007. Hij is gebouwd op Joomla 2.5.28, laatste release maart 2014, op PHP 5.6.40, laatste release januari 2019. De prijs-engine is één PHP-bestand van 4.100 regels. De B2B-loginlaag is destijds met de hand geschreven door een ex-stagiair. De Buckaroo iDEAL-integratie praat BPE 3.0 over SOAP. En in het afgelopen jaar verwerkte het systeem voor 9,1 miljoen euro aan vaste orders, zonder één mislukte transactie.

Onze opdracht: de hele stack vervangen, elke euro omzet onderweg houden, en de standing-order cron waar het magazijn op leunt niet breken.

Wat we aantroffen

De audit kostte vier dagen. We deden hem vóór de offerte, want Joomla 2.5-audits zijn het kerkhof van projecten. Wat we vonden:

  • Joomla 2.5.28 met acht third-party extensies, drie daarvan niet meer beschikbaar.
  • Een zwaar geforkte VirtueMart 2 met custom price-rule joins.
  • PHP 5.6 op een Debian 8-server achter nginx, overeind gehouden door een serie hacks uit het register_globals-tijdperk.
  • MySQL 5.5 met 412 tabellen. Een stuk of honderd ervan leeg.
  • 3.800 actieve regels in dental_price_matrix met dealerniveau, SKU, volumestaffel en einddatum contract.
  • 12.400 SKU's, waarvan er 87 vaker dan één keer per week verkochten. De long tail deed er nog steeds toe.
  • Een Buckaroo BPE 3.0 SOAP-integratie voor losse iDEAL-betalingen en een zelfgebouwde standing-order engine die dealers één keer per jaar een PDF-mandaat mailde.

Zowel Joomla 2.5 als PHP 5.6 zijn jaren geleden end of life gegaan. PHP 5.6 wordt sinds januari 2019 niet meer ondersteund, dus de server draaide zonder upstream security-patches en met een lange lijst bekende CVE's in de third-party extensies. De cyberverzekeraar stond op het punt om bij verlenging dekking te weigeren. Dat was, meer nog dan performance, de echte aanleiding.

Waarschuwing

Als je nog op Joomla 2.5 of 3.x draait met PHP 5.x, is de verlenging van je cyberverzekering de echte deadline, niet de EOL-datum. Acceptanten vragen tegenwoordig op het aanvraagformulier naar de PHP-minor versie en zetten alles onder 8.1 op een vlaggetje.

De prijsmatrix uit MySQL halen

De eerste echte engineering-stap was de prijsmatrix uit de oude database halen zonder semantiek te verliezen. Drie dingen maakten dat lastig. Eén: de matrix gebruikte soft-deleted rijen met een valid_until-kolom die soms NULL was, soms 0000-00-00, en soms een echte datum. Twee: dealerniveau was gecodeerd als één letter (A tot en met F), maar twee SKU-categorieën overschreven dat stilletjes. Drie: de matrix werd at runtime ge-joined met een contract_addendum-tabel die niemand had gedocumenteerd.

We trokken de actieve matrix met één query, legden hem voor aan de sales lead en kregen schriftelijk akkoord op het aantal regels voordat we ook maar iets aanraakten:

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 regels. We bevroren de export, zetten hem in versiebeheer en behandelden hem als source of truth voor de rest van het project. Elke diff daarna werd gemeten tegen deze CSV.

De vorm van de nieuwe stack

We kozen SvelteKit voor de storefront en het dealerportaal, Payload 3 voor de catalogus en de CMS-laag, en Postgres 16 onder allebei. De motivatie was saai en zo wilden we het ook.

De form actions van SvelteKit passen netjes op een B2B-portaal dat voor 80% uit formulieren bestaat. Server-side rendering houdt de dealer-loginflow binnen het firewall-pad waar we hem willen, en de storefront stuurt minder JavaScript over de lijn dan de oude Joomla-front-end deed met alle extensies uit. Payload draait op dezelfde Postgres-database als de storefront, dus de prijs-engine en de catalogus delen één transactiegrens. We wilden geen aparte commerce-service en een aparte CMS-sync-job. Postgres gaf ons fatsoenlijke check constraints, partial indexes op valid_until, en de mogelijkheid om de prijsregels als MATERIALIZED VIEW te draaien die elk uur ververst.

We zijn bewust niet naar een hosted commerce-platform gegaan. Shopify Plus, BigCommerce en de rest modelleren prijslijsten op een manier die 3.800 overlappende regels met contract-geldigheid niet overleeft. Dat passend maken zou betekenen dat we de sales lead moesten uitleggen waarom een dealer vier cent meer betaalde voor een doos articulatiepapier. Dat gesprek loopt niet goed af.

Het Payload-schema bestond uit vier collections (Products, Dealers, PriceRules, Contracts) en twee globals (Catalogue settings, Buckaroo merchant config). De prijs-engine zelf was één Postgres-functie die (dealer_id, sku, quantity, ordered_at) inlas en (unit_price, source_rule_id) teruggaf. Elke quote in het nieuwe portaal riep die functie aan en bewaarde de source rule ID op de orderregel. Een half jaar later heeft die ene kolom al drie prijsdiscussies beslist.

Buckaroo iDEAL op beide stacks levend houden

Van de betalingen lagen we wakker. Het oude systeem gebruikte de legacy BPE 3.0 SOAP van Buckaroo. Het nieuwe systeem zou de Buckaroo JSON API spreken met HMAC-getekende requests. Beide moesten zes weken lang tegelijk werken, want dealers hadden open mandaten staan tegen de oude merchant key.

Het plan:

  1. Een tweede Buckaroo merchant account openen op dezelfde rechtspersoon. Buckaroo regelt dat binnen 48 uur als je het netjes vraagt en de entiteit klopt.
  2. Verkeer van het nieuwe portaal naar merchant key B sturen. Bestaande TokenCheckout-mandaten naar merchant key A laten wijzen.
  3. Aan de SvelteKit-kant een webhook router schrijven die beide push-formats accepteert en in één payments-tabel schrijft met een source-kolom.
  4. Elke betalingsnotificatie uit een venster van 30 dagen replayen naar een staging database om te bevestigen dat de router identieke boekingsregels produceerde.

De replay-stap ving twee bugs op die we anders nooit hadden gevonden. De ene was een tijdzone-bug in de oude SOAP-push (Buckaroo stuurt Amsterdamse tijd, de oude code ging uit van UTC). De andere was een afrondingsverschil op gedeeltelijke terugbetalingen, jarenlang verborgen omdat niemand er ooit één had uitgevoerd.

Zes weken shadow traffic

Dit was het stuk van het project dat ons de rustige cutover opleverde. Vanaf week drie van de bouw spiegelden we elke echte read request van het oude portaal naar het nieuwe en gooiden de responses weg. Daarna promoveerden we een gesloten pilot van negen dealers naar write traffic, nog steeds met de oude database als source of truth en de nieuwe database als slave erachteraan.

Het shadow-rig draaide een diff op elke /api/price-quote-response. Dezelfde dealer, dezelfde SKU, dezelfde cart, beide stacks. Alles wat niet binnen een halve cent overeenkwam, ging in een queue. De diff negeerde de volgorde van orderregels, normaliseerde valuta-precisie naar vier decimalen, en taggede elke mismatch met de source rule ID van de nieuwe engine, zodat we direct naar de bewuste regel in de matrix-CSV konden lopen.

In zes weken kreeg de queue 11 mismatches binnen over 4.200 dealer-logins. Acht waren echte bugs in de nieuwe prijs-engine. Twee waren afrondingsverschillen waarvan we beslisten dat het nieuwe systeem gelijk had, met een briefje naar sales. Eén was een individuele dealer met een eenmalig addendum waarvan niemand aan de klantkant zich herinnerde dat het in 2017 was getekend. Elke mismatch werd een regressietest voordat de fix landde, dus we eindigden het project met een test suite voor de prijs-engine die de business rules beter documenteert dan welke wiki ook.

Het cutover-weekend

We schakelden over op zaterdagochtend in maart, niet op zondagavond. De redenering: als het brak, zaten zowel de magazijnchef als de Buckaroo-supportdesk gewoon op kantoor. Uur voor uur:

  • 06:00. Writes op het oude portaal bevriezen. Read-only banner aan. Sales lead in de kamer.
  • 06:15. Laatste delta-export van MySQL naar Postgres. Negen minuten voor de matrix, 24 minuten voor de orderhistorie.
  • 06:50. Buckaroo standing-order mandaten opnieuw uitgegeven tegen merchant key B voor de 38 dealers die de komende 30 dagen aan de beurt zijn. Oudere mandaten op key A laten staan en natuurlijk laten uitlopen.
  • 07:20. DNS TTL op het portaaldomein, twee dagen eerder verlaagd naar 60 seconden, omgezet naar het nieuwe A-record.
  • 07:35. Eerste echte dealer-login op de nieuwe stack. Standing-order cron gepauzeerd.
  • 09:10. Standing-order cron hervat tegen de nieuwe database. Eerste batch schoon verwerkt.
  • 14:00. Read-only banner weg van het oude portaal. Oude portaal warm gehouden, maar read-only.

Geen noodrollback. Twee dealers belden het kantoor over een layout-wijziging. Eén had bezwaar tegen de kleur van de nieuwe loginknop en had gelijk.

Wat we bewust hebben behouden

Twee dingen hebben we niet gemoderniseerd. De dealer order-ID-reeks ging gewoon door vanaf 184.209, omdat de eigen boekhoudsystemen van de dealers ernaar verwezen. We hebben er gewoon 10.000 bij opgeteld om ruimte te laten voor een achterblijver van de oude server. En we hebben één legacy URL-patroon behouden, /index.php?option=com_virtuemart&view=productdetails&product_id=…, met een permanente 301 naar de nieuwe SKU-URL. Google had er enkele duizenden geïndexeerd en de dealerpraktijken hadden ze nog steeds in hun favorieten.

Het oude PHP-bestand staat ergens nog op een koude server, ongemoeid. We weigeren het de komende twaalf maanden te verwijderen. De verzekeraar kan dat met ons opnemen bij de volgende verlenging.

Een audit van vijf minuten die je vandaag kunt draaien

Als je vermoedt dat je op een vergelijkbaar portaal zit, draai dit dan vanaf een shell op de server en lees de uitvoer voor aan je verzekeraar:

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

Vijf regels. PHP-versie, Joomla-versie, MySQL-versie, aantal pre-PDO code paths, en de vervaldatum van het certificaat. Als één daarvan iets van vóór 2020 teruggeeft, is het gesprek met de acceptant allang te laat.

Toen we het portaal van deze tandheelkundige distributeur opnieuw bouwden, hadden we de replay van de prijsmatrix onderschat. We ondervingen het met het shadow-traffic-rig, dat we nu hergebruiken op elke legacy migratie die we oppakken. Zeshonderd regels TypeScript, één weekend opzetten, en het verdient zichzelf terug op het moment dat het de eerste verkeerd geprijsde quote vangt.

Kern

Shadow traffic is de goedkoopste verzekering op een commerce-migratie: ongeveer 12% extra bouwkosten, risico op een verkeerd geprijsde factuur in het cutover-weekend bijna nul.

FAQ

Hoe lang duurde de volledige migratie van Joomla 2.5 naar SvelteKit?

Veertien weken van kick-off tot cutover, inclusief een audit van vier dagen, een bouw van acht weken en zes weken overlappende shadow traffic vóór de schakeling op zaterdagochtend.

Waarom hebben jullie het B2B-portaal niet naar Shopify Plus of BigCommerce verhuisd?

Het prijsmodel had 3.800 overlappende regels met contract-geldigheid en dealer-niveau overrides. Hosted commerce-prijslijsten konden dat niet representeren zonder afrondingsfouten die het salesteam direct had opgemerkt.

Hoe hielden jullie Buckaroo iDEAL in de lucht tijdens de cutover?

Twee Buckaroo merchant accounts op dezelfde rechtspersoon draaiden parallel. Nieuwe orders gebruikten de JSON API op key B. Bestaande TokenCheckout-mandaten bleven afvuren tegen key A tot ze natuurlijk afliepen.

Wat gebeurt er met de oude Joomla 2.5-server na de cutover?

Die blijft twaalf maanden koud, read-only en afgesloten van het publieke internet. Daarna archiveren we de database naar S3 en zetten we de server uit. We verwijderen nooit op de dag van de cutover zelf.

Is zes weken shadow traffic altijd nodig?

Niet altijd. We zetten het in als de prijs- of facturatielogica niet triviaal is en de kosten van een verkeerde factuur hoog. Voor een migratie van een brochuresite is twee weken read-only spiegelen meestal genoeg.

joomlamigrationlegacy sitese-commercephpcase study

Iets bouwen?

Start een project