Joomla
Joomla 3.10 naar Payload CMS: de shadow cutover van 7 weken
Een Leidse wetenschappelijke uitgever, 18.600 DOI's, vijftien jaar peer-review-historie en een Joomla 3.10-stack op geleende tijd. Zo verliep de cutover van zeven weken écht.

Het was een dinsdag in maart toen de hoofdredacteur ons een screenshot stuurde. In de Joomla 3.10 admin stond een gele banner die ze sinds 2023 negeerde, plus een verse mail van de hostingprovider: PHP 7.1 zou eind augustus stoppen met security backports. Het portaal draaide 18.600 actieve DOI-resolvers, een per-author peer-review-archief dat teruggaat tot 2011, en een nachtelijke job die metadata naar Crossref en DataCite duwde. Niets daarvan mocht een nacht overslaan.
De uitgever is een organisatie van 22 mensen in Leiden. Twee tijdschriften, vier boekenreeksen, een open-access-mandaat waar auditors op letten, en een redactieraad met mensen die je niet twee keer in een week wilt mailen. Ze hadden van een leverancier een offerte van zes cijfers gekregen voor een rebuild die wilde beginnen met het verwijderen van de DOI-tabel en het "herontwerpen van de URL-structuur". Wij waren het daar niet mee eens.
Dit is de feitelijke playbook van zeven weken die we hebben gedraaid. Namen veranderd, cijfers echt.
De randvoorwaarden die we op de muur schreven
Voor er één regel code geschreven werd, plakten we een vel A3 op de kantoormuur met vier regels erop:
- Elke DOI die vandaag resolvet moet morgen naar hetzelfde artikel resolveren, met dezelfde canonical URL.
- Geen enkel peer-review record gaat verloren; de keten van reviewer naar manuscript naar beslissing blijft bewaard met de originele timestamps.
- De nachtelijke deposit-jobs naar Crossref en DataCite blijven draaien. Geen gemiste nachten.
- Het redactieteam werkt elke dag van de migratie in het live-systeem.
Die vierde randvoorwaarde is degene die de meeste big-bang replatforms de das om doet. Vraag een klein redactieteam om twee weekenden lang te freezen en je krijgt een staking. Dit moest met shadow traffic.
Waarom Payload CMS, en niet een andere headless
Het portaal is content-zwaar maar niet content-simpel. Artikelen hebben geneste metadata (corresponding author, ORCID, funder, licentie, DOI-prefix, supplementair materiaal) en de redactionele workflow vereist custom collecties die niets weghebben van een blogpost. We hadden een CMS nodig waarin je de admin UI per collectie kunt vormgeven zonder per redacteur een SaaS-seat te kopen.
Payload draait als Node-service voor PostgreSQL of MongoDB. De admin is React, de datalaag is van jou, en access-control rules zijn TypeScript die netjes op de rolmatrix van de uitgever past (managing editor, section editor, copy editor, reviewer, author). We kozen Postgres voor de tijdschriftdata en lieten de file blobs op S3-compatible storage in Frankfurt staan voor AVG-rust. Next.js verzorgde de publieke leeszijde.
We hebben ook naar Strapi, Directus en een combinatie van Sanity-plus-Next gekeken. Doorslaggevend was dat Payload ons de admin UI, de deposit-jobs en de DOI-resolver in één repo met gedeelde types laat plaatsen. Voor een uitgever van 22 mensen betekent één repo één on-call rotatie en één deploy.
Week 1: de parallelle read replica
We zetten de Joomla-database als read-only replica op de nieuwe host neer en begonnen importscripts te schrijven tegen die replica, niet tegen productie. Het Joomla-schema is wat vijftien jaar plugin-auteurs achterlaten: de helft van de artikelinhoud in com_content, custom DOI-metadata in drie verschillende K2-extensietabellen, peer-review-historie in een eigenwijs jos_reviews-tabel dat de vorige developer in 2014 ontwierp.
We hebben niet geprobeerd het onderweg op te schonen. De eerste ingest-job was bewust dom: trek elke row, dump 'm in een legacy_*-schema op de nieuwe Postgres, nooit verwijderen. Opschonen is een tweede pass, geen eerste. Als een transform breekt, draai je 'm binnen seconden opnieuw tegen het legacy schema, niet tegen de live Joomla-site over een dunne VPN.
-- Postgres side
CREATE SCHEMA legacy;
-- Import script (Node, mysql2 -> pg)
INSERT INTO legacy.articles (id, raw, imported_at)
SELECT id, row_to_json(j.*), now()
FROM joomla_replica.jos_content j
ON CONFLICT (id) DO UPDATE
SET raw = EXCLUDED.raw, imported_at = now();
Op vrijdag van week één was de hele Joomla-contentboom als JSONB queryable op de nieuwe host. Het redactieteam had geen idee dat we bestonden.
Week 2: de DOI-resolver gaat als eerste
De DOI-resolver is de belangrijkste URL van de site. Crossref wijst naar https://publisher.example/doi/10.xxxxx/journal.2018.0142, en die URL moet voor altijd op de artikelpagina landen. Er zijn er 18.600 van, geïndexeerd door Google Scholar, geciteerd in PDF's, ingebed in de referentielijsten van andere tijdschriften.
We bouwden de nieuwe resolver als Next.js route handler voor we de artikelpagina bouwden. Waarom achterstevoren? Omdat de resolver het contract is. De artikelpagina mag van layout veranderen; de resolver mag niet van gedrag veranderen.
// app/doi/[...slug]/route.ts
export async function GET(req: Request, { params }) {
const doi = params.slug.join('/')
const target = await resolveDoi(doi) // hits Postgres, never Joomla
if (!target) return new Response('Not found', { status: 404 })
return Response.redirect(target.canonicalUrl, 301)
}
We lieten de nieuwe resolver een week tegen het legacy schema lopen, terwijl de oude Joomla-site nog leidend was. De shadow-check: voor elke inkomende DOI-request schoot de nieuwe resolver er parallel naast en logden we de diff. Vrijdag van week twee hadden we drie diffs, allemaal veroorzaakt door inconsistenties met trailing slashes in de oude Joomla rewrite rules. We hebben het oude gedrag gecodificeerd. We hebben het niet "gerepareerd".
Heeft je oude URL-structuur eigenaardigheden (trailing slashes, mixed case, dubbel-encoded tekens), behoud die eigenaardigheden. SEO en inkomende citaties zijn niet geïnteresseerd in schoonheid. Wel of ze resolveren.
Week 3: peer-review-historie, met timestamps intact
De reviews-tabel was het onderdeel waar niemand anders een offerte op had uitgebracht. Vijftien jaar reviewer_id, manuscript_id, decision, date, plus vrije reviewercommentaren in een Nederlands-Engelse mix, plus een aparte jos_reviews_files-tabel die naar PDF-bijlagen op het oude bestandssysteem wees.
Twee principes waar we strak aan vasthielden:
- Originele timestamps winnen. Nooit
now()bij import. Werd een review op 11-09-2014 om 22:47 CET ingediend, dan is dat haarcreated_atin de nieuwe database. - Reviewer-identiteit is heilig. De COPE-compliance van de uitgever hangt ervan af dat je kunt aantonen wie wat heeft beoordeeld, wanneer. We mapten reviewer-accounts één-op-één, met een
imported_reviewer_legacy_id-veld als fallback voor accounts zonder e-mailadres.
De collecties van Payload maakten het schema leesbaar op een manier die de oude PHP nooit was. De Reviews-collectie heeft expliciete relaties naar Manuscripts en Users, access controls die alleen de managing editor reviewer-identiteiten van double-blind inzendingen laten zien, en een hook die voorkomt dat wie dan ook (inclusief wij) een historische beslissingsrij muteert.
// collections/Reviews.ts
export const Reviews: CollectionConfig = {
slug: 'reviews',
access: {
read: ({ req }) => req.user?.role === 'managingEditor'
? true
: { reviewer: { equals: req.user?.id } },
update: ({ req, id }) => !isHistorical(id, req),
},
fields: [
{ name: 'manuscript', type: 'relationship', relationTo: 'manuscripts', required: true },
{ name: 'reviewer', type: 'relationship', relationTo: 'users', required: true },
{ name: 'decision', type: 'select', options: ['accept','minor','major','reject'] },
{ name: 'submittedAt', type: 'date', required: true },
{ name: 'legacyId', type: 'number', admin: { readOnly: true } },
],
}
Week 4: de deposit-jobs naar Crossref en DataCite
De uitgever deponeerde al ruim tien jaar metadata bij Crossref via direct XML deposit. De DataCite-jobs waren jonger, toegevoegd in 2019 voor een datasetserie waarvoor de DataCite REST API nodig was. Beide draaiden 's nachts om 02:30 vanuit een cron op de oude VPS. Beide werkten. Geen van beide was gedocumenteerd.
We deden drie dingen, in deze volgorde:
- De daadwerkelijke XML lezen die het oude systeem produceerde. Scheelde een week giswerk.
- De deposit opnieuw geïmplementeerd als scheduled job in Payload die hetzelfde Crossref deposit endpoint aanspreekt, met identieke credentials, tegen de nieuwe Postgres-data.
- Beide jobs tien nachten parallel laten draaien, output-XML gediffed, de vier velden gefixt waar het oude systeem stilletjes author affiliations afkapte.
De nieuwe job draait als één Node-script in dezelfde repo als het CMS. Geen aparte cron-VPS, geen shell scripts op een server waar niemand meer in kan SSH'en. pnpm tsx scripts/deposit-nightly.ts op een scheduled task in de UI van de hostingprovider.
Week 5: shadow traffic op de leeszijde
Tegen week vijf kon de nieuwe Next.js front-end elk artikel renderen. Het redactieteam werkte nog in Joomla. We stuurden een fractie van het inkomende verkeer, 5% via een Cloudflare worker rule, naar de nieuwe site, met een header die indexering onderdrukte en een banner met "Preview van het nieuwe portaal. De klassieke site staat op het oorspronkelijke adres."
Wat we in de gaten hielden:
- Server-Timing headers van de nieuwe site tegenover de oude. De nieuwe mediaan zakte van 1,4s naar 280ms, vooral omdat Joomla bij elke request een uitgelogde menustructuur renderde.
- De hit rate van de DOI-resolver. We verwachtten dat ~3% van het inkomende verkeer DOI-traffic was. Het bleek 11%, voornamelijk vanuit Google Scholar.
- Feedback van de redactie. Drie van de vier section editors logden in de nieuwe admin in om hem uit te proberen. Twee vonden een bug in het formulier voor manuscriptverdeling. Die hebben we vóór week zes gefixt.
Week 6: het redactieteam gaat over
Dit is de week waarin de vierde randvoorwaarde op de muur zichzelf terugverdiende. We hebben niemand gevraagd om te "freezen en migreren". We vroegen de managing editor om vanaf maandag nieuwe manuscriptinzendingen in de nieuwe Payload-admin te starten. De oude Joomla-admin bleef read-write voor actieve manuscripten die al midden in de review zaten.
Twee weken lang draaiden de systemen parallel voor redactioneel werk. Elke nacht trok een sync-job wijzigingen uit de oude jos_*-tabellen naar de nieuwe Postgres, vlagde alles wat op beide plekken was bewerkt (drie zulke conflicten in de hele periode; eentje was een typo-correctie in een author bio) en duwde de samenvoeging door de deposit-pipeline.
Week 7: cutover, en de oude box blijft staan
De daadwerkelijke cutover was een DNS-wijziging op zaterdagochtend. De verdeling van shadow traffic ging van 5% naar 100%. De DOI-resolver had de nieuwe app op dat moment al vijf weken bediend; in dat codepad veranderde bij cutover niets. De jobs naar Crossref en DataCite draaiden al op Postgres. De launch voelde saai aan, en dat is precies wat je wilt.
We lieten de oude Joomla-VPS negentig dagen read-only staan. Twee keer in die negentig dagen hadden we 'm nodig, beide keren om de originele ruwe HTML van een lang redactioneel stuk te verifiëren dat tijdens de JSONB-naar-Markdown-pass een verdwaald formatteerteken had opgepikt. Beide keren duurde het vijf minuten.
Wat we anders zouden doen
Twee dingen. Eén: we hebben de kosten van het opnieuw opbouwen van de indices op de nieuwe Postgres onderschat. De full-text search op vijftien jaar artikelinhoud vroeg om een tsvector-kolom en een GIN-index waar we niet op hadden begroot. Kostte ons een dag in week vijf. Twee: we hadden de hoofdredacteur in week één om de deposit-credentials moeten vragen. We moesten ze in week vier uit een oude phpMyAdmin-sessie opvissen, precies het soort bus factor-moment dat je wilt voorkomen.
Het werk zelf, een verouderd publishing-portaal migreren van PHP 7.1 af zonder één DOI te laten vallen, is niet glamoureus. Het is om 23:00 SQL van iemand anders lezen en bewust kiezen om de delen die al werken niet te "verbeteren". Toen we de deposit-pipeline voor de Leidse uitgever bouwden, liepen we ertegenaan dat het oudere direct-deposit endpoint van Crossref XML stilletjes accepteert die zijn eigen schema niet haalt; we hebben dat opgelost door zelf vóór elke push tegen het schema te valideren, wat in de eerste maand twee misvormde records ving.
Kijk je naar een Joomla 3.10-site en een agenda vol PHP-deprecation-mails, dan is het kleinste nuttige dat je vandaag kunt doen: open je nachtelijke cron-lijst en schrijf op wat elke job feitelijk doet. Dat document is meer waard dan elk architectuurdiagram dat je later nog tekent.
Kern
Een cutover moet een non-event zijn. Verhuis eerst de DOI-resolver, draai weken shadow traffic, en zet het redactieteam nooit op slot.
FAQ
Hoe behoud je DOI's tijdens een CMS-migratie zonder inkomende citaties te breken?
Bouw eerst de nieuwe resolver, laat hem minstens een week parallel tegen de legacy database lopen, en codificeer de eigenaardigheden van de oude URL's in plaats van ze te repareren. Trailing slashes en case sensitivity zijn belangrijker dan esthetiek.
Waarom Payload CMS boven Strapi of Directus voor een wetenschappelijke uitgever?
Payload zet de admin UI, de deposit-jobs en de DOI-resolver in één TypeScript-repo met gedeelde types en Postgres. Voor een klein redactieteam betekent dat één deploy, één on-call rotatie en access rules die op echte redactierollen passen.
Kun je echt een CMS-migratie draaien zonder content freeze?
Ja, met shadow traffic en een parallel-write venster. We hielden Joomla tot en met week zeven live voor redactioneel werk en synchroniseerden 's nachts. Drie conflicten in twee weken, allemaal triviaal. Geen freeze, geen staking vanuit de redactie.
Wat gaat er bij Crossref-deposit in de meeste migraties mis?
Het oudere direct-deposit endpoint van Crossref accepteert stilletjes XML die zijn eigen schema niet haalt. Valideer zelf tegen het schema voor je verstuurt. Anders kom je er maanden later achter als een citatiegraf-tool ontbrekende records flagt.