← Blog

Drupal

Drupal 7 to Drupal 11: a 1,400-page migration playbook

A Tuesday in May. The Drupal 7 site has been past end-of-life for sixteen months. 1,400 pages, seven untouched Feeds importers, and a workflow nobody documented.

Jacob Molkenboer· Founder · A Brand New Company· 11 Jun 2026· 11 min
Closed leather logbook with brass key on cream card, twine-tied tag, green ribbon bookmark on ivory paper.

Tuesday in May. The phone call lasted four minutes. A Utrecht housing corporation, sixteen months past Drupal 7 end-of-life, had been quoted €180,000 by a previous agency for a full rebuild. Their actual ask was smaller: 1,400 pages, seven Feeds importers that pull rental property data nightly, and a Workbench Moderation workflow that the original developer left behind in 2018. Move it forward. Don't lose anything.

Drupal 7 has been formally end-of-life since January 5, 2025. The Security Team stopped publishing advisories. Composer-based dependencies are rotting. The site still ran on the day we got the call, but every contrib module download was a 404 waiting to happen.

Here is the playbook we ran.

A 90-minute audit before any code

We did not open a code editor for the first ninety minutes. The audit ran against the live database over a read-only replica.

drush @d7 status
drush @d7 pm:list --status=enabled --no-core --format=table > enabled-modules.txt
drush @d7 sql:query "SELECT type, COUNT(*) FROM node GROUP BY type" > content-counts.txt
drush @d7 sql:query "SELECT name, state FROM workbench_moderation_node_history" > moderation-history.txt
drush @d7 sql:query "SELECT id, fetcher, parser, processor FROM feeds_importer" > feeds-inventory.txt

Fifteen minutes of querying gave us five real numbers:

  • 47 contrib modules enabled, of which 12 had no Drupal 8+ port.
  • 1,387 published nodes, 213 drafts, 41 in "needs review".
  • 3 active workflow states, plus 1 deprecated "Archive" state still referenced by 17 nodes.
  • 7 Feeds importers, 4 firing on cron, 3 dormant since 2019.
  • 2 custom modules totalling about 1,800 lines of PHP.

The surprise was that Archive state. The previous team had migrated off it in 2020 but never deleted the references. If you migrate blindly, those 17 nodes land in Drupal 11 pointing at a state that no longer exists. The migration fails halfway, the transaction rolls back, and you spend Thursday afternoon trying to figure out why one node ID keeps appearing in the error log.

Warning

Before you write a single migration plugin, query the moderation history table for orphan states. Every legacy Drupal site has them. They will fail the import silently if you have logging set to anything less than verbose.

Mapping Workbench Moderation onto Content Moderation

Workbench Moderation was a Drupal 7-only module. In Drupal 8 it was rewritten as Content Moderation in core, with Workflows providing the state machine.

The mapping is not automatic. State names persist, but the underlying entity model changed. A D7 node had a single moderation state per revision. A D11 node has a moderation_state field that references a Workflow config entity. The migration has to:

  1. Create the Workflow entity once, before any node migration runs.
  2. Create the states inside that workflow, matching the D7 state names.
  3. Define transitions, because Workbench Moderation stored them in a workbench_moderation_transitions table that has no D11 equivalent.
  4. Migrate per-revision states using a custom process plugin.

The workflow definition lives in a config YAML. The shape we used:

langcode: en
status: true
id: editorial
label: Editorial
type: content_moderation
type_settings:
  states:
    draft:
      label: Draft
      weight: 0
      default_revision: false
      published: false
    needs_review:
      label: 'Needs review'
      weight: 1
      default_revision: false
      published: false
    published:
      label: Published
      weight: 2
      default_revision: true
      published: true
  transitions:
    submit_for_review:
      label: 'Submit for review'
      from: [draft]
      to: needs_review
      weight: 0
    publish:
      label: Publish
      from: [needs_review]
      to: published
      weight: 1

For the 17 orphaned Archive nodes, we mapped them to draft and logged each one. The editor at the housing corp reviewed the log, restored 4 to published, and authorised deletion for the rest.

The seven Feeds importers, triaged

The Feeds module in Drupal 7 is not the Feeds module in Drupal 11. Same name, different code, different config schema.

We triaged the seven importers against four questions. Is it still firing on cron and producing data the site needs? Does the source format (CSV, XML, JSON, RSS) still exist? Is the parsing logic trivial enough to express as a Migrate plugin, or does it need Feeds proper? Will the source change in the next 24 months?

Verdicts:

  • 4 importers stayed in Feeds. The contrib module has a D8+/D11 port. CSV and JSON parsers carried over. We rewrote the field mappings.
  • 2 importers became custom Migrate plugins. They were one-off XML feeds with bespoke field logic, easier as plain Migrate YAML than as Feeds tamper rules.
  • 1 importer got deleted. It had been pulling from a SOAP endpoint that the municipality deprecated in 2022. Nobody at the client knew it was dead.

The two-sentence takeaway for any Feeds-heavy D7 site: do not assume one-for-one. The Migrate API is now expressive enough to replace half of what Feeds used to do, and you remove a contrib dependency in the process.

A clean Drupal 11 host, not an in-place upgrade

There is a school of thought that says you should run the core migration in place. Install Drupal 11 over the same database, let drush migrate:import move content node by node into the new schema.

We have done it. It is faster the first time and miserable the second. The D7 database carries fifteen years of legacy: orphaned URL aliases, taxonomy terms referencing deleted vocabularies, file_managed rows pointing at files that fell off the disk in 2019. An in-place migration drags every one of those into D11.

A clean Drupal 11 install on a fresh host, pulling from the D7 database over a read-only connection, lets you decide what crosses the line.

composer create-project drupal/recommended-project:^11 utrecht-housing
cd utrecht-housing
composer require drush/drush:^13
composer require drupal/migrate_plus drupal/migrate_tools drupal/migrate_upgrade

In settings.php, the legacy connection:

$databases['migrate']['default'] = [
  'database' => 'utrecht_housing_d7',
  'username' => 'd7_readonly',
  'password' => '...',
  'host'     => '10.0.4.12',
  'driver'   => 'mysql',
  'prefix'   => '',
];

Read-only is not optional. Certain plugins can write back to the source if you let them, and you do not want a failed import to corrupt the only working copy.

Running the migration in three passes

The full migration set runs in about 1.5 hours on the production-spec host. We split it into three groups so we can stop, inspect, and rerun.

Pass one is users, taxonomy, file metadata. No content yet.

drush migrate:import --group=d7_users
drush migrate:import --group=d7_taxonomy
drush migrate:import --group=d7_file_metadata

Pass two is files and media. The D7 site used the older file field; D11 uses Media entities. We bridged with migrate_file_to_media from contrib.

Pass three is content, in dependency order. Pages first, then articles, then the property nodes that reference both. Workbench Moderation states attach at this stage via the custom process plugin.

public function transform($value, MigrateExecutableInterface $executable, Row $row, $destination_property) {
  $map = [
    'draft'        => 'draft',
    'needs_review' => 'needs_review',
    'published'    => 'published',
    'archive'      => 'draft', // logged separately for editor review
  ];
  if (!isset($map[$value])) {
    throw new MigrateSkipRowException("Unknown D7 state: {$value}");
  }
  return $map[$value];
}

The MigrateSkipRowException is the workhorse. It is the difference between a migration that fails silently and a migration that tells you exactly which row to fix.

Diff scripts, not eyeball QA

Nobody can eyeball-check 1,400 pages. We wrote three diff scripts.

Count diff: every content type, every workflow state, every taxonomy term, count in D7 vs count in D11.

#!/usr/bin/env bash
for type in page article property news; do
  d7=$(drush @d7 sql:query "SELECT COUNT(*) FROM node WHERE type='$type'" | tail -1)
  d11=$(drush @d11 sql:query "SELECT COUNT(*) FROM node_field_data WHERE type='$type'" | tail -1)
  printf "%-12s d7=%5d  d11=%5d  diff=%d\n" "$type" "$d7" "$d11" "$((d7-d11))"
done

Body diff: pull the rendered HTML of every node on D7 staging and D11 staging, normalise whitespace, and compare. We accept theme drift and new wrapper divs. Anything else flags for review.

URL diff: every D7 URL alias has to resolve to the same entity on D11, or 301 to a new path the client approved. We crawled both sides and compared.

On Utrecht, the URL diff caught 23 mismatches the content team had not noticed. Nodes that had two aliases in D7, one Dutch and one English, where only the Dutch alias carried into D11. Without the diff, those would have shipped as 404s on launch day.

What we run on launch day

A staging-to-production cutover is its own playbook. Short version: content freeze announced 48 hours ahead, maintenance mode on D7 the morning of, final migration delta, DNS swap, redirect map deployed, monitoring dashboard pinned to a second screen for the first 24 hours.

The smallest thing you can do today: run the five-line audit from the top of this post against your own Drupal 7 site. If you find a workflow state nobody remembers configuring, or a Feeds importer nobody can name, you have just learned the most expensive part of your migration. When we built this rebuild for the Utrecht housing corp, the work that ate the most days was not the content. It was the Feeds triage and the orphan-state cleanup. We do this kind of work as legacy migration, and the playbook above is roughly what we run on every D7 site we take on.

Key takeaway

The expensive part of a Drupal 7 migration is not the content. It is the orphaned states and dormant Feeds importers nobody can explain.

FAQ

Is in-place D7 to D11 upgrade ever the right call?

Only for small, clean sites under 100 nodes with no contrib moderation or Feeds usage. For anything larger, a fresh D11 install pulling from a read-only D7 source gives you a chance to cut the cruft.

Does Workbench Moderation migrate automatically to Content Moderation?

No. States and transitions need a hand-built Workflow config entity, plus a custom process plugin to map per-revision state values. Orphan states will silently fail the import.

Can the D7 Feeds module be one-for-one ported to D11?

Partially. The Feeds contrib module has a D8+/D11 port, but config schema changed. Simple CSV and JSON importers carry over. Bespoke parsers are often easier rewritten as Migrate plugins.

How long does a 1,400-page migration take to run?

About 1.5 hours on production-spec hardware for the full set. Split into three passes (users/taxonomy, files/media, content) so you can stop, verify, and rerun a single group if something fails.

Is Drupal 7 still safe to run in 2026?

No. End-of-life passed on January 5, 2025. No security advisories are issued, and contrib modules have stopped receiving patches. Every month you delay raises the cost of the next forced fix.

drupalmigrationlegacy sitesphpcase studyarchitecture

Building something?

Start a project