Upgrading a Drupal Multisite Platform from 9 to 11
Over the last couple of years I took a Drupal platform of 88 sites on Acquia Site Factory from Drupal 9 to 10, then through 10.2, 10.3 and 10.4, and finally to Drupal 11. One codebase, one deploy, every site upgraded at the same time. A single site upgrade is a well-documented path. A multisite upgrade is the same path, except every rough edge happens 88 times, and each site's database has its own history.
These are the lessons I would hand to anyone starting the same journey.
Start earlier than you think
The first lesson is about the calendar, not code. Moving to a new major version took us at least two to three months every time: code changes, contributed module waiting, a full QA pass on every brand, then the deploy. If you start when the old version is a few months from end of life, you will be rushing at exactly the moment you should be careful.
What worked for us was simple: watch Drupal core releases daily and apply security and patch releases straight away, and start preparing for the next major or minor every six months. The release cycle page tells you exactly when each version stops being supported, so there is no excuse for being surprised. PHP has the same kind of calendar. When PHP 8.1's security support was ending, we had to plan the move to 8.2, but the hosting platform was not ready for it yet. Knowing this early meant we could wait without panic.
Audit every module before you touch core
The upgrade itself is a few commands:
composer update drupal/core drupal/core-composer-scaffold \
drupal/core-project-message drupal/core-recommended --with-all-dependencies
drush updb -y
drush cex -yWhat blocks you is everything around core. Before the Drupal 10 move I went through every package in composer.json and sorted it into three buckets: ready, replaceable, and "we can delete this". The delete bucket was bigger than expected:
- Modules with no Drupal 10 release at all, like a colour picker field, a Markdown filter and an OpenAPI UI that nobody had opened in years.
- Modules that came in as dependencies of something else. The Lightning distribution pulled in a moderation dashboard whose version did not support Drupal 10, and a translation integration required a suite version with the same problem.
- One module whose project page said it supported Drupal 10, but its
composer.jsonon the release branch did not. Always check the actual constraint, not the badge.
Our own custom modules and themes needed work too. Every info.yml got core_version_requirement updates and every composer.json got a wider Drupal constraint. Deprecated calls such as file_create_url() had to be replaced, and Twig templates that put an if inside a for loop needed rewriting.
Sometimes the newest version of a module is the problem. In one release we pinned Webform, Migrate Plus and Filelog to older versions because the latest ones broke on our platform, and we held off on Drush 13 for a while. Write those pins down with the reason, or someone will "fix" them in the next composer update.
Removing a module is harder than installing one
On one site, uninstalling a module is drush pmu. On a platform that has been alive for years, many sites had modules listed in core.extension whose code was already gone. Drupal then refuses to import config or run updates, because it thinks the module is still installed.
The fix is to remove those entries from core.extension directly, once you have confirmed the module really is gone and its config is cleaned up:
drush @app.env sfml eval '
$modules = \Drupal::config("core.extension")->get("module");
unset($modules["lightning_api"], $modules["entity_share"], $modules["filelog"]);
\Drupal::configFactory()->getEditable("core.extension")
->set("module", $modules)
->save();
'The same trick works for themes under the theme key. This became part of almost every release: one line per retired module, run on every site with acsf-tools before the new code arrives.
Config import does not scale the way you hope
Most Drush commands can run across all sites in one go: updb, cr, enabling a module, deleting a config object. Config import was the exception. Running it in bulk made the server run out of memory, and on the dev environment it sometimes reported success after importing only part of the config.
Two things helped. First, raising the environment's PHP memory limit from 512 MB to 1024 MB for the duration of the deploy, then setting it back. Second, cleaning up the configs that made the import fail before running it: config from deleted modules, leftover test content types, and fields that referred to removed media types.
When the field definition and the database disagree
After one of the 10.x upgrades, drush entup kept reporting a mismatch on the Layout Builder field of nodes. The data in the database was fine. Only the stored field definition was out of date. Deleting and recreating the field would have destroyed every page layout on 88 sites, so that was not an option.
The safe way out was to tell Drupal to store the definition from code as the installed one, without touching the table:
drush en devel_entity_updates -y
drush php-eval "
\$fields = \Drupal::service('entity_field.manager')
->getFieldStorageDefinitions('node');
\Drupal::entityDefinitionUpdateManager()->installFieldStorageDefinition(
'layout_builder__layout', 'node', 'layout_builder',
\$fields['layout_builder__layout']
);
"
drush entup -y
drush pmu devel -yI took a similar approach for view modes that were in a broken state: delete the config object, then load the YAML from the config directory directly into the config factory. Small, explicit, and easy to repeat on every site.
Retire the distribution, not just the version
The platform had started life on the Lightning distribution, and Lightning was a recurring source of trouble during upgrades. Over several releases we removed its modules one by one, deleted their leftover config objects, and replaced what we still needed with core or smaller modules.
The admin theme had to move as well. Before the Drupal 10 deploy every site got Gin installed and set as the admin theme, and old themes like Bartik were uninstalled:
drush theme:install gin --uri=<site>
drush cset system.theme admin gin -y --uri=<site>
drush theme:uninstall bartik --uri=<site>Doing these steps in earlier, smaller releases meant the actual version jump carried less risk.
The deploy checklist
Every upgrade ended with the same shape of checklist. The commands changed from release to release, but the order did not:
# before the code deploy
# raise the PHP memory limit for the environment (512 MB -> 1024 MB)
drush @app.live sfml pmu <modules removed in this release>
drush @app.live sfml cdel <config that belongs to them>
drush @app.live sfml eval '<unset orphaned entries in core.extension>'# after the code deploy
drush @app.live sfml updb -y -vvv
drush @app.live sfml cim -y
drush @app.live sfml cr
drush @app.live sfml search-api-index
# set the memory limit backA few other items were on it every time:
- Run the whole upgrade on the test environment first, with a copy of production data, and let QA test every brand there.
- Tag a release of the current production code before merging the upgrade branch, so going back is one deploy away.
- Make sure no site is left in maintenance mode after the deploy. A failed update hook can leave the flag on silently.
- Reindex search after a Solr or Search API version change. The site works without it, but search results quietly go stale.
- Transfer ownership of sites created by people who have left the company. On Site Factory, some operations fail for sites whose owner account no longer exists.
What I would do the same way again
Clean up in small releases before the big one. Every module removed, every orphaned config entry fixed and every theme moved to Gin in an earlier deploy was one less thing that could go wrong during the version jump.
And write everything down while you do it. Most of this post comes from notes I kept during the upgrades: every command, every pinned version, every "this broke, here is why". The next upgrade always starts with reading the notes from the last one.