Drupal
Drupal 11 migration audit: what we check before quoting
The audit we run on every Drupal 8 or 9 site before quoting a Drupal 11 migration. Abandoned modules, custom theme entropy, and the four config-sync traps that ate a week of our last project.

A long-time client emailed us in March. Their Drupal 9 site, an industry portal we had not touched in two years, was suddenly throwing critical security warnings. Drupal 9 went end-of-life in November 2023. They wanted “a quick quote to bump it to 11.” We said no. Not yet. First the audit.
That email is the reason this post exists. Drupal 8-to-11 quotes that skip the audit are how studios end up eating two weeks of unbilled work fixing what they did not price for. So before we send a number, we run the same checklist on every site. It takes a day. It saves the project.
The audit happens before the quote
Drupal 11 was released in August 2024 and the upgrade path is, on paper, a non-event. Composer update, run drush updb, fix any deprecations the rector did not catch. In practice, three things bend that path out of shape:
- Contributed modules that nobody is maintaining anymore.
- A custom theme that has drifted three minor versions ahead of its base.
- The site's exported configuration, full of small lies it tells about itself.
The audit catches all three. We run it in a fresh local copy of the site, pulled with drush sql-sync and drush rsync from production, plus the full config export and a snapshot of composer.lock. Anything less and you are guessing.
Module abandonment
The first thing we run is composer outdated followed by a grep for every contrib module in the project. For each, we open its drupal.org page and check three signals:
- Last commit date. Anything older than nine months is yellow. Older than eighteen months is red.
- Drupal 11 compatibility status. The project page shows a green tick or a “no stable release” warning.
- Issue queue trend. A module with eighty open issues and one merge in a year is dying, even if the maintainer claims otherwise.
We tag each module with one of four labels: keep, replace, fork, or remove. “Replace” means a viable successor exists in core or in a healthier contrib project. “Fork” means we will be patching it ourselves through the migration. “Remove” is reserved for modules whose feature has been quietly dead for two years and nobody noticed.
The Drupal community publishes an upgrade guide that lists which contrib projects are 11-ready. It is a good starting point, but it does not separate modules that are 11-ready because someone pushed a patch yesterday from modules that are 11-ready because they have been frozen and happen to still compile. We check the commit history regardless.
Drupal's security team does not write advisories for unsupported modules. If you keep an abandoned module in production, you are flying without a windsock. Replace or fork before the upgrade, not after.
The deliverable from this section is a flat CSV: module name, action, hours estimate, risk. That goes straight into the quote.
Custom theme entropy
Drupal themes do not rot the way modules do. They drift. Every minor version of Drupal core ships small Twig changes, attribute renames, library handle adjustments. A theme that was built clean against 8.6 has, by 9.5, accumulated a quiet shadow of deprecated calls that still render fine until they do not.
We measure entropy in three passes.
First pass: run drupal-rector against the theme directory and count deprecation hits. A theme under fifty hits is healthy. Fifty to two hundred is a refactor day. Above two hundred you should consider whether the theme should be rebuilt against a current base theme rather than dragged forward.
Second pass: a grep for drupal_add_js, drupal_add_css, theme(), and direct db_query() calls. These were already gone in Drupal 8, but they sneak in through copy-pasted snippets from old answers on Stack Exchange. Every one is a tiny bomb.
Third pass: count the preprocess functions. Themes with more than thirty preprocess functions are usually doing work that belongs in a module. Drupal 11's render pipeline is stricter about cacheability metadata than 9 was. Preprocess functions that mutate render arrays without declaring cache contexts will silently break page caching after upgrade. We flag every one.
// What we look for in the audit. Pre-D8 calls that still show up in custom themes.
function THEME_preprocess_node(&$variables) {
$result = db_query(
"SELECT * FROM {node_field_data} WHERE nid = :nid",
[':nid' => $variables['node']->id()]
);
// This will not survive the upgrade. Replace with an entity query and
// declare proper cache tags on $variables.
}
The output is a theme entropy score and a recommendation: ship, refactor, or rebuild. Refactor and rebuild are very different line items. The audit decides which.
Four config-sync traps that ate a week
This is the part where most quotes go wrong. Config sync looks deterministic on the surface. drush config:export, commit, drush config:import on the other side. In practice, the same four traps catch us if we do not check for them upfront. On the project that triggered this checklist, they ate a week of unplanned work.
1. UUID collisions on re-created entities
Every config entity in Drupal has a UUID. If anyone has, at any point, deleted and re-created a view, a vocabulary, or a content type, the UUID in the database no longer matches the one in the exported config. Importing then succeeds or fails in unpredictable ways depending on which environments were exported when.
We grep the exported config for UUIDs and cross-check against the live database with a small drush eval snippet. Any mismatch goes on the list. Resolving them is mechanical, but it is hours of mechanical, and you need to know about it before you quote.
2. Field storage versus field instance ordering
field.storage.node.body.yml defines the storage. field.field.node.article.body.yml defines the instance. If you import field instances before their storage exists, the import fails partway through. Drupal will not roll back cleanly. You end up with a half-imported site that needs manual cleanup.
We check this by running a dry import against a clean install of D11 with the same modules enabled, then watching for order-of-operations errors. The fix is usually a config_split tweak or a hook_install in a deploy module. Either way, two to four hours we now price into every quote.
3. Default config that re-imports itself
Modules ship default configuration in their config/install directory. When you enable a module, Drupal copies that default config into the active store. Some sites then customise it. The customisations live in the active config, not in the module. If your config/sync directory was populated from a long-dead environment, the customisations might be missing, and importing will silently restore the defaults.
The signature of this trap is “config import succeeded, the site looks fine, then a week later someone notices a field label is back to its default.” We diff config/install against the active config for every enabled module and flag any drift.
4. Environment-bleeding config
API keys, debugging flags, file paths, mail aliases. None of these belong in synced config. All of them end up there sooner or later, usually because someone enabled a module in production, configured it through the UI, and exported. The resulting YAML now has a Mailgun API key in it. Or a path to /var/www/staging. Or a debug flag set to true.
We grep the export for the obvious markers: api_key, secret, staging, localhost, 127.0.0.1, xdebug, and anything that looks base64-shaped. Each hit becomes either a config_split candidate or, for true secrets, a move to environment variables via settings.php.
The thirty-minute version
If you do not have a day, you can still de-risk the quote in thirty minutes. Open a terminal in a fresh clone of the site and run:
# Module health snapshot.
drush pm:list --status=enabled --format=json | jq -r '.[].name' > enabled-modules.txt
# Composer outdated for contrib only.
composer outdated 'drupal/*'
# Deprecation count from the rector.
vendor/bin/rector process modules/custom themes/custom --dry-run | grep -c 'would have'
# Config drift check.
drush config:status
# Secrets in synced config.
grep -rE 'api_key|secret|password|token' config/sync/
Five commands, three coffee minutes of reading, and you will know whether the migration is a Tuesday or a fortnight.
What this changes about the quote
Quotes that come out of this audit have three line items the bad quotes do not: a module replacement budget, a theme entropy score, and a config remediation block. They also include a “things we found that you should fix regardless of migration” appendix, which is usually how the client decides to give us the work.
When we ran this audit on a Dutch B2B publisher's Drupal 9 site earlier this year, the four config-sync traps were the line that closed the deal. The previous studio had quoted forty hours and skipped the audit. We quoted eighty and showed the CSV. The publisher chose us. The actual project came in at seventy-two. That is the difference an audit makes. If you are working on a similar engagement, our notes on legacy migration walk through how we structure the work after the audit lands.
The smallest thing you can do today is run the five-command block above against your own Drupal site and see what falls out. If the secrets grep returns more than zero hits, you have an afternoon of cleanup ahead of you, regardless of whether you ever migrate.
Key takeaway
A Drupal 11 quote without an audit is a guess. One day of structured checking saves the week the config-sync traps would otherwise eat.
FAQ
How long does the full audit take?
A medium Drupal 9 site is one engineer-day. The thirty-minute version at the end of the post is a sanity check before quoting, but it will not catch the four config-sync traps.
Can I skip the audit if my Drupal 9 site is small?
Under fifteen contrib modules and no custom theme, the five-command quick check is usually enough. Anything larger and the config-sync traps will find you in the third week of the project.
What is the cheapest way to handle an abandoned contrib module?
Fork it into your own Composer repository and patch only what blocks the upgrade. Rewriting feature parity into a different module is almost always more expensive than maintaining a small fork.
When does Drupal 10 reach end-of-life?
Drupal 10 is supported alongside Drupal 11 through at least 2026 with security updates. The current upgrade pressure is on sites still running 8 or 9, both of which are already past end-of-life.