← Blog

Joomla

Joomla naar Shopify Hydrogen: zes weken shadow cutover

De Joomla-cart van een koffie-importeur in Antwerpen liep elke vrijdag om 16:00 elf minuten vast. Dit is de shadow cutover in zes weken die we erop loslieten.

Jacob Molkenboer· Oprichter · A Brand New Company· 17 jun 2026· 9 min
Leren scheepslogboek, koperen sleutel op crème kaart, ijzeren label met groen draad, lakzegel op ivoor papier.

Het is vrijdag 16:00 in Antwerpen en de webshop van een specialty-koffie-importeur met 29 mensen ligt weer plat. De Exact Online inkoopsync draait elke werkdag op het hele uur, de carttabellen van VirtueMart gaan elf minuten op read-only, en een bestelling van een brander in Gent loopt vast in de checkout. De shop heeft 3.800 herkomstpagina's die allemaal nog ranken, en een per-batch cuppingscore-historie die klanten echt gebruiken om te beslissen welke Yirgacheffe-lot ze kopen. Niets op de site is kapot genoeg om weg te gooien. Het is allemaal te broos om in 2026 nog op Joomla 3.9 en VirtueMart 3.4 te laten draaien.

Dit is de playbook waarmee we ze in zes weken shadow-traffic-cutover op Shopify Hydrogen en Sanity hebben gezet, zonder één rankende pagina of één batch-record te verliezen.

Waarom we stopten met patchen

Joomla 3.x ging end-of-life in augustus 2023, zoals het project aankondigde op de Joomla 3.10 stable release-pagina. VirtueMart 3.4 leeft technisch nog steeds, maar het releaseritme is gezakt naar twee patches per jaar en de PHP 7.4-stack eronder loopt twee majors achter. De klant zat sinds 2021 op een onderhoudscontract bij een ander bureau. Hun reden om over te stappen: dat bureau wilde een upgrade naar Joomla 4 doen. De CFO wilde weten waarom hij €18k moest betalen om uit te komen op een versie die in 2028 EOL gaat.

We hebben de site een week lang doorgelicht. De bevindingen waren saai en doorslaggevend:

  • De cartlock tijdens de Exact-sync was een MySQL-transactie die row-level locks vasthield op jos_virtuemart_orders terwijl de sync naar jos_virtuemart_product_prices schreef.
  • De 3.800 herkomstpagina's waren K2-artikelen met zeventien custom fields per stuk, opgeslagen als geserialiseerde PHP in één extra_fields-kolom.
  • De cuppingscore-historie zat in een custom VirtueMart-attribuuttabel, gebouwd door een developer die in 2019 vertrok. Documentatie was er niet.
  • De Exact Online-sync was een PHP-cronscript dat opnieuw inlogde met een long-lived password en de inmiddels deprecated REST v1-endpoints van Exact gebruikte.

Een upgrade naar Joomla 4 zou er één van oplossen. Geen van de andere.

De stack die we kozen

Voor de storefront kozen we Shopify Hydrogen, voor producten en checkout Shopify, en voor de content-backbone Sanity. De splitsing is het belangrijke deel. Shopify is verantwoordelijk voor transacties, voorraad, betalingen en btw. Sanity beheert de 3.800 herkomstpagina's, de cuppingscore-historie, de fotobibliotheken van herkomstreizen en de redactionele copy. Elke origin in Sanity bevat referenties naar de Shopify-producten die eruit voortkwamen. Elke batch in Shopify draagt een Sanity-referentie terug naar zijn origin.

De reden voor Sanity in plaats van Shopify metaobjects: de redactie schrijft long-form. Herkomstpagina's zijn 800 tot 1.400 woorden, met fotogalerijen en ingesloten tasting-maps. Voor productcopy is de CMS van Shopify prima. Voor wat deze pagina's eigenlijk zijn, namelijk reisjournalistiek, is hij pijnlijk.

De regel waar we op elke redactie-plus-commerce-migratie sindsdien op terugkomen: als je content redactioneel is en je commerce transactioneel, splits ze dan op het datalaag-niveau. Laat één CMS niet doen alsof hij de ander is.

3.800 herkomstpagina's modelleren in Sanity

De Joomla-content was een K2-artikel met zeventien custom fields. We hebben het omgezet naar een Sanity-document met een schoon schema:

// sanity/schemas/origin.ts
export default {
  name: 'origin',
  type: 'document',
  fields: [
    { name: 'title', type: 'string' },
    { name: 'slug', type: 'slug', options: { source: 'title' } },
    { name: 'country', type: 'string' },
    { name: 'region', type: 'string' },
    { name: 'farm', type: 'string' },
    { name: 'altitude_m', type: 'number' },
    { name: 'process', type: 'string' },
    { name: 'varietals', type: 'array', of: [{ type: 'string' }] },
    { name: 'hero', type: 'image' },
    { name: 'body', type: 'array', of: [{ type: 'block' }, { type: 'image' }] },
    { name: 'shopify_products', type: 'array', of: [{ type: 'reference', to: [{ type: 'product' }] }] },
    { name: 'legacy_url', type: 'string' }, // original Joomla URL, kept for redirect mapping
  ],
}

De migratie liep in drie passes. Pass één: een Python-script trok elke Joomla-pagina via de SEF-URL binnen, parste de K2-HTML met BeautifulSoup, en dumpte JSON. Pass twee: een Node-script transformeerde de JSON naar het importformaat van Sanity, mapte de custom fields, en uploadde afbeeldingen naar de CDN van Sanity. Pass drie: een redacteur reviewde elke pagina in batches van 100 over vier dagen, markeerde kapotte galerijen, en fixte de 184 pagina's waar de Joomla-editor Word-HTML in had geplakt.

URL-behoud was niet onderhandelbaar. Joomla serveerde de pagina's op /herkomst/colombia-huila-finca-tamana. Hydrogen serveert ze op hetzelfde pad. De 47 herkomstpagina's met URL-drift tussen 2015 en 2022 (de klant had twee keer van SEF-plugin gewisseld) hebben we in een redirect-tabel in Cloudflare gemapt. Elke oude URL resolved nog steeds.

Cuppingscores als metafield-vraagstuk

Elke batch die de klant importeert wordt gescoord volgens het SCA-cuppingprotocol: fragrance, flavor, aftertaste, acidity, body, balance, overall. Scores horen bij de batch, niet bij het product. Een 'Yirgacheffe Konga'-lijn heeft in zijn leven misschien acht batches, elk met eigen scores, en klanten geven om de historie.

In VirtueMart leefde dit in een custom attribuuttabel zonder foreign keys. In Shopify hebben we het schoon gemodelleerd: elke batch is een variant, elke variant draagt een metafield-namespace genaamd cupping, en de historische scores worden gemirrord naar een Sanity-batchdocument zodat de origin-pagina de volledige tijdlijn kan renderen.

# Fetch all batches for an origin with their cupping scores
query OriginBatches($originId: ID!) {
  origin(id: $originId) {
    title
    batches {
      _id
      lotCode
      roastedAt
      cuppingScore
      cuppingNotes
      shopifyVariantId
    }
  }
}

De keuze om data in beide systemen te spiegelen is het saaie soort pragmatisme dat het uithoudt. Shopify is de source of truth voor de actieve batch (dat is wat voorraad en checkout aanstuurt). Sanity is de source of truth voor de historie (dat is wat op de origin-pagina rendert). Een webhook van Shopify op variant-creatie schrijft het initiële batchdocument naar Sanity. De redactie vult de tasting-notes in.

Exact Online en de nachtelijke inkoopsync

De sync had drie taken: voorraadniveaus uit Exact in de webshop trekken, voltooide bestellingen als verkoopfacturen naar Exact pushen, en de inkoopfacturen van leveranciers afstemmen op binnenkomende productbatches. De Joomla-versie draaide als één PHP-cron om 16:00 en zette de database elf minuten op slot.

We hebben hem herbouwd als een Node-service op een kleine VPS, gesplitst in drie onafhankelijke workers, en overgezet op OAuth2 met refresh-token-rotatie. De Exact REST-endpoints die het oude script gebruikte worden uitgefaseerd richting de bulk-endpoints, die schonere paginatie teruggeven en niet timeouten op de voorraadpull. De documentatie staat op start.exactonline.nl/docs/HlpRestAPIResources.aspx.

Waarschuwing

De rate limit van Exact Online is per app per administratie, niet per gebruiker. Als je de workers naïef parallelliseert verbrand je de dagquota tegen 11 uur en faalt de sync silent tot middernacht CET.

De lock verdween omdat Shopify nu de voorraad beheert en de sync naar de inventory-API van Shopify schrijft, niet naar een lokale MySQL-tabel waaruit de storefront ook leest. De cart kan niet locken op een sync die de cart-database niet aanraakt.

Zes weken shadow traffic

De cutover was het deel waar de klant zich het meest zorgen om maakte. Hun vorige migratie (Magento 1 naar Joomla in 2014) verloor twee weken aan bestellingen. We hebben een shadow-traffic-plan opgezet om deze saai te maken.

De setup: een Cloudflare Worker zat voor beide stacks. Elke request naar de live Joomla-site werd gemirrord naar de Hydrogen-stack op shadow.client-domain.be. De gebruiker zag alleen ooit de Joomla-response. De Worker logde response codes, response times, getoonde prijzen en voorraadniveaus van beide kanten.

// cloudflare/worker.js (simplified)
export default {
  async fetch(request, env, ctx) {
    const legacy = fetch(request.clone(), { cf: { resolveOverride: 'legacy.origin' } })
    const shadow = fetch(rewriteHost(request, 'shadow.origin'), { cf: { resolveOverride: 'shadow.origin' } })

    ctx.waitUntil(
      Promise.all([legacy.then(r => r.clone()), shadow]).then(([a, b]) =>
        env.DIFF_QUEUE.send({ url: request.url, legacy: snapshot(a), shadow: snapshot(b) })
      )
    )

    return legacy
  },
}

Zes weken lang triageerden we elke ochtend de diff queue. Week één leverde 312 mismatches op: de meeste waren currency rounding (Joomla rondde af op het regelitem, Shopify rondt af op de cart), een handvol waren productvarianten die we te agressief hadden samengevoegd. Tegen week vier was het aantal mismatches onder de 20 per dag, allemaal onschuldig. Tegen week zes waren de enige diffs nog timestamp-velden en de cart-session-cookie.

De shadow run bracht ook een probleem aan het licht dat we anders pas in productie hadden gevonden: 23 herkomstpagina's hadden inbound links van koffieblogs die URL-fragmenten gebruikten die de SEF-rewriter van Joomla net wegknipte. Hydrogen serveerde die als 404's. We hebben het fragment-strippen toegevoegd aan de Cloudflare-redirect-tabel.

Cutover-weekend

De echte switch gebeurde op zaterdagochtend. De volgorde van de dag:

  1. Vrijdag 18:00 CET: Joomla-cart gaat op read-only. Banner toont: 'We zijn aan het verhuizen, bestellen kan zondag weer.' Catalogus-browsing blijft live.
  2. Vrijdag 20:00: Laatste Exact-sync draait. Openstaande bestellingen afgerond.
  3. Zaterdag 06:00: Laatste productimport van Joomla naar Shopify. 47 producten hadden prijswijzigingen gekregen in het read-only window (de admin gaat niet op slot). Met de hand afgestemd.
  4. Zaterdag 09:00: DNS-TTL was de woensdag ervoor verlaagd naar 60 seconden. DNS-swap naar Hydrogen.
  5. Zaterdag 10:30: Eerste echte bestelling via Hydrogen. Een brander in Mechelen, drie zakken Ethiopisch, betaald met Bancontact.
  6. Zondag 09:00: Cart gaat live voor iedereen.

Wat ons bijna had gebroken stond niet op de lijst. Het nieuwe verzendlabel-template printte een 2mm bredere barcode en de oude Zebra-printer in het magazijn knipte hem af. Op zaterdag 11 uur kwamen we erachter toen een brander de lijn belde over een mislukte labelscan. We rolden de templatebreedte terug, de labels printten, de bestelling ging eruit.

Wat we anders zouden doen

Twee dingen. Eén: we zouden het shadow-window korter maken. Zes weken was royaal. Na week drie was de diff queue ons niet meer aan het verrassen. Vier weken was genoeg geweest en had het team drie weken triage gescheeld. Twee: we zouden de verzendlabel-dry-run starten vóór de DNS-swap, niet in het magazijn op de cutover-ochtend. De barcodebreedte was het enige echte incident en het enige wat we niet daadwerkelijk onder load hadden getest.

De 3.800 herkomstpagina's ranken nog steeds. De Exact-sync draait om 04:00 in plaats van 16:00 en is in 90 seconden klaar. De spreadsheet van de CFO met order-naar-factuur-mismatches heeft geen regels meer. De site is sneller op een telefoon in Hasselt dan hij was op een laptop op kantoor.

Toen we dit met de importeur bouwden, herhaalden we tegen onszelf dat de taak van de migratie was om onzichtbaar te zijn. Een klant die een zakje Konga van 250g koopt zou geen stackwissel moeten merken, alleen dat de pagina sneller laadt en de basket niet meer om 16:00 vastloopt. Diezelfde rekensom komt terug in het meeste legacy migratie-werk dat we oppakken. De Hydrogen-stack was hier de makkelijke helft. De saaie helft (URL-behoud, Exact rate limits, shadow-diff-triage) was waar de rekening vandaan kwam.

Kijk je deze week zelf naar een Joomla 3.x-site, dan is het goedkoopste wat je vandaag kunt doen: grep de access logs op de top 200 inbound URL's en hang ze aan een muur. Die lijst is de spec voor de zes weken werk die volgen.

Kern

Migreer door wekenlang verkeer te mirroren vóór de DNS-swap. De cutover-dag hoort saai te zijn, omdat alles al onder echte load is getest.

FAQ

Waarom Shopify en Sanity splitsen in plaats van één CMS gebruiken?

Shopify beheert transacties, voorraad en checkout. Sanity beheert long-form redactionele content zoals herkomstpagina's en batch-historie. Elke tool blijft op zijn eigen baan, en referenties leggen de link.

Hoe lang moet shadow traffic draaien vóór de DNS-swap?

Lang genoeg dat de dagelijkse diff queue je niet meer verrast. Voor deze migratie was dat ongeveer drie weken. We draaiden er zes omdat de klant de buffer wilde.

Wat is de grootste valkuil met de Exact Online API?

De rate limit is per app per administratie, niet per gebruiker. Parallelle workers verbranden de dagquota vóór de middag en falen daarna silent tot middernacht CET.

Kun je SEO behouden bij het verhuizen van 3.800 URL's van Joomla?

Ja, mits de URL-paden identiek blijven op de nieuwe stack en historische drift in redirects wordt opgevangen. We hebben 47 oude URL's via een Cloudflare-redirect-tabel afgevangen.

joomlamigrationlegacy sitese-commercecase studyarchitecture

Iets bouwen?

Start een project