Migration
Joomla naar WordPress: een halfafgemaakte migratie redden
We namen een vastgelopen Joomla 3.10 naar WordPress migratie over voor een Nederlandse vakbond met 28.000 leden. Dit vonden we, dit leverden we op, en de 16 minuten koud zweet bij de cutover.

De overdrachtsmeeting duurde 23 minuten. Een gedeelde Drive-map, drie halfafgemaakte Trello-kaarten, en één zin die de toon zette voor de volgende acht weken: "De member login is een beetje wisselvallig, maar het meeste werkt wel." De klant was een Nederlandse vakbond met 28.000 actieve leden. De vorige partij had na drie maanden de stekker uit de migratie getrokken. Joomla 3.10 was toen al voorbij zijn end-of-life support window (augustus 2023). De WordPress staging-omgeving had een half geïmporteerde artikelenboom. De ledenlogin zat in een 'werkt op dinsdag'-staat.
Dit is wat we aantroffen, wat we in de code vonden, en wat we hebben opgeleverd. De structuur is bruikbaar voor elk team dat een vastgelopen CMS-migratie overneemt.
Hoe de erfenis er echt uitzag
Twee repo's. De live Joomla 3.10 site, zes weken eerder voor het laatst aangeraakt in productie. Een WordPress 6.4 staging build met de FG Joomla to WordPress plugin één keer gedraaid en daarna verlaten. Artikelen geïmporteerd, categorieën platgeslagen in de verkeerde hiërarchie, mediabibliotheek miste ongeveer 40% van de bijlagen omdat de plugin halverwege was getimed-out.
Het plan van de vorige partij, gereconstrueerd uit één enkel architectuurdiagram in de Drive-map, was:
- Content overzetten met FG Joomla to WordPress.
- De member area herbouwen als custom WordPress plugin.
- De SSO 'bridgen' met het aparte ticketingsysteem van de bond op een Laravel backend.
Stap 1 was slecht uitgevoerd. Stap 2 was begonnen en bestond uit 30% van een plugin zonder admin-UI. Stap 3 had een stub-controller en een TODO-comment.
Het eerste wat we deden was geen code schrijven. We hebben twee dagen besteed aan het doorlopen van de live Joomla site als gebruiker, in drie rollen: anonieme bezoeker, betalend lid, afdelingsbeheerder. Die walkthrough legde acht workflows bloot die het diagram van de vorige partij niet noemde, waaronder een meldingsflow voor betalingsachterstand gekoppeld aan storno's op automatische incasso, en een moderatiequeue op afdelingsniveau die door 14 regionale coördinatoren werd gebruikt. Waren we begonnen met coderen op basis van het diagram, dan hadden we op dag één een site opgeleverd die de regionale structuur van de bond brak.
De ongedocumenteerde lidmaatschapscomponent
De custom 'membership module' bleek een volledige Joomla component te zijn, geen module. Ongeveer 11.000 regels PHP verspreid over /components/com_lidmaatschap/ en /administrator/components/com_lidmaatschap/. Zes MySQL-tabellen met prefix jos_lid_. Geen README, geen migrations-map, geen comments boven de lastige stukken. De oorspronkelijke auteur had het bureau dat het had gebouwd in 2019 verlaten.
We begonnen met het in kaart brengen van het schema. Een handige one-liner als je dit ooit doet:
mysql -u root -p joomla_prod \
-e "SELECT TABLE_NAME, TABLE_ROWS FROM information_schema.TABLES \
WHERE TABLE_SCHEMA='joomla_prod' AND TABLE_NAME LIKE 'jos_lid_%';"
Dat gaf ons rij-aantallen. De interessante was jos_lid_subscriptions met 41.883 rijen voor 28.000 leden, wat betekende dat óf er historische data was bewaard, óf iemand bij verlenging dubbele records inschoot. Beide bleken waar. Ongeveer 4.200 van die rijen waren duplicaten van een cron uit 2021 die zes weken lang twee keer per nacht had gedraaid voordat iemand het doorhad.
Om foreign keys in kaart te brengen zonder schemadiagram hebben we er één gegenereerd uit de output van SHOW CREATE TABLE en die door dbdiagram.io gehaald. Dat leverde ons binnen een middag een plaatje op.
De component had vier entry points die de moeite waard waren om te kennen: een frontend controller die de leden-profielpagina's bediende, een backend admin-view die leden toonde in een tabel met 12 kolommen, een cron job op /cli/lid_renewals.php die 's nachts draaide en naar de incassoprovider postte, en een REST-achtige endpoint op ?option=com_lidmaatschap&task=check_membership&format=json die het Laravel ticketingsysteem gebruikte om de status van een lid te verifiëren voordat een case werd geopend.
Die laatste was de SSO. Een soort van.
De SSO die eigenlijk geen SSO was
Wat de bond 'SSO' noemde, was een truc met session-cookies. Nadat een lid was ingelogd in Joomla zette een custom plugin een tweede cookie genaamd union_uid op het parent domain, ondertekend met HMAC-SHA1 en een secret die was opgeslagen in een PHP constant. De Laravel ticketing-app op een subdomein las die cookie, valideerde de signature, en vertrouwde de gebruiker. Geen OAuth, geen SAML, geen token refresh. Eén gedeelde secret in twee repo's.
Het 'wisselvallige' gedrag dat de klant noemde had drie oorzaken, op volgorde van hoeveel haar we erbij verloren. Ten eerste werd de cookie gezet op www.union.nl in plaats van op .union.nl, waardoor hij het ticketing-subdomein nooit bereikte, tenzij de gebruiker de URL met www-prefix intypte. Ongeveer 60% van de leden deed dat niet. Ten tweede gebruikte de signature PHP's hash_hmac met de standaard raw_output=false, terwijl de Laravel-kant decodeerde als binary. De helft van de tijd matchten de strings puur door toevallige base64-alfabet-botsingen. Ten derde was de secret hard-coded op twee plekken die in 2022 uit elkaar waren gaan lopen, toen de Laravel-app voor het laatst was herbouwd. Niets hiervan stond in de documentatie, want er was geen documentatie. We kwamen erachter door de cookie te volgen met Chrome DevTools en de Laravel-middleware regel voor regel te lezen.
Als een klant zegt 'de SSO is een beetje wisselvallig', ga er dan vanuit dat het geen SSO is. Het is bijna altijd een gedeelde cookie met een secret in een constant. Vind eerst de secret, plan dan de herbouw.
Het herbouwplan dat we daadwerkelijk opleverden
We hebben het plan van de vorige partij om de lidmaatschapscomponent te herschrijven als WordPress plugin verworpen. Met 11.000 regels business logic, zes tabellen, een actieve cron, en een incasso-integratie gekoppeld aan specifieke bankbestandsformaten, was herschrijven drie tot vier maanden werk en een hoog risico op het breken van de verlengingsflow. Verlengingen waren de omzet van de bond. Verlengingen breek je niet.
In plaats daarvan splitsten we het systeem in tweeën. WordPress nam de publieke site voor zijn rekening: content, nieuws, afdelingspagina's, statische ledenpagina's. De imports hebben we opnieuw schoon opgebouwd met een custom WP-CLI script in plaats van de FG plugin, omdat die plugin de afdeling-naar-auteur mapping die we nodig hadden niet kon behouden.
De lidmaatschapscomponent verhuisde naar een kleine standalone PHP 8.2 service achter members.union.nl, waarbij we de oorspronkelijke tabellen, de cron, en de incasso-integratie ongewijzigd hergebruikten. We hebben wel de vier entry points herschreven als een schone Slim 4 app, maar de kern van de business logic bleef staan. Die beslissing scheelde ongeveer tien weken en haalde de verlengingsflow volledig uit het risicovlak.
SSO werd echte SSO. We hebben één OAuth 2.0 provider opgezet op de members-service, met zowel de WordPress site als de Laravel ticketing-app als geregistreerde clients. Aan de providerkant gebruikten we league/oauth2-server, want die is saai, goed onderhouden, en geaudit. De WordPress-kant draait op een kleine custom plugin die inhaakt op de wp_authenticate action en het OAuth user-id opslaat in wp_usermeta.
Bij de migratie van credentials hebben we niet geprobeerd wachtwoorden te behouden. Joomla 3 gebruikt bcrypt en modern WordPress ook, maar de user identifier en salt-grenzen verschillen genoeg dat een verificatie-shim meer kost dan hij oplevert. We hebben elk actief lid een eenmalige wachtwoord-reset link gemaild, gespreid over vier dagen, met een terugvallijn 'reset per telefoon' voor de oudere leden die geen e-mail checken. Het callcenter van de bond handelde 412 reset-calls af in dat venster, wat ze hadden ingecalculeerd.
Hier is de verify-and-link functie die we aan de WordPress-kant gebruikten zodra een lid voor het eerst de OAuth-login had voltooid:
add_action('oauth_callback_verified', function (array $token) {
$remote_uid = (int) $token['sub'];
$email = sanitize_email($token['email']);
$user = get_user_by('email', $email);
if (!$user) {
$user_id = wp_insert_user([
'user_login' => $email,
'user_email' => $email,
'user_pass' => wp_generate_password(32, true, true),
'role' => 'member',
]);
$user = get_user_by('id', $user_id);
}
update_user_meta($user->ID, 'union_remote_uid', $remote_uid);
wp_set_auth_cookie($user->ID, true);
}, 10, 1);
Kort, saai, makkelijk te auditen. Dat was het hele punt.
De cutover-nacht en het ene ding dat bijna brak
De cutover liep op een zondag om 02:00 Amsterdam. We hadden 90 minuten voordat de cron om 03:30 de incassobatch voor de komende woensdag zou afvuren. Die batch missen betekende dat 1.400 leden niet op tijd geïncasseerd zouden worden, wat betekende dat de bond handmatig facturen zou moeten versturen, wat betekende dat we nooit meer gevraagd zouden worden.
De DNS-flip ging schoon. De OAuth-handshake werkte in één keer. WordPress serveerde de nieuwe site in 340ms mediaan. We controleerden de health endpoint van de members-service, groen. We controleerden het cron schedule, groen. Om 03:31 deden we een tail op de cron-log.
Niets.
De cron was niet afgevuurd. Bij de nieuwe PHP 8.2 service ontbrak de systemd timer die we op de staging box hadden geconfigureerd en vergeten waren toe te voegen aan de productie-Ansible playbook. We hebben het timer-bestand met de hand geschreven, ingeschakeld, handmatig getriggerd, en gekeken hoe 1.400 SEPA-incasso-opdrachten om 03:47 in de SFTP-inbox van de bank landden. Zestien minuten koud zweet.
De les is die waarmee elk migratieverhaal eindigt en die iedereen de volgende keer vergeet. Behandel het cron schedule als een first-class artefact. Zet het in de repo, in de playbook, in de runbook, en op de checklist. Diff het tegen productie vóór de cutover. 'Zorg dat cron nog werkt' is geen taak. 'Diff /etc/cron.d en systemd timers tussen staging en prod, voeg de diff toe aan het cutover-ticket' is een taak.
Wat we anders zouden doen
Drie dingen, op volgorde. We zouden drie dagen besteden, niet twee, aan het doorlopen van de live site als echte gebruiker, voordat we ook maar één regel code schreven. De afdeling-moderatieflow die we misten kostte een week aan herwerk. We zouden de members-service eerst bouwen en daarna pas WordPress er bovenop migreren. We deden het parallel en de twee teams struikelden continu over aannames in het schema. Sequentieel was op papier langzamer geweest en in de praktijk sneller. En we zouden de cron-audit inplannen als een apart ticket met een eigenaar, niet als regeltje op een checklist.
De audit van vijf minuten die je vandaag kunt doen
Toen we deze legacy migratie voor de bond opnieuw opbouwden, was het wisselen van framework nooit het lastigste deel. Dat was de ongedocumenteerde business logic die eronder zat, en de enige weg erdoorheen was elke regel ervan lezen voordat we ook maar iets aanraakten.
Zit je vandaag op een vastgelopen migratie, open de live site dan in een incognito-venster, log in als een echte gebruiker, en schrijf elke workflow op die je in de eerste tien minuten aanraakt. Die lijst is je echte migratiescope. Wat het plan van de vorige partij ook zegt, jouw lijst is de waarheid.
Kern
Als een klant zegt dat de SSO een beetje wisselvallig is, ga er dan vanuit dat het geen SSO is. Het is bijna altijd een gedeelde cookie met een secret in een constant.
FAQ
Hoe lang duurt een Joomla 3 naar WordPress migratie meestal?
Voor een content-only site twee tot vier weken. Voor een site met custom componenten, member areas of betalingsflows plan je drie tot zes maanden in, en budgetteer je de audit van de legacy code als aparte fase voordat er ook maar iets wordt herschreven.
Kun je gebruikerswachtwoorden behouden van Joomla naar WordPress?
Technisch wel, via een custom check_password filter, maar bij elke niet-triviale migratie is de schonere route een eenmalige wachtwoord-reset mail naar elke actieve gebruiker. Dat haalt een hele klasse aan edge cases weg.
Is de FG Joomla to WordPress plugin op zichzelf voldoende?
Voor artikelen, categorieën en basisgebruikers vaak wel. Voor custom componenten, ledendata, afdelingshiërarchieën of media-zware sites niet. Plan een custom WP-CLI script in voor alles wat de plugin niet aankan.
Wat gebeurt er als we Joomla 3.10 in productie laten staan?
Joomla 3 heeft in augustus 2023 zijn end-of-life bereikt. Er worden geen security patches meer uitgebracht. Elke onopgeloste CVE die sindsdien is gepubliceerd staat op je server totdat je migreert of upgradet naar een ondersteunde major versie.