Drupal
Drupal 7 naar Sanity migratie: de Field Collection-val
Dag acht van een migratie van Drupal 7 naar Sanity. De GROQ-queries gaven schone strings terug waar ooit 3.600 RTRS-gecontroleerde pesticidedoses leefden als geneste entity references.

Dag acht. De Sanity Studio zag er strak uit, Astro bouwde in vier seconden, en de staging-URL laadde direct op een 4G-hotspot op het parkeerterrein van het magazijn in Den Bosch. Toen opende de operations manager één cultivar — Cucumis sativus 'Verdon' — en verscheen de spuithistorie als een string van 412 tekens. Geen regelafbrekingen. Geen datums. Geen doseringen. Alleen een komma-gescheiden muur van legacy entity-IDs.
We bouwden het teler-portaal opnieuw voor een tuinbouwtoeleverancier met drieëntwintig mensen: een Drupal 7-portaal dat hun RTRS-certificering ondersteunt. Drieduizend zeshonderd records met pesticidedoseringen per cultivar, per week, teruggaand tot 2019. De auditor komt over vier weken langs. Het oude portaal werkt nog. Het nieuwe niet.
Dit is het verhaal van waarom we elf dagen kwijt waren aan het herstellen van wat een Field Collection-setup uit 2016 stilletjes met de data had gedaan — en hoe je het op je eigen Drupal 7-exit herkent voor het een sprint opeet.
Het teler-portaal dat we erfden
De klant levert zaden, substraat en gewasbeschermingsmiddelen aan tomaten-, komkommer- en papriketelers in Brabant en Limburg. Drieëntwintig mensen, omzet in de tientallen miljoenen, één PHP 7.1-portaal dat sinds 2018 niemand meer had aangeraakt. Telers loggen wekelijks in om vast te leggen wat ze hebben gespoten, op welke cultivar, in welke kasafdeling, in welke dosering. Het portaal genereert het auditlogboek dat hun RTRS-certificering en drie andere keurmerken voedt. Geen log, geen certificering. Geen certificering, geen contracten met de grote retailers.
De community-support voor Drupal 7 eindigde op 5 januari 2025. De site strompelde door op een niet-ondersteunde PHP 7.1 met maandelijkse patches die we een freelancer per hand lieten uitrollen. De opdracht was simpel: weg van Drupal, portaal blijft werken, geen enkele doseringsregistratie kwijtraken. We kozen Sanity voor de gestructureerde content store en Astro voor de front-end, met een dunne Hono-API op Cloudflare Workers voor de schrijfkant. We begrootten drie weken. We lagen op schema tot dag acht.
Wat de Field Collection-module daadwerkelijk opsloeg
Het portaal werd in 2016 gebouwd op Drupal 7.43, toen Field Collection de standaardmanier was om herhalende, samengestelde data te modelleren. Elke cultivar-node had een veld field_spuit_registratie ("spuitregistratie"), wat een Field Collection was. Elk collection-item bevatte drie sub-velden: een entity reference naar een bestrijdingsmiddel-node, een decimale dosering_g_per_ha, en een toepassingsdatum.
Dat ziet er onschuldig uit tot je begrijpt wat Field Collection eigenlijk doet. Elk collection-item is een eigen entity, met een eigen entity-ID, opgeslagen in field_collection_item. De parent cultivar-node houdt een entity reference naar het collection-item. Het collection-item houdt een entity reference naar het bestrijdingsmiddel. Eén spuitregistratie zit dus twee entity references diep — node → collection_item → bestrijdingsmiddel — en elk niveau heeft z'n eigen load nodig.
In 2016 was dat prima. Drupals entity_metadata_wrapper liep er transparant doorheen. De oorspronkelijke developer schreef een Twig-partial die de wrapper doorliep en elk record renderde. Het werkte negen jaar. Het bouwde ook een aanname in: de leeskant dereferencet de entities wel voor mij.
Gebruikt je Drupal 7-site Field Collection voor iets compliance-gerelateerds — auditlogs, doseringsregistraties, afgetekende versies — dan zit de data twee entity references diep. Elke migratie die de structuur naar JSON platslaat voordat de references zijn opgelost, vernietigt stilletjes die relatie.
Hoe GROQ de audit trail platsloeg
Ons exportscript draaide aan de Drupal-kant. Het laadde elke cultivar-node, liep door de Field Collection, en gooide een JSON-array met records naar buiten. Het Sanity-importscript nam die JSON en duwde 'm door @sanity/client heen, met een schema dat we als lijst van platte objecten op het cultivar-document hadden gedefinieerd:
// schemas/cultivar.ts
defineType({
name: 'cultivar',
type: 'document',
fields: [
defineField({ name: 'name', type: 'string' }),
defineField({
name: 'sprayRegistrations',
type: 'array',
of: [{
type: 'object',
fields: [
{ name: 'pesticide', type: 'string' }, // <-- the bug
{ name: 'doseGramsPerHa', type: 'number' },
{ name: 'appliedAt', type: 'datetime' },
],
}],
}),
],
})
Het bestrijdingsmiddel kwam binnen als string. Natuurlijk — de exporter had de entity reference uitgelezen, $wrapper->bestrijdingsmiddel->label() aangeroepen en de leesbare naam geserialiseerd. Tweeduizend records gebruikten dezelfde vijf middelen. Op de gerenderde pagina was de string verliesloos. De auditor zou het nooit merken.
Alleen leest de RTRS-auditor geen pagina's. Die vraagt een CSV-export op van elk doseringsmoment, gekoppeld aan het EU-toelatingsnummer voor het bestrijdingsmiddel (de toelatingsnummer). Het oorspronkelijke Drupal-portaal maakte die CSV door de pesticide-entity bij export te dereferencen en field_toelatingsnummer uit te lezen. Het nieuwe portaal had geen entity om te dereferencen. Het toelatingsnummer kwam nooit in de JSON terecht. De GROQ-query die we voor het CSV-endpoint hadden geschreven, gaf dit terug:
// /api/rtrs-export.groq
*[_type == "cultivar"]{
name,
"sprays": sprayRegistrations[]{
pesticide, // "Previcur Energy" — the name, not the number
doseGramsPerHa,
appliedAt
}
}
Drieduizend zeshonderd records kwamen eruit zonder toelatingsnummer. De GROQ-engine van Sanity deed precies wat we vroegen: projecteer de velden die bestaan. De velden die we nodig hadden — die de auditor zou opvragen — zaten niet in het document. Ze zaten in de Drupal-node van het bestrijdingsmiddel, die we niet als eigen type hadden gemigreerd.
Elf dagen verkeerde aannames
De eerste drie dagen zochten we de bug in de GROQ-query. Die zat er niet. De volgende drie dagen herschreven we de Astro-pagina om de middelnaam op te halen en te herformatteren. Verkeerde laag. Op dag zeven hadden we een lookup-service gebouwd die voor elk ontbrekend toelatingsnummer de oude Drupal-site aansprak — totdat de maandelijkse patch-cyclus van de freelancer de Drupal-site offline trok voor een onderhoudsvenster waar niemand ons over had ingelicht.
Dag acht draaide een van onze engineers één blok in de Drupal 7-shell, en werd de echte vorm van de data zichtbaar:
// drush php-eval
$node = node_load(2841);
$w = entity_metadata_wrapper('node', $node);
foreach ($w->field_spuit_registratie as $fc) {
$item = $fc->value();
$pest = entity_metadata_wrapper('field_collection_item', $item)
->field_bestrijdingsmiddel->value();
echo $pest->nid . ' ' . $pest->field_toelatingsnummer['und'][0]['value'] . "\n";
}
Elke spuitregistratie verwees naar een echte bestrijdingsmiddel-node met een echt toelatingsnummer. Onze export had de label gelezen en de reference weggegooid. Het platslaan gebeurde in onze PHP, niet in GROQ. We hadden de leeskant de schuld gegeven van een fout in de exportlaag.
Dat maakte uit, want de fix moest op de export en het schema gebeuren — elke variant van de bug die we aan de Astro-kant hadden gepatcht, was nu weggegooid werk. We gooiden vier dagen code weg.
De fix: denormaliseer bij export, niet bij read
We herstructureerden het Sanity-schema zodat elk bestrijdingsmiddel een eigen document is, en elke spuitregistratie een object dat een echte Sanity-reference naar dat document bevat. Het exportscript lost de Field Collection nu in twee passes op: eerst elke bestrijdingsmiddel-node, daarna elke cultivar met references naar de al bestaande bestrijdingsmiddel-documenten.
// schemas/pesticide.ts
defineType({
name: 'pesticide',
type: 'document',
fields: [
defineField({ name: 'name', type: 'string' }),
defineField({ name: 'toelatingsnummer', type: 'string' }), // EU reg. number
defineField({ name: 'activeIngredient', type: 'string' }),
],
})
// schemas/cultivar.ts (revised)
defineField({
name: 'sprayRegistrations',
type: 'array',
of: [{
type: 'object',
fields: [
{ name: 'pesticide', type: 'reference', to: [{ type: 'pesticide' }] },
{ name: 'doseGramsPerHa', type: 'number' },
{ name: 'appliedAt', type: 'datetime' },
],
}],
})
De GROQ-query lost de reference nu op bij read time — precies waar GROQ goed in is:
*[_type == "cultivar"]{
name,
"sprays": sprayRegistrations[]{
"pesticide": pesticide->{ name, toelatingsnummer, activeIngredient },
doseGramsPerHa,
appliedAt
}
}
Het exportscript draaide veertien minuten. De CSV kwam eruit met elk toelatingsnummer op z'n plek. We deden een diff tegen de laatste door Drupal gegenereerde CSV en vonden zeven historische records waar het oude portaal een vrije-tekst middelnaam zonder entity reference had opgeslagen — een data-entry-quirk uit 2017 die we met de hand corrigeerden. Twee weken later tekende de auditor af.
Lessen uit een Drupal 7-naar-Sanity-migratie
Drie dingen die we nu bij elke Drupal 7-exit doen.
Eén: voor we ook maar een regel exportcode schrijven, draaien we drush field-info fields op de live site en grepen we naar field_collection, entityreference en paragraphs. Elke treffer betekent dat de data minstens twee hops diep zit. Dat verandert het schemaontwerp voor het het migratiescript verandert.
Twee: we behandelen de Drupal entity graph als bron van waarheid en reproduceren 'm in het nieuwe systeem voor we ook maar iets platslaan. Bevat een Field Collection een entity reference, dan wordt die reference een eigen document in de nieuwe stack. Platslaan is een presentatie-vraagstuk, geen opslagvraagstuk. GROQ lost references op bij read time, precies zodat je ze niet in het document hoeft te bakken.
Drie: elke exportrun produceert een record-count diff tegen de live Drupal-site voor iemand naar de front-end kijkt. Gingen er 3.600 records in en kwamen er 3.600 strings uit, dan "werkt" het script. Gingen er 3.600 records in en komen er 3.593 volledig opgeloste references uit, dan vind je die zeven uitschieters op dag één in plaats van dag elf.
Hoe dit werk er voor de klant uitzag
Toen we het nieuwe teler-portaal voor deze leverancier uit Den Bosch bouwden, liepen we tegen een Field Collection-setup uit 2016 aan die een twee-hops entity reference verstopte achter een perfect leesbare Twig-template. We hebben uiteindelijk de export pass herbouwd om de reference graph te bewaren, elk bestrijdingsmiddel als eersteklas Sanity-document gemodelleerd, en pas in de GROQ-leeslaag platgeslagen. Staar je naar een Drupal 7-portaal en een EOL-datum, dan zijn onze aantekeningen uit dit project over legacy migratie bijna één-op-één toepasbaar.
Open een terminal op je Drupal 7-site en draai drush field-info fields | grep -E 'field_collection|entityreference|paragraphs'. Elke regel die je ziet is een plek waar een migratiescript stilletjes een reference kan laten vallen. Map die plekken voor je iets anders mapt.
Kern
Bij een Drupal 7-migratie is de gevaarlijke data de entity reference die je Twig-template stilletjes voor je dereferencet. Sla pas plat bij read, niet bij export.
FAQ
Kun je nog veilig leunen op de Drupal 7 Field Collection-module?
Nee. De community-support voor Drupal 7 eindigde in januari 2025, en Field Collection stond daarvoor al in maintenance mode. Behandel elke site die het gebruikt als migratiekandidaat, niet als onderhoudskandidaat.
Waarom bestrijdingsmiddelen als Sanity-document modelleren en niet als inline object?
Omdat hetzelfde middel terugkomt in honderden cultivars en één gezaghebbend toelatingsnummer nodig heeft. Met references krijg je één bron van waarheid en lost GROQ pas bij read time op, zonder data te dupliceren.
Hoe lang moet een Drupal 7-naar-Sanity-migratie duren voor een portaal van deze omvang?
Drie weken is realistisch voor een site van 23 mensen met een paar duizend records, mits je de entity graph in kaart brengt voor je het exportscript schrijft. Ontdek Field Collection-nesting op dag twee, niet op dag acht.
Waar kijkt een RTRS-achtige audit eigenlijk naar in de data?
Per-toepassingsregistraties, gekoppeld aan het EU-toelatingsnummer, met cultivar, dosering, datum en operator. Alleen de middelnaam is niet genoeg — de auditor wil de gereguleerde identifier.