← Blog

Drupal

Drupal 7 naar Astro en Directus: 4.600 URL's migreren

De archivaris controleert elk kwartaal de Archiefwet-metadata. Drupal 7 was end of life. We hadden tien weken, 4.600 geïndexeerde URL's, en geen ruimte om één bewaartermijn-veld te missen.

Jacob Molkenboer· Oprichter · A Brand New Company· 12 jun 2026· 9 min
Leren logboek, messing sleutel op kaart, ijzeren labels met touw, groen lint, rood lakfragment op ivoorpapier.

De archivaris op het Haagse kantoor van onze klant draait elke drie maanden dezelfde query. Ze opent het publicatieportaal, pakt tien willekeurige rapporten van het laatste kwartaal en controleert elk rapport op een compleet Archiefwet-metadatablok. Bewaartermijn. Vernietigingsdatum. Dossier-id. Classificatie. Als er één veld leeg is, faalt de audit en moet ze escaleren. Het portaal draait op Drupal 7.

Drupal 7 bereikte op 5 januari 2025 het einde van zijn levenscyclus. Geen core-securityupdates meer. Geen toezeggingen meer van het Drupal Security Team. Onze klant, een gemeentelijk adviesbureau van 22 mensen dat adviseert over wonen en sociaal beleid, draaide hun publicatieportaal er sinds 2014 op. Ze publiceren ongeveer 80 rapporten per jaar. Andere gemeentesites, journalisten en een handvol academische onderzoekers linken naar zo'n 4.600 van die URL's. Geen van die links mag breken.

De opdracht in één alinea

Weg van Drupal 7. Behoud elke publieke URL. Behoud elk bewaarveld op elke publicatie, want de archivaris controleert ze elk kwartaal en haar audit voedt het Archiefwet-compliancerapport voor de gemeente. Geef haar een flow die ze herkent. In tien weken. Zonder dat de redacteuren een nieuwe woordenschat hoeven te leren.

Waarom we Astro en Directus kozen

Het voor de hand liggende antwoord was Drupal 10. We schreven het in de eerste week af. Het contentmodel van de klant is in wezen één content type ('publicatie') met een handvol taxonomy-termen. Ze gebruikten geen Views, Panels of de andere onderdelen waar Drupal zijn complexiteit aan ontleent. Bewerken in D7 was al jaren een bron van wekelijkse supporttickets. Een upgrade naar D10 zou die pijn behouden en er twee maanden module-voor-module triage bovenop hebben gelegd.

Dus splitsten we het systeem in tweeën. Een statische frontend op Astro, omdat het portaal een paar keer per week wordt bijgewerkt en aan de publieke kant geen ingelogde gebruikers heeft. Een headless CMS op Directus, omdat het op Postgres draait, de admin-UI eruitziet en aanvoelt als een spreadsheet, en we de Archiefwet-bewaarvelden konden modelleren als echte, getypeerde kolommen in plaats van een Drupal Field Collection.

De 4.600 URL's inventariseren voordat we code aanraakten

Voordat we één regel migratiecode schreven, bouwden we drie onafhankelijke lijsten van de URL's die we moesten behouden. Ze weken van elkaar af, en dat is precies de reden waarom we er drie maakten.

  1. Een volledige crawl van de live site met wget --mirror, gefilterd op HTML-responses.
  2. De Drupal-sitemap.xml zoals de XML Sitemap-module die uitspoog.
  3. Een dump van de node- en url_alias-tabellen rechtstreeks uit MySQL.
drush sqlq "SELECT n.nid, n.title, n.type, n.status, n.created, ua.alias \
FROM node n LEFT JOIN url_alias ua ON ua.source = CONCAT('node/', n.nid) \
WHERE n.type = 'publicatie' ORDER BY n.created DESC" \
  > publications.tsv

De drie lijsten weken op verhelderende manieren van elkaar af. De crawl vond 4.612 URL's. De sitemap had er 4.587. De database had 4.654 gepubliceerde nodes. Het gat zat in de gebruikelijke verdachten: nodes die gepubliceerd waren zonder menu-entry, aliassen die naar ongepubliceerde revisies wezen, en een reeks van 41 rapporten uit 2017 die een oude redacteur als 'promoted to front page' had gemarkeerd maar nooit daadwerkelijk had gepubliceerd. We legden elk verschil voor aan de hoofdredacteur voordat we begonnen te bouwen. Dat gesprek kostte een middag en bespaarde ons een week brandblussen na de launch.

Archiefwet-bewaartermijnen als eersteklas schema

In Drupal 7 zat de bewaarmetadata in een Field Collection die aan de publicatienode hing. Dat betekende vijf extra databasetabellen, een wonderlijke saveflow, en geen enkele manier om af te dwingen dat een van de velden was ingevuld. De kwartaalaudit van de archivaris bestond juist omdat het systeem niet vertrouwd kon worden om zijn eigen regels te handhaven.

In Directus modelleerden we het bewaarblok als kolommen op de publications-collectie:

ALTER TABLE publications
  ADD COLUMN retention_years   integer       NOT NULL,
  ADD COLUMN destruction_date  date          GENERATED ALWAYS AS
    ((published_at::date) + (retention_years || ' years')::interval) STORED,
  ADD COLUMN dossier_id        varchar(32)   NOT NULL,
  ADD COLUMN classification    varchar(16)   NOT NULL
    CHECK (classification IN ('openbaar','intern','vertrouwelijk')),
  ADD COLUMN legal_basis       text          NOT NULL,
  ADD COLUMN last_audited_at   timestamptz;

Elk veld NOT NULL. De vernietigingsdatum gegenereerd, niet ingevoerd, zodat hij niet uit de pas kan lopen met de publicatiedatum. De classificatie beperkt tot drie waarden, in het Nederlands, want dat is de woordenschat die de archivaris gebruikt in haar auditchecklist. De audit-timestamp wordt bijgewerkt door een Directus Flow zodra ze het 'gecontroleerd'-vinkje in de admin aanvinkt.

Kern

Als het vorige systeem een kwartaalcontrole door een mens nodig had om zijn regels af te dwingen, heeft het nieuwe systeem dat pas echt vervangen wanneer die regels in het schema zelf leven.

De ETL-pipeline van Drupal-nodes naar Directus-items

Eén Node-script. Lezen uit de D7-MySQL-database. Schrijven naar Directus via de officiële SDK. De interessante delen waren de rommelige stukken: PDF-bijlagen opgeslagen onder sites/default/files/, taxonomy-termen die drie keer waren hernoemd, en een vrije-tekst-'onderwerp'-veld dat één redacteur sinds 2019 als opmerkingenveld gebruikte.

import { createDirectus, rest, createItem, uploadFiles } from '@directus/sdk'
import mysql from 'mysql2/promise'
import { readFile } from 'node:fs/promises'

const directus = createDirectus(process.env.DIRECTUS_URL!).with(rest())
const db = await mysql.createConnection(process.env.D7_DSN!)

const [rows] = await db.execute<any[]>(`
  SELECT n.nid, n.title, n.created, n.status,
         fb.field_bewaartermijn_value  AS retention_years,
         fd.field_dossier_value        AS dossier_id,
         fc.field_classificatie_value  AS classification
  FROM   node n
  LEFT JOIN field_data_field_bewaartermijn   fb ON fb.entity_id = n.nid
  LEFT JOIN field_data_field_dossier         fd ON fd.entity_id = n.nid
  LEFT JOIN field_data_field_classificatie   fc ON fc.entity_id = n.nid
  WHERE  n.type = 'publicatie' AND n.status = 1
`)

for (const r of rows) {
  const pdf  = await readFile(`./d7-files/publicaties/${r.nid}.pdf`)
  const file = await directus.request(uploadFiles(pdfFormData(pdf, r.nid)))
  await directus.request(createItem('publications', {
    legacy_nid:      r.nid,
    title:           r.title,
    published_at:    new Date(r.created * 1000).toISOString(),
    retention_years: Number(r.retention_years),
    dossier_id:      r.dossier_id,
    classification:  mapClassification(r.classification),
    legal_basis:     'Archiefwet 1995, art. 3',
    pdf:             file.id,
  }))
}

De volledige run op een laptop duurde net geen zes uur, vooral door de PDF-uploadstap. We draaiden hem drie keer tegen een staging-Directus-instantie voordat we het migratieweekend ingingen. Elke run leverde een CSV-diff op van velden die sinds de vorige run waren veranderd. De hoofdredacteur keek die door voordat we de volgende ronde startten.

URL-behoud en de 301-tabel

Drie URL-vormen uit D7 moesten blijven werken:

  • /publicaties/woonzorg-onderzoek-2018 (Pathauto-alias)
  • /node/1234 (canonieke Drupal-URL)
  • /taxonomy/term/56 (onderwerp-listingpagina's)

Het nieuwe portaal gebruikt /publicaties/{slug} voor rapporten en /onderwerpen/{slug} voor onderwerpen. We exporteerden de inventarisatie uit stap drie als een Vercel-redirectsconfig, met de legacy nid opgeslagen als Directus-kolom zodat middleware kan terugvallen op een database-lookup voor alles wat de statische config mist.

{
  "redirects": [
    { "source": "/publicaties/woonzorg-onderzoek-2018",
      "destination": "/publicaties/woonzorg-onderzoek-2018",
      "permanent": true },
    { "source": "/node/1234",
      "destination": "/publicaties/woonzorg-onderzoek-2018",
      "permanent": true },
    { "source": "/taxonomy/term/56",
      "destination": "/onderwerpen/wonen",
      "permanent": true }
  ]
}

4.612 entries in het bestand. Vercel verwerkt het zonder klagen. De catch-all-middleware voor /node/:nid raakt Directus alleen bij een config-miss, en dat gebeurde in de eerste maand na de launch negen keer, allemaal voor nodes waar van buiten de site nooit naartoe was gelinkt.

De kwartaalexport van de archivaris

Dit was de onuitgesproken eis die het project bijna brak. De archivaris wilde geen nieuwe tool leren. Ze wilde elk kwartaal een CSV in haar inbox, in dezelfde vorm als die ze uit de oude Drupal Views Bulk Operations kreeg. Dus bouwden we een Directus Flow die draait op de eerste maandag van januari, april, juli en oktober. Hij haalt elke publicatie op, filtert de exemplaren eruit die de vernietigingsdatum al voorbij zijn, en mailt de CSV naar haar en haar manager.

Ze kan nog steeds in Directus inloggen om individuele records steekproefsgewijs te controleren. In de zes maanden sinds de launch heeft ze dat twee keer gedaan, beide keren omdat een collega haar een specifieke vraag stelde. De audit zelf gebeurt nu in haar e-mailclient.

Het cut-overweekend

Vrijdag 17:00 bevroren we bewerkingen op de D7-site door de redacteursrol op alleen-lezen te zetten. De laatste ETL-run startte om 17:15 en eindigde om 22:40. Vrijdag 20:00 zetten we de DNS TTL op 60 seconden. Zaterdag 09:00 wisselden we het A-record naar Vercel. Zaterdag 10:00 dienden we de sitemap opnieuw in bij Google Search Console en pingden we Bing. De oude Drupal-site bleef twee weken in alleen-lezen-modus online op d7.[client-domain] als fallback. Niemand had hem nodig. De hoofdredacteur sliep beter omdat hij bestond.

Maandagmiddag had de archivaris een testexport ontvangen uit de staging-Flow. Ze vergeleek hem met de laatste D7-export, vond één kolomkop in een andere volgorde, vroeg ons die te wisselen en gaf akkoord.

Wat we na de launch hebben aangepast

Drie dingen braken op manieren die we niet hadden voorzien. De Algolia-zoekindex had een tweede ronde nodig omdat we Drupals <p>&nbsp;</p>-artefacten niet uit de bodytekst hadden gestript, waardoor elk rapport in de zoekresultaten leek te beginnen met een lege alinea. De kwartaal-Flow-mail belandde de eerste keer dat hij draaide in de 'automatisch'-map van de archivaris, die ze nooit leest. We voegden haar manager toe als CC zodat het bericht een menselijke ontvanger had. De Astro-build op 4.600 pagina's duurde 92 seconden. Prima voor wekelijks publiceren, pijnlijk voor redactionele preview. We koppelden Directus-webhooks aan Vercels incremental builds zodat een enkele publicatie-update in ongeveer acht seconden opnieuw bouwt.

Toen we deze migratie deden, was het verrassende hoeveel van die tien weken in de gewoonte van één persoon ging zitten. Twee van die weken waren gesprekken met de archivaris over haar auditchecklist, die uiteindelijk onze testsuite werd. Dat soort legacy-migratie is grotendeels wat we bij ABN doen, en het staat of valt vrijwel altijd met de vraag of één persoon binnen de klant het nieuwe systeem vertrouwt.

Draai je vandaag een Drupal 7-site? Dit is de vijf-minutenoefening die je vóór alles doet. Open je analytics, sorteer de URL's van de laatste twaalf maanden op inkomend extern verkeer, en schrijf de top vijftig op. Dat zijn de URL's die je migratie niet mag breken. De rest is onderhandelbaar.

Kern

Als het oude systeem een kwartaalcontrole door een mens nodig had om zijn regels af te dwingen, heeft het nieuwe systeem het pas vervangen wanneer die regels in het schema zelf leven.

FAQ

Waarom niet upgraden naar Drupal 10 in plaats van weg te migreren?

De klant gebruikte in wezen één content type, zonder Views- of Panels-complexiteit. Een upgrade naar D10 had de bewerkpijn behouden en er twee maanden module-voor-module triage bovenop gelegd, zonder echte winst.

Hoe behield je de zoekrankings op 4.600 URL's?

We exporteerden elke publieke URL op drie manieren (crawl, sitemap, database), legden de verschillen voor aan de redacteur, en schreven een Vercel-redirectsbestand met 4.612 permanente 301's plus een middleware-fallback voor catch-all node-id's.

Wat gebeurt er met de Archiefwet-metadata als een publicatie de vernietigingsdatum passeert?

Een Directus Flow markeert hem voor review en sluit hem uit van de kwartaal-CSV-export. De daadwerkelijke verwijdering blijft een menselijke beslissing die de archivaris neemt, want dat is wat de Archiefwet vereist.

Hoe lang duurde de migratie van begin tot eind?

Tien weken van briefing tot launch. Ongeveer twee van die weken liepen we mee met de archivaris zodat haar auditchecklist onze testsuite kon worden voordat er één regel code was geschreven.

drupalmigrationlegacy sitesmysqlarchitecturecase study

Iets bouwen?

Start een project