Drupal
Drupal 7 naar 10: negen dagen vastlopen op Paragraphs
Dag één van de upgrade ging prima. Dag twee kleurde het migrate-dashboard rood en bleef rood. De productspecs stonden netjes in Drupal 7 en waren weg in Drupal 10.

Het was een dinsdag om 11:47 bij een leverancier van industriële onderdelen met 24 mensen aan de rand van Hasselt. Het migrate-dashboard stond zestien uur lang groen. Tot het niet meer groen was. Elfduizend vierhonderd product-spec nodes waren geïmporteerd in Drupal 10 met lege spec-tabellen. Dezelfde nodes openden zonder problemen in de Drupal 7-source. We zaten negen dagen voor go-live, met drie campagnes voor vakbeurzen die al om de nieuwe catalogus heen waren gebouwd en een hoofd operations die de cutover-datum persoonlijk had ondertekend in een stuurgroepdeck.
Hier zijn die negen dagen, wat we steeds dachten dat het probleem was, en het ene ding aan het Paragraphs-migratiepad waar niemand je voor waarschuwt tot je al een week kwijt bent. De bug is alledaags zodra je hem kent. De reden dat hij elke keer het grootste deel van een sprint opslokt, is interessanter.
De site die we overnamen
De klant verkoopt industriële bevestigingsmiddelen en pakkingen via een B2B-catalogus. Hun Drupal 7-site was gebouwd in 2013 door een Brussels bureau dat inmiddels was opgeheven. De catalogus draaide op een content type genaamd product_spec, met een Field Collection-veld voor technische attributen: aanhaalmoment, materiaal, draadsteek, certificeringen. Elk product had tussen de 4 en 22 collection-items. Totaal in de database: 41.820 field_collection_item-rijen verdeeld over 11.400 nodes.
Twee details waren belangrijk, al wisten we dat toen nog niet.
Ten eerste had het oorspronkelijke bureau willekeurige key-value-paren willen opslaan op sommige collection-items (specifieke vendor-eigenaardigheden die in geen enkel bestaand veld pasten). In plaats van nog meer Drupal-velden toe te voegen, hadden ze één field_extra_attrs van het type text_long aangemaakt en daar de PHP serialize()-output in gedumpt. Een rij in de database zag er zo uit:
a:3:{s:9:"din_class";s:5:"8.8.0";s:6:"finish";s:13:"zinc-plated";s:8:"rohs_doc";s:42:"sites/default/files/specs/rohs-2018-44.pdf";}
Ten tweede waren de collection-items door de jaren heen flink bewerkt. Veel hadden tien of meer revisies. De revisie-pointer van de parent node kwam niet altijd overeen met de laatste revisie van het collection-item. De site renderde prima op Drupal 7 omdat Field Collection de expliciete revision_id volgde die was opgeslagen bij de node-revisierij, niet de hoogste in de revisietabel.
De eerste fout
We draaiden de upgrade met de standaard core Migrate Drupal UI plus de Paragraphs-contrib module, die een migratieplugin meebrengt om Field Collection-content over te zetten naar Paragraph-entiteiten. De eerste run deed ongeveer wat we verwachtten: zo'n 240 fouten op rijniveau, vooral ontbrekende bestanden. De site had drie filesystem-layouts op elkaar gestapeld na een Pantheon-naar-Acquia-verhuizing in 2017, en onze file_copy-plugin respecteerde het pad waarmee elke rij oorspronkelijk was geschreven. We bouwden de source-to-destination-map opnieuw, wezen de wezen om, draaiden opnieuw, en het dashboard werd groen.
De volgende ochtend begonnen de content editors van de klant te klikken. Productpagina's renderden. Spec-tabellen waren leeg. Niet gedeeltelijk. Leeg.
De database vertelde een interessanter verhaal:
SELECT COUNT(*) FROM node__field_specs;
-- 41,820
SELECT COUNT(*) FROM paragraph__field_torque
WHERE field_torque_value IS NOT NULL;
-- 6,118
De paragraph-rijen bestonden. De data erin niet. Ongeveer 35.000 collection-items waren binnengekomen met NULL-waarden op elk subveld, ook al hadden de source-rijen waarden in Drupal 7. De catalogus was, in productietermen, weg.
Doodlopende weg één: de source-query
De voor de hand liggende verdachte was de source-plugin. Misschien las hij de verkeerde revisietabel. We staken een dag in het instrumenteren van de d7_field_collection_item-source door hem lokaal te subclassen, elke rij die hij ophaalde naar een JSONL-log te dumpen en te diffen met directe SELECTs op de live D7-database. De source-plugin was prima. Hij haalde correcte rijen op, met correcte waarden, in de correcte volgorde. 41.820 rijen gelogd, 41.820 matchten byte voor byte.
We zaten op dag drie.
Doodlopende weg twee: het destination-schema
Volgende theorie: de destination-paragraph-entiteit was het probleem. Misschien liet een field-type-mismatch stilletjes waarden vallen, of miste onze paragraph-type-config een verplichte setting en at de destination-plugin de cast op. We bouwden het paragraph-type vanaf nul opnieuw, exporteerden de config, diffden hem tegen een verse module-installatie, controleerden elke storage-setting van elk veld dubbel en draaiden de migratie opnieuw in een schone MySQL-database. Zelfde resultaat: paragraph-entiteiten aangemaakt, payload leeg.
We zaten op dag vijf en begonnen aan dat ding dat je doet als een migratie is vastgelopen: een mail opstellen over het opschuiven van de go-live-datum.
Wat er echt fout zat
De bug zat in de revision lookup, maar niet waar wij hadden gekeken. Dit is de keten die we uiteindelijk traceerden:
- De Paragraphs-migratieplugin leest
field_revision_field_specsuit de D7-source om te vinden welke collection-items bij welke node-revisie horen. - Voor elk collection-item zoekt hij vervolgens de nieuwste revisierij op in
field_collection_item_revisionom de paragraph-velden te vullen. - De "nieuwste revisie"-lookup gebruikt
MAX(revision_id). Niet derevision_iddie door de parent node is vastgezet. - Op de site van onze klant hadden de meeste collection-items een "nieuwste" revisie die was aangemaakt door een nooit gepubliceerd concept uit een redactionele workflow van 2018. Die revisies waren met lege waarden opgeslagen om het formulier leeg te maken, met de bedoeling ze later weer te vullen. De editors zijn nooit teruggekomen.
Dus de migratie las uit een echte rij. De rij was leeg. De migratie kopieerde leeg trouw door naar de paragraph-velden. Geen fouten, want leeg is een geldige waarde.
De geserialiseerde PHP maakte de diagnose lastiger, niet de bug zelf. De field_extra_attrs-kolom was de enige plek waar we een verschil konden zien tussen het verlaten concept en de revisie waar de node feitelijk naar wees, omdat text_long-waarden in het verlaten concept niet NULL waren maar een andere geserialiseerde payload. Toen we de twee revisiesets uiteindelijk rij voor rij vergeleken, sprong het patroon er binnen een uur uit.
Het gênantste van de post-mortem: twee senior Drupal-developers in het team, samen drieëntwintig jaar D7 in productie, en geen van beiden hadden we dit specifieke faaltype eerder gezien. We hadden allebei drie of vier contrib-migratiepaden meegemaakt die source-data verkeerd lazen. Geen van die paden las een rij die technisch geldig maar semantisch fout was. De fix kost uren. De diagnose kost dagen, omdat niets in het dashboard of de logs zichzelf ooit aankondigt als de bug. Elke rij leest als success.
Field Collection in Drupal 7 hield zijn eigen revisiegeschiedenis bij, los van de parent node. Elke migratieplugin die "de nieuwste revisie" ophaalt in plaats van "de revisie die de parent heeft vastgezet", pakt op langlopende sites stilzwijgend verlaten concepten mee. De default plugin van Paragraphs-contrib doet het eerste.
De fix
We schreven een custom source-plugin die d7_field_collection_item_revision wrapt en dwingt om de revision_id te respecteren die is opgeslagen bij de gepubliceerde revisie van de parent node. De override zag er ongeveer zo uit:
namespace Drupal\hasselt_migrate\Plugin\migrate\source;
use Drupal\paragraphs\Plugin\migrate\source\d7\FieldCollectionItemRevision;
class PinnedRevisionItem extends FieldCollectionItemRevision {
public function query() {
$query = parent::query();
$query->innerJoin(
'field_revision_field_specs',
'fr',
'fr.field_specs_revision_id = fci.revision_id
AND fr.revision_id = (
SELECT vid FROM node WHERE nid = fr.entity_id
)'
);
return $query;
}
}
Hierdoor kwam de collection-item-revisie binnen die de node feitelijk renderde, niet welke revisie toevallig het hoogste ID had. We voegden ook een process-plugin toe om de payload van field_extra_attrs te unserialize()en en op te splitsen over drie nieuwe paragraph-velden, in plaats van een geserialiseerde blob mee te slepen naar een moderne stack. PHP-objectdeserialisatie is een bekend aanvalsoppervlak, en een nieuw CMS is het juiste moment om er afscheid van te nemen.
De cleanup over de half-geïmporteerde tabellen was het makkelijke deel. Drupal's migratie-tooling verwacht dat je rollbackt in plaats van DELETE: elke destination-plugin weet welke rijen hij heeft aangemaakt en kan ze in de juiste volgorde droppen zonder foreign keys te breken. We draaiden drush migrate:rollback per migratie-ID, zagen de paragraph- en revisietabellen netjes leeglopen en draaiden de hele pipeline opnieuw tegen de gecorrigeerde source-plugin. Totale wall-clock voor de gecorrigeerde rerun was 4 uur 12 minuten op een 16 GB EC2-box met de source-MySQL gemount als RDS read replica. Spec-tabellen kwamen gevuld terug. De lege concepten bleven in D7, waar ze hoorden.
Wat we anders zouden doen
Drie dingen, op volgorde van hoeveel pijn elk ervan ons had bespaard.
Diff revisies voor je de source vertrouwt
De snelste manier om deze klasse van bug te vangen is een query van vijf minuten voor je begint met migreren. Vergelijk voor elk entiteitstype met revisies het aantal rijen van "nieuwste revisie" met "revisie vastgezet door parent". Als die getallen meer dan 1 of 2 procent verschillen, zitten er verlaten concepten in je source-data, en elke migratieplugin die default op MAX(revision_id) staat, gaat tegen je liegen. We draaien deze diff nu als eerste artefact van elke D7-audit, voordat we ook maar naar de modulelijst kijken.
Behandel geserialiseerde velden als aparte migratiestap
Elk tekstveld waarvan de inhoud begint met a:, O: of s: is geserialiseerde PHP. Pak een sample met een SQL-query van één regel, beslis welk schema je eigenlijk wilt, en schrijf een eigen process-plugin om de payload over echte kolommen of een JSON-veld te splitsen. Sleep de blob niet mee. De blob overleeft elke developer die wist wat hij betekende, en de volgende die de data aanraakt zit een cronscript uit 2013 te reverse-engineeren.
Draai de volledige migratie op dag één naar staging
Onze fout bleef zestien uur bestaan in een groen dashboard omdat niemand naar de gerenderde output keek. Eén content editor die op dag één rondklikt in een staging-kopie had de lege spec-tabellen gevangen voor we gehecht raakten aan onze theorie over de bug. Goedkope verzekering, en de mensen die je het liefst hebt klikken (de editors die de catalogus echt kennen) zijn meestal blij dat je het vraagt. We vragen de klant nu één editor aan te wijzen wiens enige migratie-verantwoordelijkheid twintig minuten per dag met koffie is: URL's openen uit een vastgelegde sample. Ze testen niet op performance of design. Ze testen op aanwezigheid. Landden de woorden?
Het kleinste wat je vandaag kunt doen
Draai je nog een Drupal 7-site met Field Collection-velden (de module staat op maintenance mode, maar is niet dood), open dan een database-client en draai:
SELECT COUNT(*) AS abandoned_drafts
FROM field_collection_item fci
WHERE fci.revision_id != (
SELECT MAX(revision_id)
FROM field_collection_item_revision
WHERE item_id = fci.item_id
);
Is het getal groter dan nul, dan heb je hetzelfde type probleem als wij. Het bijt je niet zolang de site op Drupal 7 staat. Het bijt je op de dag dat je begint met de upgrade.
Toen we de rest van de upgrade voor de klant in Hasselt draaiden, en de twee andere Belgische sites die via doorverwijzing kwamen, bouwden we de diff-revisiescheck in onze standaard kickoff voor legacy-migraties. Sindsdien verliezen we geen dagen meer aan verlaten concepten. De geserialiseerde-PHP-uitpakker is nu de tweede migratie die we schrijven op elk D7-project, voor iemand het over thema's heeft.
Kern
Field Collection-migraties naar Paragraphs pakken standaard de nieuwste revisie. Op langlopende sites is dat vaak een verlaten concept, niet de gepubliceerde versie.
FAQ
Waarom gooide de migratie geen error toen de data ontbrak?
Leeg is een geldige veldwaarde. De Paragraphs-migratieplugin kopieerde wat hij in de source-rij vond. Er werd geen fout opgeworpen omdat er technisch niets mis was: de source-revisie bevatte écht lege waarden.
Moeten nieuwe Drupal-sites nog Field Collection gebruiken?
Nee. Field Collection staat op maintenance mode. Paragraphs is de standaardkeuze voor het groeperen van velden op Drupal 9 en 10. Field Collection introduceren op een nieuwe build levert je alleen toekomstige migratie-hoofdpijn op.
Is geserialiseerde PHP in een tekstveld altijd een bug?
Niet altijd, maar het is een smell. Het verbergt structuur voor de database, breekt indexering en creëert een deserialisatie-oppervlak. Bij elke migratie hoort het uitgepakt te worden naar echte kolommen of een JSON-veld.
Hoe lang duurt een upgrade van Drupal 7 naar 10 meestal?
Voor een content-zware site met custom modules is drie tot acht weken gerichte werktijd realistisch. Verborgen verrassingen zoals die in deze post zijn meestal de reden dat projecten aan de bovenkant van die range uitkomen.