Drupal
Drupal 7 to Statamic: a five-week church portal cutover
A 27-person kerkgenootschap, 41,200 sacramentsregisters, a Drupal 7 site past EOL, and a SILA mutatiebericht that cannot miss a single day. Here is the five-week cutover.

It was a Tuesday evening in October when the koster of a small kerkgenootschap in Zutphen forwarded us a screenshot of a timeout error. The doopboek search — 41,200 records reaching back to a time before the parish had electric light — had spent 47 seconds inside a PHP 7.0 worker before nginx gave up. The portal is sixteen years old. It runs on Drupal 7. And it has to keep working for at least another 110 years, because that is how long the kerkenraad keeps sacramental records under their own erfgoedreglement, which the UAVG accommodates as a legitimate interest.
Twenty-seven people use the portal. Two of them write to it daily: the ledenadministrateur and the predikant. The other twenty-five read it, mostly on Sundays, mostly on phones in pew-side overcoats. Nothing about this site is high-traffic. Everything about it is high-stakes.
This is the playbook we used to move it onto Statamic and Laravel across five weeks of shadow traffic, without losing a single SILA mutation.
The portal we inherited
The legacy stack was, charitably, a museum. Drupal 7 with thirty-one contrib modules, eight of which had no D9 or D10 successor. A custom sacramenten module written against the D7 Field API, plus a parallel slab of bespoke PHP 7.0 that handled the SILA integration through a nightly cron and a SOAP endpoint. The database was MySQL 5.6 on a VPS the church had been renting since 2010. The kerkenraad paid €14 a month for it.
Drupal 7 reached official end of life on 5 January 2025. By the time we were called in, the site had been on commercial extended support for fourteen months, and the extended-support invoice had quietly grown to more than ten times the hosting bill. The kerkenraad wanted out.
What they did not want was risk. The site holds the only authoritative copy of doop-, trouw- en overlijdensregisters for the gemeente. The paper archive ends in 2008. If we lost a doop record from 2014, there is nowhere to recover it from.
The non-negotiables
Before we drew an architecture, we wrote the constraints down on one page and got the kerkenraad to sign it. Three of them mattered more than the rest.
The 41,200 registers must arrive byte-for-byte. Every doop, trouw, vormsel, and uitvaart record, including the typo'd ones, the duplicates, and the seventeen entries from 1962 where the voorganger field is just the word “onbekend”. Cleaning data was explicitly out of scope. Archivists clean. We migrate.
The UAVG retention has to be enforced in code, not in policy. The church applies a 110-year retention to sacramental records, which they justify under the AVG as necessary for the performance of their ecclesiastical task. The Autoriteit Persoonsgegevens guidance on bewaartermijnen is clear that the controller must be able to demonstrate the term and act on it. That means a te_vernietigen_op date stored next to every record, indexed, and scanned by a scheduled job.
SILA cannot miss a day. The Stichting Interkerkelijke Leden Administratie pushes a daily mutatiebericht from the Gemeentelijke BRP to participating churches: address changes, deaths, name corrections. If our portal is offline for the nightly window, that day's mutations have to be re-requested by hand, and the kerkenraad has to file a justification with SILA. Once is a hassle. Twice is a meeting.
The new shape
We split the application along the line that the church already drew in its own head. Public-facing content — the kerkdienst-rooster, the liturgieboekje, the orgelconcert agenda, photo galleries from doopfeesten — went onto Statamic, because the predikant wanted to edit it in a browser without a developer in the loop. The leden-portaal, with the registers, the sacramenten history, and the SILA endpoint, went onto a Laravel app behind authentication.
Statamic ships as a Laravel package, so both halves live in the same composer project and the same git repo. Content authors work in Statamic's control panel. The portaal lives under /portaal with its own middleware stack and its own Postgres database.
We picked Postgres over MySQL for two reasons. First, the jsonb column type made the irregular shape of historical records (some have getuigen, some have a doopwater veld, one has a hand-written marginalia field from 1971) easier to model without forty nullable columns. Second, partial indexes let us index only living members, which is most of the working queries.
The sacramenten table
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');
});The legacy_drupal_nid column is the one piece of debt we deliberately took on. It made the byte-for-byte audit possible, and it gives the kerkenraad a way to trace any record back to its D7 source for the next decade. It costs one indexed UUID per row. We'll live with it.
Mapping the 41,200 registers
Drupal 7's Field API stores values across three or four tables per field. A single doop record touches node, field_data_field_doopdatum, field_data_field_doopplaats, field_data_field_getuigen, plus a field_collection_item for the voorganger reference. Reading them with Drush worked, but it was slow on a VPS this old.
We exported in stages, with an idempotent ETL script that we re-ran whenever we found a mapping bug:
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.gzThe Laravel import side ran inside a single transaction per batch of 500, with a CRC32 of the row written into a migrations_audit table. After the import, we ran a reconciliation pass that compared row counts and field-level CRC32 sums against the D7 source. We hit byte equality on 41,187 of 41,200 records on the first full run. The remaining thirteen were Drupal field-collection orphans — rows the D7 UI had never been able to show either. We logged them, the kerkenraad signed off, and they went in as-is with a was_orphan: true flag in their aantekening.
Five weeks of shadow traffic
The riskiest part of any portal migration is not the data. It is the moment you tell the predikant to start using the new URL. So we did not.
For five weeks, both stacks ran in parallel behind nginx, and every authenticated request to the legacy portal was mirrored to the new one. The user saw the legacy response. We saw the new one in the 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;
}The Laravel side knew it was being shadowed by the X-Shadow header and committed nothing to the database. Instead, it ran the request, produced the response, hashed it, and dropped a row into a shadow_diffs table whenever the response — minus volatile fields like CSRF tokens and timestamps — did not match what D7 had produced.
Week one had 318 diffs per day, almost all of them HTML whitespace. Week three we were at twelve. Week five we had two genuine ones left, both in a corner of the SILA admin screen that nobody used. We fixed them, ran another week, and went to zero.
Mirrored requests still hit your new database connection, your new mail queue, and any third-party API. Put the new stack in a no-side-effects mode for the entire shadow period or you will email the kerkenraad twice per Sunday for a month.
Re-attaching SILA without missing a mutation
SILA pushes its mutatiebericht to a single endpoint per kerkelijke gemeente. The cutover problem is real: if both stacks register for the feed, you get duplicates and a phone call. If neither does, you miss a day and you file paperwork.
We solved it the boring way. The legacy portal kept ownership of the SILA endpoint until cutover hour. Every nightly mutation was written to D7's database and forwarded to Laravel through an internal HTTP call that wrote to the new Postgres tables under a source: 'sila_via_d7' tag. Laravel had its own ingest endpoint live and tested, but it was not registered with SILA until the morning of cutover.
The registration change with SILA itself takes 24 to 72 hours to propagate. We scheduled it for a Wednesday morning, three days before the cutover weekend, and ran both endpoints in parallel for the overlap window. Each side de-duplicated against the SILA mutatie-id, which is unique per message. Belt, braces, and a third belt.
The cutover hour
The actual switch took eleven minutes on a Saturday morning. We had rehearsed it three times against a copy of production.
- Freeze writes on D7. Maintenance mode on, plus a database-level read-only role for the application user. The two write-side users got an email an hour earlier asking them to step away from the keyboard.
- Run the final delta import. The reconciliation job had been running every fifteen minutes for the previous week, so the delta was always under 200 rows. This time it was 47.
- Flip the nginx upstream for
/portaal/fromdrupal7_upstreamtolaravel_upstream. Reload, not restart. No dropped connections. - Turn off the mirror block. The shadow stack stops being a shadow.
- Re-run the reconciliation pass one final time. Row counts and CRC32 sums equal on both sides. Sign off in the migration log.
The kerkenraad watched on a video call. The predikant made a doop entry for a baby born that week. The entry appeared in the new portal, the audit row appeared in migrations_audit, and SILA's next nightly mutatiebericht landed cleanly at 03:14 the following morning.
What we would do differently
Two things. We would write the shadow-diff classifier earlier — the first week we drowned in whitespace diffs that a five-line normalizer would have collapsed. And we would have negotiated the SILA registration change as a single 24-hour swap rather than a 72-hour overlap. The overlap worked, but the de-dup logic was the most complex code in the project and it now lives in production forever as load-bearing scaffolding.
When we built the leden-portaal for this kerkgenootschap, the thing we kept coming back to was that a small site with high-stakes data is harder to migrate than a large site with low-stakes data. There is no traffic to learn from, no A/B cohort to bleed onto. Shadow traffic gave us the signal a high-volume site would have had for free. If you are staring at a Drupal 7 install that you cannot afford to break, this is the shape of a legacy migration that actually lands.
If you have a Drupal 7 or custom-PHP portal still in production today, the smallest useful thing you can do this week is run drush pm:list --status=enabled --type=module --no-core against it and write next to each module whether a Composer-era replacement exists. That list is your migration scope, and you will read it differently than you read it from memory.
Key takeaway
On a small, high-stakes portal, five weeks of mirrored shadow traffic buys you the diff signal a busy site would give you in an afternoon. Pay the five weeks.
FAQ
Why not stay on Drupal with extended commercial support?
The kerkenraad already paid more for extended support than for hosting, and every contrib module not yet ported was another patch they'd have to underwrite alone. The cost curve crossed within a year.
How is the 110-year UAVG retention enforced in the new stack?
Every sacramentsregister carries a te_vernietigen_op column set to datum + 110 jaar, indexed. A nightly scheduled job lists records that fall due, so the kerkenraad signs off on destruction in batches rather than per record.
Did the SILA mutatiebericht ever drop a day during the cutover?
No. The legacy portal owned the SILA endpoint until the moment of switch, with a three-day overlap window where both endpoints de-duplicated against the unique mutatie-id on each message.
Why Statamic and Laravel rather than just Laravel?
The predikant edits the public content weekly and needed a browser-based control panel, not a developer in the loop. Statamic ships as a Laravel package, so both halves share one repo, one deploy, and one auth layer.
How long was the actual user-visible downtime?
Eleven minutes on a Saturday morning, with the kerkenraad watching on a video call. The cutover steps had been rehearsed three times against a copy of production beforehand.