Drupal upgrades
What a Drupal 9 to 11 jump actually costs
The last 9 to 10 jump I ran on a client estate took sixteen hours. That number is true, and it is close to useless as an estimate for yours.
Sixteen hours was not a measurement of the upgrade. It was a measurement of the estate the upgrade landed on.
When an agency asks me what a Drupal 9 to 11 move costs, the honest first answer is that the version numbers do not contain the price. Two sites on the same Drupal 9.5, same core, same profile, can sit a hundred hours apart. The gap is never in core. It is in everything that was allowed to drift while core stood still.
So rather than quote a range that would be fiction, here is the shape of the real cost: what the cheap version looks like, what the four expensive things are, and how you can price your own estate in an afternoon without hiring anyone.
What sixteen hours actually means
On that estate, the jump itself was one Composer resolve, a database update, a cache rebuild and a regression pass. It went quickly because four conditions were already true before I started:
- every contributed module in the build already had a release spanning both majors,
- no text format was still running on CKEditor 4,
- PHP and the database already met the target minimums,
- the custom code no longer called anything core had removed.
When those four hold, a major jump is close to mechanical. When even one of them fails, the same command does not resolve, and a failed resolve tells you nothing useful about the price. You are no longer upgrading. You are doing archaeology, and then upgrading.
The four conditions above are the estimate. The jump is the cheap part.
The four things that set the price
1. Contributed modules with no dual major release
Most actively maintained modules ship releases constrained to ^10 || ^11, which is exactly what you want: one release that is valid on both sides of the jump, so the module is not what blocks you.
Your cost lives entirely in the ones that do not. A module can be abandoned, or mid rewrite, or quietly replaced by something that landed in core. Each of those is not a task, it is a decision: patch it, fork it, replace it, or drop the feature and tell the client. Making that call needs someone who knows what the feature was for and who is still using it, which is usually the slowest information to get.
This is the single line item that turns a two day upgrade into a two week one, and it is the one nobody can see from the outside.
2. Custom code holding APIs core removed
The upgrade_status module gives you the authoritative list of deprecated calls in your custom modules and themes. Install it early and keep the report: it is your baseline, and you will diff against it.
Read it as a report, though, not as an estimate. A flagged call inside a preprocess function is a few minutes. The same flag inside a custom entity, or a service that half the site behaviour hangs off, is a different job entirely. Swapping core/jquery.once for core/once in a theme library is mechanical. Untangling what a custom module built on jQuery UI was doing is not.
Widening core_version_requirement in your info files is the part everyone quotes, because it takes seconds. It is also the part that matters least.
3. Text formats still on CKEditor 4
CKEditor 4 left core at Drupal 10, so the editor migration has to happen while CKEditor 4 still exists, which means before the jump, not during it. Core ships smart default settings that do most of the mapping for you.
The cost is not the migration, it is the verification. Every format has to round trip: links, embedded media, custom buttons, anything an editor plugin was adding. Three simple formats is an afternoon. A dozen formats with custom toolbars, an editor plugin and five years of pasted markup in the body fields is a genuine piece of work, and it is content risk rather than code risk, which makes it harder to hand to a junior.
4. The platform underneath
Drupal 11 raises the floor, and the floor is not in your repository:
| Target | PHP | MySQL / MariaDB | Drush |
|---|---|---|---|
| Drupal 10 | 8.1 minimum, 8.3 recommended | 5.7.8 / 10.3.7 | ^12 |
| Drupal 11 | 8.3 | 8.0.3 / 10.6 | ^13 |
If the host cannot reach those numbers, your blocker is procurement, not Drupal, and no amount of developer time moves it. A database major version change is its own event too: dump before, re-import after, and budget for the fact that a managed host may schedule it rather than let you do it.
This is where estimates die quietly, because it is the only one of the four that you cannot audit by reading the codebase.
Why the big bang fails
The tempting move on an end of life site is to go straight from 9 to 11 in a single resolve. On a site that has drifted, it simply does not resolve. Worse is when it half resolves, because then you have forty packages changing at once and no way to tell which one produced the failure you are looking at.
What works instead is boring and gated. Land the last minor of the major you are already on. Move contrib to the dual major releases. Bump the platform. Migrate the editor. Only then take the major jump as one atomic resolve. Commit and tag at every gate, so that when a gate fails you throw away one gate and not one week.
Keep core/ and vendor/ in version control, and your recovery primitive becomes git reset --hard followed by git clean -ffd.
Prove it works once, on purpose, before you need it. A recovery path you have never executed is a plan, not a primitive.
Two failure modes account for most lost days. The first: on an end of life site, Composer refuses to resolve because of security advisories against the very packages the upgrade is there to fix, so it has to be unblocked deliberately and temporarily rather than fought. The second, and the more expensive one:
A green Composer resolve is not a working site. Check drush status, then load a real page.
How to price your own estate in an afternoon
You do not need me for this part. Three checks give you most of the picture.
drush status
composer why-not drupal/core-recommended ^11
drush en upgrade_status -y
drush upgrade_status:analyze --all
drush ev '$all = \Drupal::entityTypeManager()
->getStorage("editor")->loadMultiple();
foreach ($all as $id => $editor) {
print $id . ": " . $editor->getEditor() . "\n";
}'
Then sort everything the output gives you into three buckets. Mechanical: info file constraints, library renames, anything with one obvious replacement. Decisions: every module with no dual major release, and every deprecation sitting in code whose purpose is no longer documented. Platform: the PHP and database rows, plus whoever has to approve changing them.
The mechanical bucket is predictable and you can quote it confidently. The platform bucket is a calendar problem more than a cost. The decisions bucket is your real estimate, and its size is the only number worth arguing about.
What waiting costs
Nothing about this gets cheaper by sitting still. Security coverage ends on a fixed date whether or not the client has budget. Contributed modules stop cutting releases for the major you are stranded on, so every month of delay adds entries to the decisions bucket, and those are the expensive ones.
The estate that jumped in sixteen hours did not get there by being lucky, or by being small. It got there because it was never allowed to drift in the first place, which is a maintenance decision made years earlier, not an upgrade decision made this quarter.
If you cannot answer the four conditions today, that is the finding, and it is worth knowing before you quote.
Want a second opinion on yours?
Send me the URL, or the repository, and what is hurting. I will tell you which of the four buckets your estate is actually in, and what I would fix first.
No charge and no pitch. The written audit, with the detail behind it, is the paid part.