Drupal
Van Drupal 7 naar Statamic: kerkportaal-cutover in 5 weken
Een kerkgenootschap van 27 mensen, 41.200 sacramentsregisters, een Drupal 7-site over zijn EOL heen en een SILA-mutatiebericht dat geen dag mag missen. Dit is de cutover in vijf weken.

Het was een dinsdagavond in oktober toen de koster van een klein kerkgenootschap in Zutphen ons een screenshot van een timeout-error doorstuurde. De doopboek-zoekopdracht — 41.200 records die teruggaan tot een tijd waarin de gemeente nog geen elektrisch licht had — had 47 seconden in een PHP 7.0-worker doorgebracht voordat nginx het opgaf. Het portaal is zestien jaar oud. Het draait op Drupal 7. En het moet nog minstens 110 jaar werken, want zo lang houdt de kerkenraad sacramentele registers onder hun eigen erfgoedreglement, wat de UAVG accommodeert als gerechtvaardigd belang.
Zevenentwintig mensen gebruiken het portaal. Twee daarvan schrijven er dagelijks naar: de ledenadministrateur en de predikant. De andere vijfentwintig lezen mee, vooral op zondag, vooral op telefoons onder een overjas in de kerkbank. Niets aan deze site is high-traffic. Alles eraan is high-stakes.
Dit is het draaiboek waarmee we 'm in vijf weken shadow traffic naar Statamic en Laravel verhuisden, zonder één SILA-mutatie te verliezen.
Het portaal dat we erfden
De legacy stack was, vriendelijk gezegd, een museum. Drupal 7 met eenendertig contrib-modules, waarvan er acht geen D9- of D10-opvolger hadden. Een custom sacramenten-module geschreven tegen de D7 Field API, plus een parallelle stapel maatwerk-PHP 7.0 die de SILA-integratie afhandelde via een nightly cron en een SOAP-endpoint. De database was MySQL 5.6 op een VPS die de kerk sinds 2010 huurde. De kerkenraad betaalde er €14 per maand voor.
Drupal 7 bereikte op 5 januari 2025 zijn officiële end of life. Toen wij erbij werden gehaald, zat de site al veertien maanden op commerciële extended support en was de extended-support-factuur stilletjes uitgegroeid tot meer dan tien keer de hostingrekening. De kerkenraad wilde eruit.
Wat ze niet wilden, was risico. De site bevat de enige authentieke kopie van doop-, trouw- en overlijdensregisters van de gemeente. Het papieren archief eindigt in 2008. Als we een doopinschrijving uit 2014 verliezen, is er nergens een back-up vandaan te halen.
Wat niet onderhandelbaar was
Voor we een architectuur tekenden, zetten we de randvoorwaarden op één pagina en lieten we de kerkenraad ze ondertekenen. Drie ervan wogen zwaarder dan de rest.
De 41.200 registers moeten byte-voor-byte aankomen. Elk doop-, trouw-, vormsel- en uitvaartrecord, inclusief de typo's, de duplicaten en de zeventien inschrijvingen uit 1962 waar in het voorgangerveld alleen het woord “onbekend” staat. Data opschonen viel expliciet buiten scope. Archivarissen schonen. Wij migreren.
De UAVG-bewaartermijn moet in code worden afgedwongen, niet in beleid. De kerk hanteert een bewaartermijn van 110 jaar voor sacramentele registers, die ze onder de AVG rechtvaardigen als noodzakelijk voor de uitvoering van hun kerkelijke taak. De richtlijn van de Autoriteit Persoonsgegevens over bewaartermijnen is duidelijk: de verwerkingsverantwoordelijke moet de termijn kunnen aantonen en erop kunnen handelen. Dat betekent een te_vernietigen_op-datum naast elk record, geïndexeerd, en gescand door een scheduled job.
SILA mag geen dag missen. De Stichting Interkerkelijke Leden Administratie pusht een dagelijks mutatiebericht vanuit de Gemeentelijke BRP naar deelnemende kerken: adreswijzigingen, overlijden, naamcorrecties. Als ons portaal tijdens het nachtelijke venster offline is, moeten de mutaties van die dag handmatig opnieuw worden aangevraagd en moet de kerkenraad een verantwoording bij SILA indienen. Eén keer is gedoe. Twee keer is een vergadering.
De nieuwe vorm
We splitsten de applicatie langs de lijn die de kerk in haar hoofd al had getrokken. Publieke content — het kerkdienst-rooster, het liturgieboekje, de agenda voor orgelconcerten, fotoreportages van doopfeesten — kwam op Statamic, omdat de predikant het zelf in de browser wilde kunnen aanpassen zonder developer ertussen. Het leden-portaal, met de registers, de sacramentenhistorie en het SILA-endpoint, kwam op een Laravel-app achter authenticatie.
Statamic wordt geleverd als Laravel-package, dus beide helften wonen in hetzelfde composer-project en dezelfde git-repo. Content-auteurs werken in het control panel van Statamic. Het portaal draait onder /portaal met een eigen middleware-stack en een eigen Postgres-database.
We kozen Postgres boven MySQL om twee redenen. Eerst maakte het jsonb-kolomtype het modelleren van de onregelmatige vorm van historische records makkelijker (sommige hebben getuigen, andere een doopwater-veld, één heeft een handgeschreven marginaliaveld uit 1971), zonder veertig nullable-kolommen. Ten tweede konden we met partial indexes alleen levende leden indexeren, en daar gaan de meeste queries over.
De sacramenten-tabel
Schema::create('sacramenten', function (Blueprint $t) {
$t->id();
$t->foreignId('lid_id')->constrained('leden');
$t->enum('soort', ['doop','vormsel','huwelijk','uitvaart']);
$t->date('datum');
$t->string('plaats');
$t->string('voorganger')->nullable();
$t->jsonb('getuigen')->default('[]');
$t->text('aantekening')->nullable();
$t->date('te_vernietigen_op'); // datum + 110 jaar
$t->uuid('legacy_drupal_nid')->nullable()->unique();
$t->timestamps();
$t->index(['lid_id','soort']);
$t->index('te_vernietigen_op');
});De legacy_drupal_nid-kolom is het ene stukje schuld dat we bewust hebben overgenomen. Hij maakte de byte-voor-byte audit mogelijk en geeft de kerkenraad de komende tien jaar een manier om elk record terug te leiden naar de D7-bron. Het kost één geïndexeerde UUID per rij. Daar leven we mee.
De 41.200 registers in kaart brengen
De Field API van Drupal 7 verspreidt waarden over drie of vier tabellen per field. Eén doopinschrijving raakt node, field_data_field_doopdatum, field_data_field_doopplaats, field_data_field_getuigen, plus een field_collection_item voor de voorganger-referentie. Het uitlezen met Drush werkte wel, maar op een VPS van deze leeftijd was het traag.
We exporteerden in fases, met een idempotent ETL-script dat we opnieuw draaiden zodra we een mapping-bug vonden:
drush @kerk.live sql:query \
--extra='--batch --quick' \
"SELECT n.nid, n.created, n.changed,
dd.field_doopdatum_value AS doopdatum,
dp.field_doopplaats_value AS doopplaats
FROM node n
LEFT JOIN field_data_field_doopdatum dd ON dd.entity_id = n.nid
LEFT JOIN field_data_field_doopplaats dp ON dp.entity_id = n.nid
WHERE n.type = 'sacrament_doop'
ORDER BY n.nid" \
| gzip > exports/doop-$(date -u +%Y%m%dT%H%M).tsv.gzDe Laravel-importkant draaide elk batch van 500 in één transactie, met een CRC32 van de rij in een migrations_audit-tabel. Na de import volgde een reconciliatieronde die row counts en field-level CRC32-sommen vergeleek met de D7-bron. Op de eerste volle run haalden we byte-gelijkheid op 41.187 van de 41.200 records. De resterende dertien waren field-collection-wezen van Drupal — rijen die de D7-UI zelf ook nooit had kunnen tonen. We logden ze, de kerkenraad tekende ervoor en ze gingen as-is mee met een was_orphan: true-vlag in hun aantekening.
Vijf weken shadow traffic
Het riskantste deel van elke portaalmigratie is niet de data. Het is het moment waarop je de predikant vertelt dat hij de nieuwe URL moet gaan gebruiken. Dus dat deden we niet.
Vijf weken lang draaiden beide stacks parallel achter nginx, en elke geauthenticeerde request naar het legacy-portaal werd gespiegeld naar het nieuwe. De gebruiker zag de legacy-response. Wij zagen de nieuwe in de logs.
location /portaal/ {
mirror /__shadow;
mirror_request_body on;
proxy_pass http://drupal7_upstream;
}
location = /__shadow {
internal;
proxy_pass http://laravel_upstream$request_uri;
proxy_set_header X-Shadow "1";
proxy_set_header X-Shadow-User $cookie_SESS;
proxy_connect_timeout 200ms;
proxy_read_timeout 1500ms;
}De Laravel-kant wist dankzij de X-Shadow-header dat ze geschaduwd werd en committeerde niets naar de database. In plaats daarvan voerde ze de request uit, produceerde de response, hashte 'm en zette een rij in een shadow_diffs-tabel zodra de response — minus volatiele velden als CSRF-tokens en timestamps — niet overeenkwam met wat D7 had geproduceerd.
Week één: 318 diffs per dag, vrijwel allemaal HTML-whitespace. Week drie zaten we op twaalf. Week vijf hadden we nog twee echte over, allebei in een hoekje van het SILA-adminscherm dat niemand gebruikte. We hebben ze gefixt, nog een week gedraaid en kwamen op nul uit.
Gespiegelde requests raken nog steeds je nieuwe database-connectie, je nieuwe mail-queue en elke third-party API. Zet de nieuwe stack tijdens de hele shadow-periode in een no-side-effects-modus, anders mailt hij de kerkenraad een maand lang twee keer per zondag.
SILA opnieuw aansluiten zonder een mutatie te missen
SILA pusht haar mutatiebericht naar één endpoint per kerkelijke gemeente. Het cutover-probleem is echt: registreren beide stacks zich op de feed, dan krijg je duplicaten en een telefoontje. Doet geen van beide het, dan mis je een dag en mag je papierwerk invullen.
We hebben het op de saaie manier opgelost. Het legacy-portaal bleef tot het cutover-uur eigenaar van het SILA-endpoint. Elke nachtelijke mutatie werd weggeschreven naar de D7-database én via een interne HTTP-call doorgestuurd naar Laravel, die ze in de nieuwe Postgres-tabellen wegschreef onder een source: 'sila_via_d7'-tag. Laravel had een eigen ingest-endpoint live en getest, maar het werd pas op de ochtend van de cutover bij SILA geregistreerd.
De registratiewijziging bij SILA zelf duurt 24 tot 72 uur om door te werken. We planden 'm op een woensdagochtend, drie dagen voor het cutover-weekend, en lieten beide endpoints tijdens het overlap-venster naast elkaar lopen. Elke kant ontdubbelde op de SILA-mutatie-id, die uniek is per bericht. Riem, bretels en nog een riem.
Het cutover-uur
De daadwerkelijke switch duurde elf minuten op een zaterdagochtend. We hadden hem drie keer gerepeteerd tegen een kopie van productie.
- Schrijfbewegingen op D7 bevriezen. Maintenance mode aan, plus een database-level read-only-rol voor de applicatie-user. De twee schrijf-users kregen een uur eerder een mail met het verzoek bij het toetsenbord vandaan te blijven.
- De laatste delta-import draaien. De reconciliatie-job had de voorgaande week elk kwartier gelopen, dus de delta zat altijd onder de 200 rijen. Deze keer waren het er 47.
- De nginx-upstream voor
/portaal/omzetten vandrupal7_upstreamnaarlaravel_upstream. Reload, geen restart. Geen verbroken connecties. - Het mirror-block uitzetten. De shadow-stack is geen schaduw meer.
- De reconciliatieronde één laatste keer draaien. Row counts en CRC32-sommen aan beide kanten gelijk. Aftekenen in de migratielog.
De kerkenraad keek mee via een videogesprek. De predikant maakte een doopinschrijving aan voor een baby die die week was geboren. De inschrijving verscheen in het nieuwe portaal, de audit-rij verscheen in migrations_audit en het volgende nachtelijke SILA-mutatiebericht landde de ochtend erna keurig om 03:14.
Wat we anders zouden doen
Twee dingen. We zouden de shadow-diff classifier eerder schrijven — de eerste week verzopen we in whitespace-diffs die een normalizer van vijf regels had platgeslagen. En we zouden de SILA-registratiewijziging onderhandeld hebben als een swap van 24 uur in plaats van een overlap van 72 uur. De overlap werkte, maar de ontdubbel-logica was de complexste code van het project en staat nu voor altijd als load-bearing scaffolding in productie.
Toen we het leden-portaal voor dit kerkgenootschap bouwden, kwamen we steeds terug op één ding: een kleine site met high-stakes data is moeilijker te migreren dan een grote site met low-stakes data. Er is geen traffic om van te leren, geen A/B-cohort om op te bloeden. Shadow traffic gaf ons het signaal dat een high-volume site gratis zou hebben gehad. Als jij naar een Drupal 7-installatie staart die je je niet kunt veroorloven te breken, dan is dit de vorm van een legacy migratie die ook echt landt.
Heb je vandaag nog een Drupal 7- of maatwerk-PHP-portaal in productie staan, dan is het kleinste nuttige dat je deze week kunt doen: drush pm:list --status=enabled --type=module --no-core erop draaien en achter elke module noteren of er een Composer-tijdperk-vervanger bestaat. Die lijst is je migratie-scope, en je leest 'm anders dan je 'm uit je hoofd las.
Kern
Op een klein, high-stakes portaal koopt vijf weken gespiegelde shadow traffic je het diff-signaal dat een drukke site je in een middag zou geven. Betaal die vijf weken.
FAQ
Waarom niet op Drupal blijven met commerciële extended support?
De kerkenraad betaalde al meer voor extended support dan voor hosting, en elke contrib-module die nog niet geport was, was weer een patch die ze in hun eentje moesten dekken. De kostencurve kruiste binnen een jaar.
Hoe wordt de UAVG-bewaartermijn van 110 jaar in de nieuwe stack afgedwongen?
Elk sacramentsregister krijgt een te_vernietigen_op-kolom op datum + 110 jaar, geïndexeerd. Een nachtelijke scheduled job lijst de records die aflopen, zodat de kerkenraad in batches aftekent voor vernietiging in plaats van per record.
Heeft het SILA-mutatiebericht tijdens de cutover ooit een dag laten vallen?
Nee. Het legacy-portaal bleef eigenaar van het SILA-endpoint tot het moment van de switch, met een overlap-venster van drie dagen waarin beide endpoints ontdubbelden op de unieke mutatie-id van elk bericht.
Waarom Statamic en Laravel en niet alleen Laravel?
De predikant past de publieke content wekelijks aan en had een browser-gebaseerd control panel nodig, geen developer ertussen. Statamic is een Laravel-package, dus beide helften delen één repo, één deploy en één auth-laag.
Hoe lang was de daadwerkelijke voor de gebruiker zichtbare downtime?
Elf minuten op een zaterdagochtend, met de kerkenraad meekijkend via een videogesprek. De cutover-stappen waren vooraf drie keer gerepeteerd tegen een kopie van productie.