Drupal
Drupal 7 to WordPress migration: the serialized-array trap
An eleven-day migration freeze caused by a single serialized PHP array. The exporter ran clean, the import looked fine, the lead-routing was dead.

The clock on the verkoop-binnendienst monitor in Brugge read 17:48 on a Tuesday in March when the inside-sales lead sent a single, blunt message to her project manager. "Vier dagen, geen enkele offerte-aanvraag." Four days, zero quote requests reaching her team through the new website. The form was visible on the homepage. The submit button worked. Test submissions landed in the catch-all inbox. Real ones, from real visitors, were disappearing. The site had been Drupal 7 for nine years. As of two Tuesdays ago, it was WordPress.
We had inherited the migration twenty days earlier from another agency that ran out of budget and patience. The brief on paper was simple: move a Drupal 7 site for a 27-person bouw-installateur to WordPress 6.5 before Drupal 7 hit end of life, keep the SEO, keep the lead intake. The form on the homepage had captured roughly 3,800 historical offerte-aanvragen and routed live ones across 1,240 conditional branches into eight inside-sales queues based on postcode, installation type, and a hidden field marking whether the visitor was a private homeowner or a building contractor.
The export that looked clean
The previous agency had used a well-known Drupal-to-WordPress exporter. The dry run produced clean WXR files. The staging environment looked correct. The live form rendered without errors. The handover document said "Webform migration complete, fields verified." We checked the fields. We checked the page rendering. We checked the post-submit redirect. Everything passed.
What we did not check, because it did not occur to us to check, was whether the conditional logic between the fields had survived the trip. The fields were there. The labels were there. The required flags were there. The if-this-then-show rules that decided which sales rep would see the lead had been quietly dropped on the floor.
So the form worked. Every submission triggered exactly one routing branch: the default one. The default destination was the catch-all inbox that nobody on the verkoop-binnendienst monitored, because for the previous nine years that inbox had only received fallback noise. The team kept watching their named queues. The named queues stayed empty. Four days of leads, gone.
Eleven days of false starts
We spent the first day inside WordPress. We checked the form plugin's logic editor, the email routing rules, the notification settings, the SMTP logs. The conditional rules were missing entirely. We assumed configuration drift, rebuilt three sample branches by hand, watched them fire correctly, and felt smart for about forty minutes.
Day two we widened the search. We pulled SMTP logs from the catch-all inbox: 47 submissions across four days, all marked as auto-routed to the default destination, none touching a named queue. We diffed every table the exporter had touched against a staging snapshot to confirm nothing else had been silently rewritten. The conditional rules were not in a sibling table. They were not in a transient. They were not in post meta with a strange key. They had simply not been written.
Then we opened the spreadsheet of branches the client had given us. 1,240 rows. Each row carried two to five conditions, an output queue, and in roughly a third of cases a hidden tag that fed the CRM. Rebuilding by hand was not a fix. It was a six-week project priced as a two-week one, paid out of our pocket because we had quoted the migration as a fixed-fee transition.
The spreadsheet itself had a history. It had been buried in a March 2022 email thread between the previous agency and the inside-sales lead, forwarded to us on day two with an apologetic note that the client had assumed everyone in the chain already had it. We did not. The previous agency had treated it as reference material for the verkoop-binnendienst, not as a migration artifact, and had never opened it during their attempt. Neither, until we asked for it, had we.
Day three we went back to the Drupal 7 database. The plan was straightforward: pull the routing rules straight from the source, transform them, push them into the WordPress form plugin's schema. The plan dissolved the moment we opened the webform table.
What Webform 4.x actually stored
The Drupal 7 Webform 4.x module kept a per-form configuration blob in a LONGBLOB column called conditionals. The column held the entire conditional ruleset, including action targets, operators, source components, and grouped AND/OR logic, as a single serialized PHP string. Not JSON. Not XML. A serialized PHP array, the kind PHP's serialize() produces and unserialize() can read back.
The actual payload for this site started like this, after we pulled the BLOB and ran it through strings:
a:3:{s:7:"enabled";b:1;s:6:"groups";a:1240:{i:0;a:4:{s:6:"andor";s:3:"and";s:5:"rules";a:3:{i:0;a:3:{s:6:"source";s:8:"postcode";s:8:"operator";s:11:"starts_with";s:5:"value";s:1:"8";}i:1;a:3:{s:6:"source";s:12:"install_type";s:8:"operator";s:5:"equal";s:5:"value";s:10:"warmtepomp";}i:2;a:3:{s:6:"source";s:11:"customer_ty";s:8:"operator";s:5:"equal";s:5:"value";s:7:"private";}}s:7:"actions";a:1:{i:0;a:3:{s:6:"target";s:13:"queue_zeeland";s:6:"action";s:4:"show";s:5:"value";s:0:"";}}s:6:"weight";i:0;}...
That single field, for that single form, was 412 KB of dense serialized PHP describing 1,240 routing decisions. The structure was three levels deep, with cross-references between rule groups and component IDs that lived in a separate webform_component table. Drupal could read it because Drupal wrote it. Anything else needed a translator.
Why the exporter flattened it
The Drupal-to-WordPress exporter we inherited was a community plugin maintained by one person on weekends. It handled posts, taxonomies, users, media, and the visible structure of Webform fields. When it encountered the conditionals blob, it did what naive migration tools do: it treated the column as a string, ran a sanity regex, and when the regex failed to match a pattern it recognised, it wrote an empty value into the WordPress form plugin's equivalent column.
The regex itself was not unreasonable. It looked for the canonical Drupal "source / operator / value" triple in the shape the Webform 3.x branch had used, where conditionals were stored as a flatter array of associative entries. The 4.x rewrite added an outer groups wrapper with per-group weight ordering and an AND/OR toggle, and pushed the triples down a level. The regex had never been updated to match the new shape. Anything serialized in the 4.x format slipped past it without a hit, which the exporter interpreted as "nothing to migrate."
No warning. No log line. No row in the migration report. The export summary said Webform: 1 form, 47 components, 3,812 submissions and a green tick. The conditionals were silently zero.
Any migration tool that touches Drupal 7's Webform 4.x without explicitly naming conditionals in its mapping has almost certainly dropped your routing logic. Diff the count of branches before and after, not just the count of fields.
This is the part of the war story that hurts most in hindsight. The exporter did not lie. It told us, in the summary, exactly what it migrated. It also told us, by omission, exactly what it did not. We did not read between the lines because the spreadsheet of branches was not on our desk that week, and because we trusted the green tick more than we should have trusted a free plugin maintained on weekends.
The repair script
Once we had the structure, the fix was three hundred lines of PHP and two days of testing. The shape of it:
<?php
// pull-conditionals.php, run from the Drupal 7 docroot
require_once 'includes/bootstrap.inc';
drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
$nid = 142; // node id of the offerte-aanvraag form
$row = db_query(
"SELECT conditionals FROM {webform} WHERE nid = :nid",
[':nid' => $nid]
)->fetchObject();
$conditionals = unserialize($row->conditionals);
$components = db_query(
"SELECT cid, form_key, name FROM {webform_component} WHERE nid = :nid",
[':nid' => $nid]
)->fetchAllAssoc('cid');
$out = [];
foreach ($conditionals['groups'] as $i => $group) {
$rules = array_map(function ($r) use ($components) {
return [
'field' => $components[$r['source']]->form_key ?? $r['source'],
'operator' => map_operator($r['operator']),
'value' => $r['value'],
];
}, $group['rules']);
$out[] = [
'logic' => strtoupper($group['andor']),
'rules' => $rules,
'actions' => array_map('normalise_action', $group['actions']),
];
}
file_put_contents('conditionals.json', json_encode($out, JSON_PRETTY_PRINT));
The output was a JSON file with 1,240 rule groups in a flat, transport-friendly format. From there a second script imported into Gravity Forms' conditional logic schema using its REST API, one form at a time, with checksums against the rule count.
The mapping was not one-to-one. Drupal Webform 4.x supported operators that Gravity Forms did not: regex_matches for postcode patterns, a CIDR-style range matcher for IP filtering, and a date-comparison operator that used a custom token format. Gravity Forms' rule engine ran on simple equality and substring matching. We documented seven mismatched operators, translated five into contains approximations the inside-sales lead signed off on after walking through the affected branches on a video call, and flagged two for manual review on each submission until we replaced them with a small Gravity Forms add-on two weeks later.
The replay set was the most valuable artifact of the eleven days. We pulled 200 historical submissions from Drupal's webform_submitted_data table, picked to exercise the rarest postcode prefixes and every distinct customer-type / install-type combination in the spreadsheet. Each one ran through both the original Drupal logic and the new Gravity Forms rules, and the destination queue had to match. The diff hit zero on attempt seven. The first six runs caught two off-by-one weight ordering bugs and a typo in our operator-mapping table that had silently flipped every starts_with into ends_with for one batch of branches.
What we kept from the wreckage
Eleven days. That was the total slip from the moment the inside-sales lead's message landed to the moment her queues started receiving correctly-routed leads again. During those eleven days, the verkoop-binnendienst processed inbound manually from the catch-all, working from a printed copy of the routing spreadsheet. They lost no leads they knew about. They probably lost a handful they did not.
For the first month after cutover we ran a parallel verifier. Every form submission posted to a small Cloudflare Worker that evaluated the original Drupal rule set against the payload and logged any disagreement with the new Gravity Forms routing. Forty thousand submissions later, three disagreements, all on the regex operators we had already flagged for manual rebuild. The Worker was retired in May once the add-on shipped and the disagreement log had been quiet for two weeks.
We also rewrote our migration checklist that quarter. The version we had been working from carried a single line that said "form data" with a checkbox. The new version has fourteen lines, six of which are about conditional logic and routing destinations alone. The first item on it is now: open the source schema yourself, with your own eyes, before you trust any tool's report.
Three habits we now apply to every Drupal-to-anything migration:
- Diff the rules, not just the fields. Count conditional branches, validation rules, email handlers, and webhook destinations before and after. A green tick on field counts means nothing if the logic between fields is gone.
- Read the BLOB columns. Any column named
data,config,conditionals,settings, orextrais suspect. Open it. If it starts witha:orO:, it is serialized PHP and your exporter probably cannot read it. - Demand the routing spreadsheet on day one. Before the kickoff call, ask the client for every routing rule, every conditional email, every CRM tag. If they cannot produce it, that absence is your project's largest unknown.
The bouw-installateur in Brugge is still on WordPress 6.5. The offerte-aanvragen route correctly. The verkoop-binnendienst now has a printed checklist taped to their monitor that says "als een formulier verandert, tel de regels." When a form changes, count the rules. That note was not there before March.
When we took on the legacy migration for this Brugge client, the worst-broken thing was the part that looked most-fixed. We ended up solving it by treating every serialized column as untrusted, unserializing it ourselves, and diffing the structure against the source before we trusted any green tick from a community exporter.
If you are planning a Drupal 7 migration this quarter, the smallest useful thing you can do today is open your webform table, copy the conditionals column for your most important form, and count the rule groups inside it. Whatever number you get is the number your migration needs to land with on the other side.
Key takeaway
On a Drupal 7 Webform migration, the fields are the easy part. The conditional logic lives in a serialized PHP blob, and most exporters drop it silently.
FAQ
Why did the Webform migration fail silently?
The Drupal 7 Webform 4.x module stored conditional logic as serialized PHP in a single BLOB column. The community exporter treated it as a string, did not match a recognised pattern, and wrote an empty value with no warning in the migration report.
How can I check if my Drupal 7 migration dropped Webform conditional logic?
Open the webform table in the source database, unserialize the conditionals column for each form, count the rule groups, then confirm the same count exists in the destination form plugin's schema before you go live.
Is this only a Drupal 7 problem?
No. Any CMS that stores configuration as serialized PHP, including older WordPress plugins and several Joomla components, carries the same risk. Treat any BLOB starting with a: or O: as a translation problem, not a copy problem.
What is the safest way to handle Drupal 7 end of life?
Pick a destination, audit every form, view, and block with custom logic, and budget twice as long as the exporter promises. Drupal 7 reached end of life in January 2025, so unsupported sites are already a security liability.