Drupal
Drupal 7 to Drupal 11 migration: hospital intranet playbook
The Drupal 7 intranet had been quietly running a Limburg hospital for eight years. Six thousand nodes, seventeen custom content types, and an AD SSO nobody had touched since 2018.

The site map landed in our inbox as an Excel export from a server that ran PHP 7.4 and had not been rebooted since a Christmas Eve power cut in 2023. Six thousand two hundred and four published nodes, seventeen custom content types, four roles, and a sentence at the bottom of the spreadsheet: AD-login werkt nog, niemand weet meer hoe. The hospital's intranet had been quietly running on Drupal 7 since 2018. EOL had come and gone. The team needed Drupal 11, and they needed it without breaking the way nurses found patient handover forms at 06:50 on a Monday.
This is the playbook we used to move that site. The shapes generalise to any heavily customised Drupal 7 install that has outlived its documentation.
Why the in-place upgrade is not the playbook
Drupal 7 to Drupal 11 is not an upgrade path. It is a migration. The Migrate API in Drupal 10 and 11 reads from a Drupal 7 source database and writes into a fresh Drupal install, one content type at a time. That sounds like a chore. It is the kindest tool you will get.
The temptation, especially with seventeen custom types, is to take the small-steps route: 7 to 8, 8 to 9, 9 to 10, 10 to 11. Do not. Drupal 8 reached end of life in November 2021. Composer-based 8 sites are scarce on the ground and the maintainers' patience is finite. Every intermediate stop adds a database state nobody on the team will ever boot again. The Migrate API path is one staging environment, one runbook, one cutover.
Read the official path before you start. The Drupal core upgrade documentation is honest about which modules have a stable migration path and which do not.
The inventory week
Before any code, we ran a week of pure discovery. The order matters.
First, an audit table of every content type and its field instances, exported with drush field-info --format=json. Seventeen rows with hundreds of fields between them. Two of the custom types turned out to be near-duplicates created by two different department heads in 2019. We flagged them for a merge during migration. Three more had field_collection instances, which is the moment any seasoned Drupal team breathes in. Field Collection has no first-party migration into Paragraphs. There is a contributed module called Field Collection Migrate, but it carries assumptions. Test it on a sample type before you commit.
Second, a node count by type and by year. The intranet had a content decay curve. About 38% of the nodes had not been edited since 2020. We sat down with the comms lead and the head of nursing and asked which types were still load-bearing. We ended up archiving four content types into a read-only HTML snapshot on a subdomain and migrating only thirteen.
Third, the SSO archaeology. That gets its own section.
The cheapest migration work happens before any code. Every content type you do not migrate is a week you do not spend in field mapping.
Reverse-engineering an AD SSO from 2018
The hospital's intranet logged users in against the on-site Active Directory. Nobody on the current team had touched the integration. The 2018 contractor was no longer trading. The documentation was one Confluence page titled SSO instellen with a single screenshot of a PuTTY window.
We worked it out from the database and the file system.
The system table told us which modules were enabled. Two were relevant: ldap_authentication and simplesamlphp_auth. The variable table held the configured endpoints. The actual SAML certificate and metadata lived under /var/simplesamlphp/cert/, owned by www-data, mode 640, last modified 14 February 2019.
The certificate had not been rotated in seven years. It had also not expired, because the AD admin had set the lifetime to twenty years in a moment of optimism. We treated that as a gift and as a finding worth writing down.
For the new build, we did not port the old integration. SimpleSAMLphp is still maintained, but its Drupal module has had a thin time of it through the D9 and D10 transitions. We replaced it with the SAML Authentication module, a clean wrapper around onelogin/php-saml. The hospital's AD FS endpoint accepted the new service provider metadata on the first request, which is the kind of small mercy you take and do not look at.
Do not migrate the user table as-is. The Drupal 7 user UIDs will collide with the Drupal 11 admin users you create during setup. Use the Migrate API's migration_lookup plugin to remap, or accept that UID 1 is going to be a new account.
The Migrate API in practice
The Migrate API has three moving parts: a source plugin that reads the D7 database, a process pipeline that transforms each field, and a destination plugin that writes the D11 entity. Drupal core ships the source and destination plugins for nodes, users, taxonomy, files, and menus. The process plugins are the ones you actually write code for.
A migration definition lives in YAML, either under config/sync/ or inside a custom module's migrations/ directory. Here is the shape we used for one of the hospital's custom content types, protocol:
id: hospital_protocol
label: 'Protocol nodes from D7'
migration_group: hospital
source:
plugin: d7_node
node_type: protocol
process:
type:
plugin: default_value
default_value: protocol
title: title
uid:
plugin: migration_lookup
migration: hospital_users
source: node_uid
field_afdeling:
plugin: migration_lookup
migration: hospital_taxonomy_afdeling
source: field_afdeling
'field_protocol_text/value': 'field_protocol_text/value'
'field_protocol_text/format':
plugin: static_map
source: 'field_protocol_text/format'
map:
filtered_html: basic_html
full_html: full_html
plain_text: plain_text
destination:
plugin: 'entity:node'
migration_dependencies:
required:
- hospital_users
- hospital_taxonomy_afdeling
- hospital_files
Three things in that file earned their place.
The migration_lookup plugin is how the new site finds the D11 user that corresponds to the old D7 user. If the user migration has not run, the lookup returns null and the node gets assigned to UID 0. You either run migrations in dependency order, or you accept anonymised nodes and run a fix-up pass later.
The text format static_map is the small surprise. D7's filtered HTML format does not exist on a fresh D11 install. If you do not map it, your bodies migrate but every node renders as escaped HTML, which is funny once and then expensive.
The migration_dependencies block makes drush migrate:import --group=hospital resolve order for you. Use it. The alternative is a runbook with twenty-three step numbers and a quiet panic at step nineteen.
Files and media
The intranet had 11,400 files attached to nodes: PDFs of patient information leaflets, a few thousand intranet photos, a worrying number of Word documents. Drupal 7 stored these as file entities. Drupal 11 wants media entities wrapping file entities, with a media type per file purpose.
You can migrate file to file directly and skip media. Do not. The intranet's editorial workflow uses media library, and any new content created after cutover will use media entities. If you migrate the old files as files only, you end up with two parallel systems and editors who never know which one to use.
The path that worked: migrate files first into the public file system, then run a second migration that wraps each file in a media entity of the right type, then update the node migrations to point their file fields at the new media entities. Three passes, but each one is debuggable on its own.
Module triage
The D7 install had 86 contributed modules. Forty-three of them had a clean D11 successor, twenty-one had a successor with a breaking config change, eleven had been superseded by core, and the rest were either abandoned or replaced by a custom module that should have been a feature flag. We made a spreadsheet, one row per module, with columns for the D11 status, the editor or workflow that depended on it, and the cost of doing without.
A third of the modules came out in the wash. The hospital had been carrying a Webform install for a single PDF download form that we replaced with a static link, and a workflow module that nobody had configured. A migration is the only time you get free permission to delete things.
Staging rehearsal and rollback
We rehearsed the full migration three times before cutover. Each rehearsal ran against a fresh copy of production. Each rehearsal produced a delta report: how many nodes migrated, how many had warnings, how long the import took, how many files failed to copy. The third rehearsal took 47 minutes end to end on a four-core staging box. That number went into the cutover window.
The cutover itself was a Friday night between 22:00 and 23:30. We froze content editing at 21:00, took a final SQL dump, ran the migration on the new infrastructure, switched DNS, and watched the SSO. The first nurse logged in at 06:43 on Saturday. The dashboard looked the same. The URLs were the same. The bones underneath were not.
The rollback plan was a single sentence: keep the old VM running, read-only, at oud.intranet.[redacted].nl, for thirty days. We never used it. It is still the cheapest insurance we have ever shipped.
What we would do differently
Two things.
One: we underestimated how much editorial content needed to be rewritten, not migrated. Some protocols had been edited so many times that the markup was a mosaic of WYSIWYG decisions from a decade of staff turnover. We migrated them and then a clinical editor spent two weeks cleaning them up. That work should have been scheduled before cutover, not after.
Two: we should have shipped a content audit dashboard on day one. By the time we built it in week six, the comms lead had already made the keep-or-archive decision based on instinct. Her instinct was right, but a chart would have made the conversation faster, and the audit trail would have made the next steering committee meeting shorter.
The smallest thing you can do today
If you are sitting on a Drupal 7 site and the EOL date is making you nervous, do not start with code. Run drush field-info --format=json > fields.json against your production database. Open the file. Count your custom content types. If the number surprises you, that is your real project plan.
When we built the migration for this hospital intranet, the part that almost broke us was not the seventeen content types or even the SSO. It was the field-collection-to-paragraphs jump. We solved it by writing a small custom process plugin that flattened single-item collections back into native fields and only used Paragraphs for the multi-item ones. If your stack is in the same shape, our legacy migration work is built around exactly this kind of audit week.
Key takeaway
Drupal 7 to Drupal 11 is a migration, not an upgrade. Spend the first week on inventory and SSO archaeology before you touch a Composer file.
FAQ
Can you upgrade Drupal 7 to Drupal 11 in place?
No. Drupal 7 to 11 is a migration, not an upgrade. You stand up a fresh Drupal 11 install and use the core Migrate API to pull content, users, taxonomy, and files from the D7 database.
How long does a 6,000-node Drupal 7 migration take?
Plan four to six weeks of build for a heavily customised site: one week of inventory, two to three weeks of migration code, one week of rehearsals, and a single cutover window.
What happens to existing URLs after the migration?
If you migrate the URL alias table and keep node IDs stable, public URLs survive. Verify with a crawl of the old site against the new one before DNS cutover, and keep redirects ready for any path that changes.
How do we handle Drupal 7 modules with no Drupal 11 version?
Triage them in week one. Most have a D11 successor or a core equivalent. The rest are usually carrying a feature you can either delete, rebuild as a small custom module, or replace with a static link.
What about Field Collection on Drupal 7?
Field Collection has no first-party migration into Paragraphs. Use the Field Collection Migrate contrib module for multi-item collections, and flatten single-item collections back into native fields with a custom process plugin.