← Blog

Joomla

Joomla 1.5 naar Laravel 12: dual-write cutover in vijf weken

Een 19 jaar oude Joomla 1.5 portal, 6.200 actieve SSO-sessies, een iDEAL-verlenging die volgend kwartaal live moet. Dit is de dual-write cutover van vijf weken die alle drie in leven hield.

Jacob Molkenboer· Oprichter · A Brand New Company· 13 jun 2026· 10 min
Open groen leren logboek, koperen sleutel op crème kaart, rubberstempel met groen lint, rood waxfragment op ivoor papier.

De portal die we aantroffen

Maandagochtend, half januari. Een brancheorganisatie van 44 mensen in Hilversum heeft over negen dagen een bestuursvergadering. Het bestuur wil een helder antwoord over de ledenportal. Die portal draait op Joomla 1.5 op PHP 5.4, achter Apache 2.2 op een Debian 7-bak die sinds 2019 geen security-update meer heeft gehad. Het is ook de plek waar 6.200 actieve leden inloggen om de cao op te halen, te stemmen over beleid en hun lidmaatschap te verlengen via iDEAL.

Joomla 1.5 ging in september 2012 end of life. PHP 5.4 volgde in september 2015. Het maatwerk com_members-component daarbovenop draagt veertien jaar aan businessregels: pro rata verlengingsprijzen, geneste plaatsvervangerlidmaatschappen, het aparte factuurspoor voor de drie ereleden. Niets daarvan staat gedocumenteerd buiten de code.

Het bestuur wilde de portal moderniseren. De leden wilden dat hij niet stuk ging. We hadden vijf weken.

Waarom een lift-and-shift naar Joomla 5 geen optie was

De voor de hand liggende route is Joomla 1.5 naar Joomla 5. We hebben hem doorgerekend en zijn weggelopen. Drie redenen.

Ten eerste, de migrator. Er is geen first-party pad van 1.5 naar 5. Je gaat 1.5 naar 2.5 naar 3 naar 4 naar 5, elke stap een halfdood kerkhof van extensies. De meeste derde-partij components uit 2010 zijn dood. De overlevers willen nu een jaarabonnement en een vriendelijk gesprek over een onderhoudscontract.

Ten tweede, de backoffice. De penningmeester van een brancheorganisatie is geen Joomla-admin. Het is een boekhouder die een scherm wil dat eruitziet als Exact. Filament geeft haar dat scherm in een week. De Joomla-admin in 5 is nog steeds de Joomla-admin.

Ten derde, de datavorm. Verlengingspayloads van Mollie komen terug als JSON met geneste refund-objecten en chargeback-events. Postgres jsonb indexeert dat. MySQL 5.5 niet.

Dus het plan werd een rebuild. Laravel 12, Filament 4, Postgres 16, Mollie voor iDEAL, Cloudflare ervoor. De oude Joomla-portal bleef vijf weken naast de nieuwe draaien terwijl we het verkeer stuk voor stuk verplaatsten.

De shadow database

De eerste week was er niets dat een lid kon zien. We zetten Postgres naast de live MySQL en bouwden wat we de shadow noemden.

Elke write die de oude portal naar MySQL deed, moest binnen een seconde in Postgres landen. We probeerden twee paden. Een read replica met pg_chameleon die CDC deed tegen de binlog werkte, maar liep achter onder piekbelasting. We eindigden met een kleinere shim: een Laravel HTTP-endpoint, aangeroepen vanuit com_members via een dunne PHP-klasse die we in het bestaande component drukten.

// /joomla-root/components/com_members/helpers/shadow.php
class MembersShadow {
    public static function mirror($table, $row, $op = 'upsert') {
        $payload = json_encode(['t' => $table, 'r' => $row, 'op' => $op]);
        $ch = curl_init('https://shadow.portaal.internal/v1/mirror');
        curl_setopt_array($ch, [
            CURLOPT_POST           => 1,
            CURLOPT_POSTFIELDS     => $payload,
            CURLOPT_HTTPHEADER     => [
                'Content-Type: application/json',
                'X-Shadow-Token: ' . getenv('SHADOW_TOKEN'),
            ],
            CURLOPT_TIMEOUT_MS     => 250,
            CURLOPT_RETURNTRANSFER => 1,
        ]);
        curl_exec($ch);
        curl_close($ch);
        // Fire and forget. Drift is backfilled hourly.
    }
}

250 ms timeout. Fire and forget. Een nachtelijke job diffte MySQL tegen Postgres op de vier tabellen die ertoe deden (members, renewals, committee_seats, invoices) en speelde de diff opnieuw af. We accepteerden dat de twee stores voor korte vensters uit elkaar zouden lopen. Stille drift accepteerden we niet, dus de diff-job postte naar Slack zodra een rij-aantal de 0,1% delta passeerde.

De reconciliation-job draaide elke nacht om 03:00. Hij vergeleek rij-aantallen en een gehashte sample van payload-kolommen aan beide kanten, en bracht drie soorten drift naar boven: ontbrekende rijen in Postgres (de mirror-aanroep verdween in het netwerk), ontbrekende rijen in MySQL (per definitie onmogelijk, en precies daarom testten we erop) en waarde-mismatches op dezelfde primary key. De derde categorie sloeg in vijf weken twee keer aan. Beide keren ging het om verlengingsrecords die de legacy-code binnen één request twee keer muteerde zonder tussendoor te committen, zodat alleen de laatste write de shadow bereikte. We patchten de mirror om bij request shutdown te flushen in plaats van per aanroep, en de diff werd stil.

6.200 SSO-sessies in leven houden

De portal stond ook voor drie interne tools: een cao-archief, een commissiestemtool en een loonbenchmark-dashboard. Alle drie vertrouwden op een Joomla-sessiecookie. Als we die cookie wegknipten, zouden 6.200 mensen opnieuw moeten inloggen, precies in het lunchuur waarop ze allemaal hun mail lezen. Dat is de trigger voor een vijandige bestuursvergadering.

Dus Laravel moest vijf weken lang dezelfde sessie kunnen lezen die Joomla schreef. Twee onderdelen.

Eén. De cookienaam en het secret. Joomla leidt zijn sessiesleutel per host af. We bouwden die afleiding na in een Laravel-middleware en lazen jos_session via een read-only MySQL-connectie.

Twee. De brug. Nieuwe logins via Laravel schreven zowel naar de Joomla-sessietabel als naar de eigen sessie-store van Laravel. Oude logins bleven alleen naar Joomla schrijven. Beide apps lazen uit beide stores via een kleine guard:

// app/Auth/JoomlaBridgeGuard.php
public function user()
{
    if ($this->laravelSession()->has('member_id')) {
        return $this->resolve($this->laravelSession()->get('member_id'));
    }

    $joomlaCookie = request()->cookie($this->joomlaCookieName());
    if (! $joomlaCookie) {
        return null;
    }

    $row = DB::connection('joomla_mysql')
        ->table('jos_session')
        ->where('session_id', $joomlaCookie)
        ->where('time', '>', now()->subHours(4)->timestamp)
        ->first();

    return $row ? $this->resolve($row->userid) : null;
}

Wanneer een lid tijdens het cutover-venster een Laravel-pagina raakte, zag de guard de Joomla-cookie, vond de rij, hydrateerde een Laravel-user en zette een verse Laravel-cookie ernaast. Op geen enkel moment werden ze uitgelogd. We hebben het gemeten: 27 gedwongen re-logins over de volle vijf weken, allemaal van leden waarvan de browser de nieuwe cookie weigerde omdat third-party cookies geblokkeerd stonden in een same-origin context. Twee telefoontjes losten dat op.

We hebben de guard tien dagen tegen een kopie van productie-sessies getest voordat we er echte gebruikers op richtten. Het testharnas trok elke dag een snapshot van jos_session, speelde elke cookie door de Laravel-guard en controleerde of de geresolvede Laravel-user matchte met het Joomla-user-id. Daar vingen we één bug: een oudere Joomla-versie padde het session-id met achterloopse spaties die de Laravel-cookieparser eraf knipte, zodat een klein deel van de cookies niet meer resolveerde tot we het trim-gedrag aan de leeskant matchten. Zonder dat harnas zou die bug op dag één van de canary in productie opduiken, midden in de lunchpiek.

Waarschuwing

Als je oude portal HttpOnly op de sessiecookie zet maar geen SameSite, werkt de nieuwe app die dezelfde cookie leest prima in testing, en gaat een browserupdate midden in je cutover hem opeens droppen. Zet SameSite=Lax op de legacy-sessie een week voordat je de migratie start, niet tijdens. Wij leerden dat op dag drie van week twee.

De iDEAL-verlengingsflow tijdens de cutover

Verlengingen draaien op een Q1-piek. Ongeveer 70% van de 6.200 leden verlengt tussen half januari en eind maart. De brancheorganisatie gebruikt iDEAL omdat elke Nederlandse onderneming dat verwacht. De oude portal postte naar een legacy XML-endpoint. De nieuwe post naar de REST API van Mollie. We moesten de cutover doen midden in de piek.

De truc was om de verlengingsinitiator de gateway te laten kiezen op basis van één feature flag, en om webhooks van beide gateways veilig te laten landen, ongeacht welke kant op dat moment de waarheid bezat.

Vanaf week twee opende elke nieuwe verlengingssessie tegen Mollie. In-flight legacy-transacties (betaald maar nog niet via webhook bevestigd) bleven oplossen tegen de oude portal, die zowel naar MySQL als via de shadow naar Postgres schreef. We hebben nooit twee systemen gehad die dezelfde verlenging als betaald markeerden, omdat het verlengingsrecord vanaf week één een gateway-kolom droeg. De webhook-handler routeerde op die kolom.

// routes/webhooks.php
Route::post('/webhooks/payments/{gateway}', function (string $gateway, Request $r) {
    abort_unless(in_array($gateway, ['mollie', 'sisow']), 404);

    $renewalId = match ($gateway) {
        'mollie' => Mollie::resolveRenewalId($r->input('id')),
        'sisow'  => Sisow::resolveRenewalId($r->input('trxid')),
    };

    $renewal = Renewal::lockForUpdate()->find($renewalId);
    abort_if($renewal->gateway !== $gateway, 409, 'gateway mismatch');

    dispatch(new ConfirmRenewal($renewal, $gateway));
    return response()->noContent();
});

De 409 bij gateway-mismatch bleek de nuttigste regel van allemaal. Drie leden openden een Mollie-sessie, lieten 'm liggen en gingen vervolgens via een oud tabblad terug naar de oude portal om daar een legacy-sessie te starten. De 409 ving het netjes op. We factureerden degene die betaalde, en betaalden de ander terug.

Observability tijdens de cutover

Twee dashboards stonden vijf weken lang aan op een tv naast mijn bureau. Het eerste liet shadow-drift zien: rij-aantal-delta per tabel per minuut, met een rode lijn op 0,1%. Het tweede toonde de gateway-split: aantal verlengingssessies geopend tegen elke gateway en aantal webhooks opgelost, beide als een 15-minuten trailing average. Live counters bekijken slaat post-hoc logs lezen. De meeste kleine schommelingen die we in week drie vingen (Cloudflare-retries die een webhook verdubbelden, een Mollie-statusupdate die out of order binnenkwam) verschenen op die twee schermen voordat een alert afging.

Sentry deed de rest. We tagden elke exception met de cutover-fase (1 t/m 5) en het oppervlak (backoffice, ledenfront, webhook) zodat triage scherp bleef. De duurste bug die we gestuurd hebben was een ontbrekende index op members.email_lower die een query van 2 ms in een query van 600 ms veranderde zodra de canary 10% raakte. Het slow-query-rapport van Sentry bracht hem in twaalf minuten boven. De fix was één CREATE INDEX CONCURRENTLY op Postgres en een redeploy.

Het week-per-week schema

De vijf weken liepen in een strikte volgorde. Elke week verplaatste één lees- of schrijfoppervlak. Niets verplaatste twee keer in dezelfde week.

Week 1: shadow live, geen zichtbare verandering

Postgres naast MySQL. De mirror-shim die op elke write postte. Nachtelijke diff-job vijf nachten op rij groen voordat we iets anders aanraakten. Filament-backoffice alleen in staging.

Week 2: backoffice gaat over

De penningmeester, de secretaris en twee bestuursadmins logden in op Filament in plaats van op de Joomla-backend. De ledenkant nog 100% Joomla. Vanaf dit punt werden nieuwe iDEAL-verlengingen naar Mollie geleid.

Week 3: 10% canary

Cloudflare Workers splitsten het verkeer op een stabiele hash van het lidnummer. 10% van de leden werd door Laravel bediend op read-only pagina's (cao-archief, profiel, verlengingsgeschiedenis). Alle writes gingen nog via de brug de shadow in. We hielden Filament-audit logs in de gaten voor de backoffice en Sentry op de voorkant voor de rest.

Week 4: 100% lezen, Joomla bezit de writes

Elk lid las nu uit Laravel. Joomla bezat de writes nog om één specifieke reden: de commissiestemtool had een websocket op de oude bak waarvan de rebuild als laatste gepland stond. Die lieten we staan.

Week 5: writes klappen om, Joomla wordt read-only

De stemtool verhuisde op dinsdag. Joomla werd op woensdagavond om 22:00 read-only, met 24 uur aankondiging op het dashboard. Vrijdag ging de bak uit en wees DNS alleen nog naar de nieuwe stack.

Het terugval-plan dat we nooit gebruikten

In de eerste 14 dagen na de vrijdagcutover streamde een reverse-CDC job Postgres-writes terug naar MySQL. Als er iets zichtbaar fout was gegaan, hadden we DNS terug kunnen klappen naar de Joomla-bak en geen data verloren. We hebben hem nooit gebruikt. We lieten hem die volle twee weken draaien, omdat de dag waarop we hem afbraken altijd de dag zou worden waarop een bestuurslid iets ontdekte wat we gemist hadden.

Het lastige aan het terugpad was de schema-mismatch. Postgres droeg rijkere types dan MySQL zou accepteren: jsonb op verlengingspayloads, timestamptz op elke audit-kolom, een gegenereerde email_lower-kolom op members. De reverse CDC accepteerde een lossy view op die kolommen, plat van jsonb naar longtext, tijdzone weg, gegenereerde kolom helemaal overgeslagen. We lijstten elke lossy kolom op in het runbook, zodat, als we hadden moeten rollbacken, niemand de MySQL-kopie als gezaghebbend op die velden zou behandelen. Uitspellen wat de rollback wel en niet kon behouden, maakte de keuze om hem twee weken warm te laten staan verantwoord in plaats van paranoïde.

Er kwam niets boven. We hebben hem op dag 15 afgebroken.

Het draaiboek, afgepeld

Zet de nieuwe database naast de oude. Mirror writes vanaf dag één. Brug sessies op een guard, niet op een redirect. Route webhooks op een kolom die de verlenging al draagt. Verplaats oppervlakken één voor één, nooit twee in dezelfde week. Houd het terugpad twee weken na de cutover warm en weersta de drang om eerder te feestvieren.

Toen wij de ledenportal voor de brancheorganisatie herbouwden, was het stukje dat we onderschat hadden het cookie-gedrag midden in de cutover. We losten het op door SameSite=Lax op de legacy Joomla-sessie te zetten, een week voordat het verkeer ging bewegen. Dat soort detail leert een legacy migratie je alleen op de moeilijke manier. De dual-write architectuur zelf was het saaie deel. Saai was het doel.

Het kleinste wat je vandaag kunt doen, als je naar een Joomla 1.5 portal of een vergelijkbare oude stack zit te kijken: draai mysqldump --no-data erop en lees de foreign keys hardop voor. De helft van de businesslogica zit in de constraints, en je ziet hem op geen andere manier tot je hem op één pagina hebt staan.

Kern

Mirror writes vanaf dag één, brug sessies op een guard in plaats van op een redirect, en verplaats één oppervlak per week. Het saaie draaiboek levert op.

FAQ

Waarom Joomla 1.5 niet gewoon in-place upgraden naar Joomla 5?

Er bestaat geen direct pad. Je gaat 1.5 naar 2.5 naar 3 naar 4 naar 5, en op elke stap zijn de meeste derde-partij components dood. Herbouwen op een moderne stack was goedkoper dan vier opeenvolgende upgrades uitvechten.

Hoe hielden jullie 6.200 SSO-sessies in leven tijdens de cutover?

Een Laravel-guard las de oude Joomla-sessietabel via een read-only MySQL-connectie, de volle vijf weken lang. Leden droegen beide cookies tot de nieuwe het overnam. In totaal 27 gedwongen re-logins.

Waarom Postgres in plaats van bij MySQL blijven?

De verlengingspayloads van Mollie zijn geneste JSON met refund- en chargeback-objecten. Postgres jsonb indexeert ze. MySQL 5.5 kan dat niet, en de backoffice-rapportages hadden die indexes nodig.

Hadden jullie een rollback klaar als de cutover misging?

Ja. Veertien dagen na de cutover streamde een reverse-CDC job Postgres-writes terug naar MySQL. We hebben hem nooit gebruikt, maar lieten hem toch draaien. Op dag 15 afgebroken.

Hoe voorkwamen jullie dubbele afschrijvingen tijdens de wissel van betaal-gateway?

Elk verlengingsrecord droeg vanaf week één een gateway-kolom. De webhook-handler routeerde op die kolom en gaf 409 bij mismatch, zodat een oud tabblad op de oude gateway niet kon botsen met een nieuwe sessie.

joomlaphpmigrationlegacy sitesarchitecturemysql

Iets bouwen?

Start een project