d11
Skill0 starsChanged 25 days agoMITView on GitHub
Orchestrate the upgrade of a Dockerized, Makefile-driven Drupal 10 site to Drupal 11. Use when the user mentions a Drupal upgrade/migration, "D10 to D11", or wants the full campaign run in the correct order. Sequences three reusable sub-skills — php-docker-upgrade (PHP 8.4), php-deprecations-audit (upgrade_status/rector/phpstan), jquery-4-migration — around the D11-specific work it owns itself (module cleanup with deployable uninstall/purge update hooks, then a combined contrib + core bump + apply) and a per-step Git workflow (one Draft MR per step). IMPORTANT: fix deprecations BEFORE bumping core; pair every module removal with an uninstall hook so preprod/prod do not break on cim; all Drupal commands MUST go through Makefile targets.
In this repository17 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- drupal-build-foundationsSkill
- drupal-build-intakeSkill
- drupal-buildSkill
- drupal-build-unitSkill
- drupal-rgaaSkill
- drupal-scaffoldSkill
- gulp-to-viteSkill
- jquery-4-migrationSkill
- lando-to-dockerSkill
- phpcbfSkill
- phpcsSkill
- php-deprecations-auditSkill
- php-docker-upgradeSkill
- php-standardsSkill
- phpstanSkill
- sass-migratorSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
13 rules. About 0 are common, 5 are distinctive, and 8 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
8
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- could be automatic: a setting could enforce it
- Very specific: line 90
Cleanup: remove unused modules/themes → references/contrib-and-cleanup.md, each removal paired with an uninstall hook → …
- Distinctive: line 169
Every step ends the same way — don't repeat the reasoning per step.
- Distinctive: line 183
Skip: keep the step's changes on the current branch and move on.
- Distinctive: line 184
If an MR is opened it stays Draft: never mark Ready / merge without explicit user approval.
- Distinctive: line 238
BEFORE any other operation.
- Very specific: line 249
Do not raise require.php in composer.json here: the lock still targets D10, and the floor moves to >=8.4 in step 6, where the D11 lock …
- Very specific: line 316
Update core_version_requirement in all .info.yml files for custom modules, features, and themes right now — without this, updb in the …
- Distinctive: line 390
Only now, once custom code is fixed (step 4), JS migrated (step 5) and cleanup done (step 3).
- Very specific: line 410
Read the drupal/<module> <ver> requires drupal/core (…) lines from why-not: any without ^11 is a blocker to bump.
- Very specific: line 435
Check what is enabled first — only *enabled* removed-core modules orphan at updb (make drush c='pml --status=enabled').
- Very specific: line 522
Advisories: every one is fixed in the same MR, by updating the package to its patched release (make composer c='update drupal/<pkg> …
- Very specific: line 605
After updb on a database aligned with config/sync (make cim first):
- Very specific: line 643
Remove the audit --dev dependencies that are no longer needed:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Drupal 10 → 11 Upgrade Orchestrator (Dockerized / Makefile project)
This skill orchestrates the upgrade: it owns the correct order, the Git cascade, and the D11-specific steps, and it delegates the three independently-reusable pieces to dedicated sub-skills:
| Delegated to | Covers | Step |
php-docker-upgrade | PHP 8.4 in Docker | 2 |
php-deprecations-audit | upgrade_status + rector + phpstan on custom code | 4 |
jquery-4-migration | jQuery 3 → 4 in the theme | 5 |
Those sub-skills are usable on their own; here they run in sequence, wrapped in the Git/MR protocol below. When you reach steps 2, 4 and 5, invoke the named sub-skill and come back for the step's "Close" (MR) part.
Golden rule: everything goes through the Makefile
This project is Dockerized and driven by a Makefile. All Drupal commands
run inside containers via make. Never call drush, composer, npm or
php directly on the host — always use a make target.
| Need | Make target |
| Generic Drush command | make drush c='<cmd>' |
| Clear cache | make cr |
| Generic Composer | make composer c='<args>' |
| Add a dependency | make composer-require <pkg> |
| Remove a dependency | make composer-remove <pkg> |
| Run DB updates | make updb |
| Export / import config | make cex / make cim |
| Enable / uninstall module | make en <m> / make pmu <m> |
| DB dump / import | make db-export / make db-import <f> |
| Static analysis / lint / fix | make phpstan / make phpcs / make phpcbf |
| Build theme assets | make npm-build (or make gulp-deploy only if the theme still uses Gulp) |
| Start / rebuild containers | make up / make rebuild / make rebuild-no-cache |
| Shell in PHP container | make shell |
| Admin one-time login link | make uli uid=1 |
For commands that must run inside the container (rector, php -v…), use
make shell and execute from within.
⚠️
--flags breakmake.make composer <pkg> --no-updatemakes Make treat--no-updateas an (invalid) option and abort. Always pass Composer flags insidec='…':make composer c='require <pkg> --no-update'.
⚠️ Newlines break
maketoo — keep everyc='…'on ONE line. Make runs each recipe line in its own/bin/sh -c, so a multi-linec='eval "…"'is cut at the first newline and fails withsh: -c: line 0: unexpected EOF while looking for matching `"'. Long PHP snippets in this skill are therefore written as one (long) line — do not reformat them for readability. For anything genuinely long,make shelland rundrush evalfrom inside the container instead.
💡 PHP 8.4 deprecation noise. From step 2 onward, contrib code triggers PHP 8.4 "implicitly marking parameter … as nullable" notices that Drupal prints on every
drush/composercall, drowning real output. Filter while parsing:… 2>&1 | grep -viE "Deprecated:|Implicitly marking". They clear as contrib is bumped to its D11 release (or patched) in step 6 — a cleanmake crwith 0 deprecation lines is a good end-state signal.
Operation order (IMPORTANT)
Key rule: fix deprecations BEFORE bumping core. Upgrade Status and drupal-rector detect and fix APIs "deprecated in D10, removed in D11" only while the site is still running Drupal 10. If you bump core to D11 first, those APIs are already gone: Upgrade Status switches to targeting Drupal 12 ("deprecated in D11, removed in D12") and the D11 fix list becomes unreachable. Custom code must be made "D11-ready" while still on D10.
Correct order:
- Backup (db-export, cex, files)
- PHP 8.4 (Docker) → sub-skill
php-docker-upgrade— technical prerequisite - Cleanup: remove unused modules/themes →
references/contrib-and-cleanup.md, each removal paired with an uninstall hook →references/uninstall-update-hooks.md - Audit + fix custom PHP deprecations — ON D10 → sub-skill
php-deprecations-audit - jQuery 4 theme migration — ON D10 → sub-skill
jquery-4-migration - Contrib + Drupal 11 core bump in ONE Composer transaction, then apply (updb, cim/cex, build) + verify — only once steps 3–5 are done
Loop: fix custom code / jQuery / contrib until Upgrade Status shows 0 D11 errors, THEN do the combined bump.
Why contrib and core are ONE step (not two): a two-phase approach — bump contrib on D10, then core — usually hits an unsolvable intermediate state, because most D11-ready contrib releases require
drupal/core:^11(they dropped D10 support), socomposer requirefails while core is still^10, and the reverse fails too. Raising core and all blocking contrib in a singlecomposer update --with-all-dependencieslets Composer resolve the whole graph at once. Steps 2–5 already made the code D11-ready on D10, so the golden rule still holds.
Git workflow: cascading branches + one Draft MR per step
Each major upgrade step produces its own branch and its own Draft MR, all tied to the same Mantis ticket.
The
gm:merge-request/gm:reviewskills are optional. Check the available-skills list at the start of the session and adapt:
- If
gm:merge-requestis available, delegate branch/commit/MR creation to it — it carries the team's conventions (branchfix/<ticket>-<slug>, committype(scope: #<ticket>): …, the self-hosted GitLab host, MR as Draft). Likewise usegm:reviewfor the per-step review.gm:security, when present, runs the security check at the end of step 6.- If it is NOT available, ask the user once how to close each step: (a) run the git workflow manually following the conventions in this section (branch → surgical commit → push, then open the MR with
glabif authenticated), or (b) skip branching/MR entirely and just apply each step's changes on the current branch. Remember the choice for the session.The conventions below (branch names, cascade, staging discipline) apply either way — only who performs the commit/MR changes.
Cascading branches (IMPORTANT)
Each branch is created from the previous step's branch, not from develop.
Without this, the final bump does not include the fixes from steps 3–5 and the
same errors resurface during the bump.
develop
└─ fix/<ticket>-php84-docker
└─ fix/<ticket>-cleanup-modules
└─ fix/<ticket>-deprecations-custom
└─ fix/<ticket>-jquery4
└─ feat/<ticket>-core-d11 (contrib + core bump, combined)Starting each step:
git checkout fix/<ticket>-previous-step # base = previous step's branch
git checkout -b fix/<ticket>-this-stepBranch convention (per-step slug, shared ticket):
| Step | Base | Branch |
| PHP 8.4 (Docker) | develop | fix/<ticket>-php84-docker |
| Module cleanup | previous step | fix/<ticket>-cleanup-modules |
| Custom PHP deprecations | previous step | fix/<ticket>-deprecations-custom |
| jQuery 4 (theme) | previous step | fix/<ticket>-jquery4 |
| Contrib + D11 core bump | previous step | feat/<ticket>-core-d11 |
Branch base rule: the first branch (PHP 8.4) starts from an up-to-date
develop; every subsequent branch starts from the previous step's branch.
Closing each step (MR protocol)
Every step ends the same way — don't repeat the reasoning per step. Adapt to what's available (see the note above):
- Review — if
gm:reviewis available, run it on the step's modified files and put its verdict in the MR's "Verification" section. Otherwise do a brief manual self-review of the diff (/code-reviewor a read-through). - Commit + MR — if
gm:merge-requestis available, invoke it with ticket<ticket>(captured in step 0). Otherwise fall back to the workflow the user chose when gm was found absent:- Manual: stage only this step's files (surgical staging), commit as
type(scope: #<ticket>): …, push the branch, and — ifglabis authenticated — open a Draft MR targeting the previous step's branch (step 2 →develop); ifglabis absent, push and hand the user the branch name to open the MR themselves. - Skip: keep the step's changes on the current branch and move on.
- Manual: stage only this step's files (surgical staging), commit as
- If an MR is opened it stays Draft: never mark Ready / merge without explicit user approval.
Running Drupal (
updb/cr, or just browsing) regenerates a few tracked-but-runtime files — e.g.web/sites/default/files/.htaccessand compiled translation JS aggregates. They reappear as "modified" after almost every command;git restorethem before each surgical commit so they never leak into an MR.
Below, each step's ▸ Close: line gives only what differs: the commit scope and any extra "Verification" content.
Step 0 — Start the environment & capture the ticket
make up
make drush c='status'Ask the user for the Mantis ticket number right now (once only) — it will
be reused for all branches and MRs in the session. If the user already provided
it in their request, use it. Store it as <ticket> (e.g. 12345) for branch
names and commit messages delegated to gm.
Production prerequisites go in the MRs, never on the servers. Never SSH into preprod/prod and never ask the user for server access or to check them — list the requirements in the MRs' "Production actions" for the hosting team:
| Requirement (Drupal 11) |
| Database: MariaDB ≥ 10.6, MySQL/Percona ≥ 8.0, PostgreSQL ≥ 16, SQLite ≥ 3.45 |
| PHP 8.4 available for the CLI and the web runtime (FPM pool / vhost handler) |
A database below the minimum is a blocker for the whole campaign: updb -y
forces past the requirements error and leaves the site on an unsupported engine.
Step 1 — Backup
make db-export
make cex # freeze current config (rollback point)
tar -czf files_backup_$(date +%Y%m%d).tar.gz web/sites/default/files/Git-commit the saved state before continuing.
Step 2 — PHP 8.4 upgrade (Docker) → sub-skill php-docker-upgrade
🌿 Branch:
git checkout develop && git pull, then work onfix/<ticket>-php84-docker.
Recent Drupal 11 requires PHP 8.4. This is a technical prerequisite done BEFORE any other operation.
Invoke the php-docker-upgrade sub-skill with 8.4 as the floor. Its step 0
checks whether a newer supported cycle fits (what production runs, what the CI
image runs) and decides the target; then it bumps the image, runs
make rebuild, verifies php -v, and reinstalls dependencies.
Refresh the other images in the same MR: the dev-only tools to their newest
release, the node image to the current Node Active LTS (rule and commands:
lando-to-docker → references/image-versions.md).
Do not raise require.php in composer.json here: the lock still targets
D10, and the floor moves to >=8.4 in step 6, where the D11 lock already needs it.
▸ Close (see MR protocol): commit the Dockerfile (+
config.platform.phpif the project pins one). Bump the CI runner image too if it pins PHP/node (e.g.php-8.1-node-20→php-8.4-node-24: the same Node LTS as the node image), and the PHP paths indeploy.php. Targetdevelop. The MR carries the image-versions report table (Image / File / Status / Newer version).The MR description must carry the production prerequisites, staging before the merge on the staging branch, prod before the merge on the prod branch (otherwise
deploy:vendorsfails and blocks every hotfix):
- PHP 8.4 installed with the same extensions and
php.inias the current one.- The FPM pool / vhost handler switched to 8.4 and restarted:
deploy.phponly drives the CLI, so without it the site is still served by the old PHP while the deploy reports 8.4.- Server crontabs calling the old PHP binary updated.
sid_length/sid_bits_per_characterremoved from the sharedservices.ymlif present (deprecated in PHP 8.4).- After deploy, both
drush statusand the webphpinfo()show 8.4.Also state that this MR does not ship alone: custom code still has implicitly-nullable parameters until step 4, which log PHP 8.4 deprecations. A clean
make crdoes not prove otherwise — it does not load those classes.
Step 3 — Cleanup: unused modules & themes (ON D10)
🌿 Branch: from
fix/<ticket>-php84-docker→fix/<ticket>-cleanup-modules.
Clean up now, while still on D10: less code to audit and fix later. See
references/contrib-and-cleanup.md (section "Unused modules & themes").
make drush c='pml --type=module --status=disabled' # candidates
make pmu <unused_module>
make composer-remove drupal/<unused_module>
make cexPair EVERY removal with an uninstall hook (IMPORTANT)
The make pmu above only cleans your local database. Preprod and prod still
list the module in core.extension and system.schema while its code is gone —
which breaks cim and, worse, makes every later module install throw
The module <name> does not exist.
So each module or theme dropped from composer.json that some environment
still has installed needs an update hook in a project-owned,
always-installed module (typically <project>_global_config), committed in the
same MR as the removal. Read references/uninstall-update-hooks.md before
writing one:
- No hook for a module absent from the integration branch's
core.extension.yml:cimalready keeps it uninstalled everywhere, and a hook would only add an untested destructive path. - Code can stay one more deploy? Ship
uninstall($modules, FALSE)with the return value checked, and remove the code in the next deploy. - Code cannot stay (no release for the new core): on deploy it is gone
before
updatedb, andModuleInstaller::uninstall()then returnsFALSEsilently. Purge by hand, naming every module, config object (dependents included) and table explicitly — no prefix matching, no generic helper.
The reference also carries the theme variant (easier — ThemeInstaller copes
with missing code) and how to replay the deploy on a pre-removal database dump.
D11 compatibility in info.yml files
Update core_version_requirement in all .info.yml files for custom
modules, features, and themes right now — without this, updb in the combined
bump (step 6) will list all custom modules as "D11.x incompatible" and block.
# Check what needs updating
grep -rl "core_version_requirement" modules/custom/ modules/features/ themes/custom/
# Update in one pass
find modules/custom/ modules/features/ themes/custom/ -name "*.info.yml" \
| xargs grep -l "core_version_requirement" \
| while read f; do
sed -i 's/core_version_requirement: \^8 || \^9 || \^10$/core_version_requirement: ^8 || ^9 || ^10 || ^11/' "$f"
done
# Verify the result
grep -r "core_version_requirement" modules/custom/ themes/custom/ | head -5⚠️ The
sedabove only matches the exact constraint^8 || ^9 || ^10. Any.info.ymlwith a different format (e.g.^9 || ^10, or a single^10) is left untouched — manually check every file the verifygrepstill shows without^11.
▸ Close (see MR protocol): commit the removed code + updated
.info.ymlfiles + the uninstall hooks (<project>_global_config.install) + config export (config/).
Step 4 — Audit + fix custom PHP deprecations (ON D10) → sub-skill php-deprecations-audit
🌿 Branch: from
fix/<ticket>-cleanup-modules→fix/<ticket>-deprecations-custom.
Pivotal step — must be done BEFORE touching core. Upgrade Status only surfaces "removed in D11" APIs while the installed core is still D10.
Invoke the php-deprecations-audit sub-skill. It installs upgrade_status,
scans, applies rector + phpstan + manual fixes (incl. PHP 8.4 implicitly-nullable
params), and loops the scan until 0 D11 errors. Custom modules live under
web/modules/custom. Do not move to the next step before this reaches 0.
Note: leave the audit
--devdeps (upgrade_status,drupal-rector) installed for now — they are removed at the very end of the combined bump (step 6), not here.upgrade_statusis also reused there to inventory contrib D11-readiness before the bump.
▸ Close (see MR protocol): commit
web/modules/customonly — NOTupgrade_status/drupal-rector. Addupgrade_status:analyzeoutput (0 D11 errors) +make phpstanto "Verification".
Step 5 — jQuery 4 theme migration (ON D10) → sub-skill jquery-4-migration
🌿 Branch: from
fix/<ticket>-deprecations-custom→fix/<ticket>-jquery4.
Also do this while still on D10, to decouple bug sources. Code migrated to native APIs stays fully functional under D10's jQuery 3.x + polyfill, so you can migrate and test safely before the bump.
Invoke the jquery-4-migration sub-skill. It scans custom + third-party JS,
converts to native APIs, rebuilds (make npm-build + make gulp-deploy),
handles not-yet-compatible libraries with a scoped local polyfill, and removes
the legacy polyfill once usages are zero.
▸ Close (see MR protocol): commit migrated JS (
src/js), built assets if versioned, cleaned.libraries.yml, the generatedjquery.deprecated.functions.js(or the deleted legacy polyfill once usages are zero). Manual UAT (modals, sliders, AJAX) in "Verification".
Step 6 — Contrib + Drupal 11 core bump (single Composer transaction) + apply
🌿 Branch: from
fix/<ticket>-jquery4→feat/<ticket>-core-d11. Contrib readiness, the core bump, AND its application (updb/cim/build) all go into one branch/MR — they are inseparable (see "Why contrib and core are ONE step" in Operation order).
Only now, once custom code is fixed (step 4), JS migrated (step 5) and cleanup done (step 3).
Diagnose ALL blockers up front (avoid iteration)
Get the full picture before touching any constraint, so you bump everything
in one pass instead of discovering blockers one failed composer update at a
time:
# 1) Composer-level blockers: every installed package requiring core <= 10.
# Use a CONCRETE D11 version (not a range) — it yields a clean, complete list
# where the resolver error only ever names the first conflict.
make composer c='why-not drupal/core 11.4.4'
# 2) Contrib D11-readiness report (upgrade_status is still installed from step 4):
make drush c='upgrade_status:analyze --all'
# or per module: make drush c='upgrade_status:analyze <module>'Read the drupal/<module> <ver> requires drupal/core (…) lines from why-not:
any without ^11 is a blocker to bump; watch for partial pins like
^11 <11.2 (e.g. gin/gin_toolbar) that need a newer release. core-recommended's
own vendored deps (symfony, guzzle…) are noise — --with-all-dependencies
updates them. The upgrade_status report cross-checks which contrib has a D11
release vs still needs a patch/fork, so you decide bump-vs-remove-vs-patch for
every module in one go.
Check the project's own autoloaded PHP
Beyond contrib, the project's OWN PHP that Composer autoloads (the classmap /
files in composer.json autoload — typically scripts/composer/*.php) can
reference APIs removed or dropped for D11 and fatal the post-install-cmd.
Common offenders seen on GM projects:
Webmozart\PathUtil\Path(abandoned; may leave the dependency graph) →Symfony\Component\Filesystem\PathDrupalFinder\DrupalFinder+->locateRoot()→DrupalFinderComposerRuntime- old settings rewriting →
Drupal\Core\Site\SettingsEditor
Diff these files against a fresh drupal-composer/drupal-project D11 template and
port them; php -l each after editing.
Handle removed-core modules & contrib with no D11 release
Check what is enabled first — only enabled removed-core modules orphan at
updb (make drush c='pml --status=enabled').
| Module | In D11 | Action (only if relevant) |
ckeditor (CKEditor 4) | replaced by ckeditor5 in core | if a text format still uses CKE4: make en ckeditor5 (before updb); then make pmu ckeditor + composer remove. Verify: make drush c='config:get editor.editor.<format> editor' → ckeditor5 |
quickedit, rdf, tour, color (core) | removed / moved to contrib | if enabled: make pmu <m>, or install the contrib version; else nothing to do |
contribute | 5.x betas pin drupal/core ~8.0 | make pmu contribute + composer remove |
| any contrib with no D11 release | — | if unused (e.g. an optimize backend with no pipeline configured): uninstall + remove; if needed: dev/beta branch → fork → alternative → custom patch (cweagans/composer-patches) |
A high version number does not guarantee D11 support for beta/rc releases — always confirm against the
why-not/upgrade_statusoutput.
⚠️ composer-patches gotchas (when patching a module — e.g. a one-line PHP 8.4 nullable fix on a contrib with no fixed release):
composer.patches.jsonis only read if it is wired viaextra.patches-file(or patches are inline inextra.patches). A straycomposer.patches.jsonwith no wiring is silently ignored — the patch never applies. Check/add"patches-file": "composer.patches.json"inextra, and drop obsolete patches (a stale D10 core patch will fail on D11 and abort the install withcomposer-exit-on-patch-failure).- Do not apply/re-apply a patch with
composer reinstall <pkg>: with patches it can delete the module dir and then fail ("Package is not installed"), leaving it missing. Usecomposer install(it re-applies), orremove+require.
Bump everything in ONE composer update
# Let Composer resolve past the installed core's own security advisories
make composer c='config -- policy.advisories.block false'
# Core + scaffold + drush + the DEV core package.
# NOTE: core-dev ^10 ALSO pins core to 10 — bump it too or the resolve fails.
make composer c='require drupal/core-recommended:^11.0 drupal/core-composer-scaffold:^11.0 drupal/core-dev:^11 drush/drush:^13 --no-update'
# The PHP floor follows the runtime from step 2 (the update below rewrites the
# lock's platform section)
make composer c='require php:>=8.4 --no-update'
# Every blocking contrib from the diagnose step, at its D11 release
# (major bump where the current major has no D11 release)
make composer c='require drupal/<mod_a>:^X drupal/<mod_b>:^Y … --no-update'
# Uninstall (on D10, so hook_uninstall runs) THEN drop the incompatible/unused ones
# NB: this cleans YOUR database only -- add a purge hook for each one still
# installed on preprod/prod (references/uninstall-update-hooks.md), or they
# break on cim.
make drush c='pmu <removed_a> <removed_b> --yes'
make composer c='remove drupal/<removed_a> drupal/<removed_b> --no-update'
# Resolve the whole graph in one transaction
make composer c='update --with-all-dependencies'⚠️ allow-plugins: core 11 pulls the
symfony/runtimeComposer plugin, blocked by default — the run aborts on it after resolving. Allow it, then finish the install:make composer c='config allow-plugins.symfony/runtime true' make composer c='install'
If
composer updatestill conflicts, its error names the next blocker — bump that constraint and re-run (rare when the diagnose step was thorough). For an orphaned sub-module or aupdbschema gap, seereferences/contrib-and-cleanup.md(§ Orphaned modules, § Schema gaps).
Security and freshness, after the bump
The advisory block was lifted to resolve: lift it back, and leave nothing known vulnerable behind.
If
gm:securityis available, run it here, after the unset below. It covers the composer and npm audits, the Drupal SAs and the container images, and ranks the findings by how exploitable they are. Fold its fixes into this step's MR when they are package updates. Anything bigger (the image toolchain, an abandoned package, a held-back runtime) goes in the MR as a follow-up. Without it, run the checks below by hand. Run it again on the day the MR ships if drupal.org published an SA in between.
make composer c='config --unset policy.advisories.block' # then delete the empty "policy": {} it leaves in composer.json
make composer c='audit --locked --abandoned=report' # target: "No security vulnerability advisories found"
make composer c='outdated --direct --minor-only' # what a plain update would still pick up- Advisories: every one is fixed in the same MR, by updating the package to
its patched release (
make composer c='update drupal/<pkg> --with-dependencies'). Core and contrib SAs land weekly: check drupal.org/security on the day the MR goes out, not only when the work started. - Abandoned packages: plain
auditexits non-zero on them even with zero advisories. Report them in the MR with the suggested replacement; replacing one is a separate change unless it is blocking. - Front:
make npm c='audit --include=dev'. Legacy image toolchains (imagemin-*,fast-glob) carry critical advisories with no fix: the answer is thegulp-to-viteskill (sharp + svgo, Node LTS), notnpm audit fix --force. - Images: re-run the image-versions check. A PHP/DB/Node held back for production goes in the MR as a follow-up, not as a silent pin.
Purge the orphans the bump itself created
A contrib major that drops a dependency leaves modules installed with no code
— nobody removed them, Composer did. Real case: module_filter 4.1.1 required
jquery_ui_autocomplete (pulling jquery_ui + jquery_ui_menu) and its own
update installed it; module_filter 6.0.0 dropped the dependency, so all three
lost their code while staying in core.extension.
These are not cosmetic: one such orphan aborted core's own
search_update_11402 (it installs search_node) and with it the entire updatedb
run. List them right after the bump, before running updb:
make composer c='install' # code now matches composer.lock
make drush c='eval "print_r(array_values(array_diff(array_keys(\Drupal::config(\"core.extension\")->get(\"module\")), array_keys(\Drupal::service(\"extension.list.module\")->getList()))));"'Everything that comes back needs a purge hook and a
hook_update_dependencies() entry ordering it before the core update that
installs a module — see references/uninstall-update-hooks.md
(§ Order the purge before core's own updates). Same file for a contrib that
declares hook_update_last_removed() above your schema while shipping no
hook_update_N (§ Don't blindly force a contrib schema number).
Apply
⚠️ Order matters: after the Composer bump, code is D11 but the DB schema is still D10.
make crfails with a missing-column error. Runmake updbbeforemake cr.
# Maintenance mode
make drush c='state:set system.maintenance_mode 1 --input-format=integer'
# DB updates (BEFORE cr — schema is not yet D11)
make updb
# Cache rebuild (now that schema is up to date)
make cr
# Rebuild frontend
make npm-build
make gulp-deploy # only if the theme build uses it
# Exit maintenance mode
make drush c='state:set system.maintenance_mode 0 --input-format=integer'
make cr
make cim? Only if the project is config-first (a git-trackedconfig/syncis the source of truth). If config lives in the DB andconfig_sync_directoryis just a backup, skipcim— it would re-import the pre-bump snapshot and revertupdb's config changes — and runmake cexinstead to refresh the backup to the new D11 state.
Config-first: every config change made by updb must reach config/sync
Deploys run updatedb → config-import → updatedb. A post-update that
rewrites config is reverted by the import if config/sync still holds the
old shape, and the second updatedb does not replay it (post-updates run once).
config:status stays clean everywhere, so nothing signals it.
Real case: system_post_update_migrate_compress_setting turns
system.performance css|js.gzip into compress. The sync file kept gzip,
and locally a config-split patch on system.performance absorbed the
difference (it listed compress as a dev-only key and gzip as a sync-only
key), so local config:status was clean while every cim in prod removed
compress (silently ignored by D11, compression lost in D12).
After updb on a database aligned with config/sync (make cim first):
make cex
git diff --stat config/ # updb's changes, to commit
# Splits patch the same objects: re-read every config/<split>/config_split.patch.*.yml
# whose object updb touched, and drop keys the update renamed or removed.
grep -rn "gzip:" config/ # must be empty after the D11 bumpcex also skips language overrides: when an update adds langcode: en to a
config whose site default language differs, locale moves the translated strings
into the language.<default> collection. config:status then lists the object
as "Only in DB" forever. Export the override into
config/sync/language/<default>/. Do it from make shell (make echoes its
recipe line, which would land in the file):
vendor/bin/drush php:eval 'echo \Drupal\Core\Serialization\Yaml::encode(\Drupal::service("config.storage")->createCollection("language.fr")->read("<name>"));' > config/sync/language/fr/<name>.ymlReview the deploy recipe for a core bump
deploy.php (or the CI deploy script) was written for same-major releases. Two
habits break on a core bump:
crbeforeupdatedbfails on D11 code over a D10 schema (Unknown column 'alias') and aborts the deploy: drop it,updatedbis followed by acranyway.- Leaving maintenance mode inside the update task, before
deploy:publish: the previous release then serves a database already migrated to D11. Turn maintenance off only after publish. Deployer removes thereleasesymlink at publish, so adrush_bin_pathbuilt on{{deploy_path}}/releaseno longer exists there: call{{current_path}}/vendor/bin/drush.
Final cleanup
Remove the audit --dev dependencies that are no longer needed:
make pmu upgrade_status
make composer c='remove drupal/upgrade_status palantirnet/drupal-rector'
make cex⚠️ If
make composer-removefails with "Removal failed, still present" for packages inrequire-dev, workaround: remove the entries directly incomposer.json, then runmake composer c='update'.
Sync the exported config to the git-tracked directory
make cex exports to the path defined by $settings['config_sync_directory']
in settings.php / settings.local.php.
Identify the real export path:
# Option 1 — read settings.php directly on the host
grep "config_sync_directory" sites/default/settings.php sites/default/settings.local.php 2>/dev/null
# Option 2 — ask Drupal via drush
make drush c='eval "echo \Drupal\Core\Site\Settings::get(\"config_sync_directory\");"'If the export path differs from the git-tracked config dir, ask the user for the correct path, then rsync between the two locations.
Verify
make drush c='status' # must show Drupal 11.x
make drush c='core:requirements --severity=1' # no ERRORS (warnings OK)
make drush c='cr' # expect 0 deprecation warnings
make drush c='watchdog:show --count=50 --severity=3' # severity NAME is localized — use the number (3 = Error)
make drush c='cron'
make translateManual tests
- Homepage loads (
https://<project>.dev.localhost) + each domain/theme level - Admin login:
make uli uid=1(note: a gin 4→5 bump is a major admin-theme change) - Content creation/editing (CKEditor 5), forms, search
- Theme JS interactions (modals, sliders, AJAX) — jQuery-4 plugins work in-browser, 0 console errors
- Cron runs clean, logs are error-free
- CI pipeline green on the new PHP-8.4 image
▸ Close (see MR protocol): commit
composer.json+composer.lock(+patches/+composer.patches.jsonif a module was patched) + the uninstall/purge hooks for everything dropped or orphaned here + the regenerated D11 scaffold files +config/if git-tracked. Targetfix/<ticket>-jquery4. "Verification" =drush status(11.x) +core:requirements+crwith 0 deprecations + the deploy replayed on a pre-bump dump (references/uninstall-update-hooks.md§ Verify); "Manual UAT" = tests above. "Production actions" = the step-0 prerequisites (database version, PHP 8.4 on FPM), a database dump before deploying, and any manual change to server-side shared files. Stays Draft until the user validates UAT.
Rollback
git checkout composer.json composer.lock config/
make composer c='install'
make db-import _dumps/<last-dump>.sql.gz
make cim
make crBecause each major step has its own branch/Draft MR, a rollback can also be targeted: simply don't merge (or close) the failing MR rather than reverting everything. Steps done on D10 (custom, jQuery) remain valid even if the combined bump (step 6) is postponed.
Common issues
| Symptom | Cause | Fix |
composer update fails on platform | Container PHP < 8.4 | Step 2 — sub-skill php-docker-upgrade |
composer update blocked on advisories | Installed core has security advisories | composer config -- "policy.advisories.block" false |
composer update conflict, contrib requires core ^10 | A contrib (or core-dev ^10) still pins core to 10 | Bump every blocker found by why-not drupal/core <v> in the SAME transaction; don't forget core-dev:^11 |
symfony/runtime … blocked by your allow-plugins config | Core 11 pulls the symfony/runtime Composer plugin | composer config allow-plugins.symfony/runtime true, then composer install |
post-install-cmd fatals (Class … not found, Webmozart\PathUtil) | Project's scripts/composer/*.php use APIs dropped for D11 | Update to Symfony\…\Path / DrupalFinderComposerRuntime / SettingsEditor — Step 6 "Check the project's own autoloaded PHP" |
Patch in composer.patches.json silently not applied | File not wired | Add extra.patches-file: composer.patches.json (or inline extra.patches) |
composer reinstall <pkg> deleted the module then failed | composer-patches + reinstall quirk | composer install to recover/re-apply; never reinstall a patched package |
make composer … --no-update prints Make usage/errors | Make eats --flags as its own options | Pass flags inside c='…' |
Class not found after upgrade | Cache stale | make cr |
make cr fails after Composer bump | DB schema still D10, code already D11 | Run make updb first |
make cim reverts D11 config / "empty import" | Config is DB-authoritative, config/sync is a stale/relocated backup | Skip cim, run cex; or read the real path from settings.php and rsync |
| DB schema errors | updb not run | make updb |
| Module auto-disabled | D11 incompatible | Find alternative / apply patch |
JS broken ($.trim/$.type is not a function) | jQuery 4 removed the API | Step 5 — sub-skill jquery-4-migration (extend the scoped shim) |
| Theme broken | Classy/Stable base | Migrate to Starterkit |
deprecated function PHP | Custom code not migrated | Step 4 — sub-skill php-deprecations-audit |
| Custom modules listed "D11.x incompatible" | core_version_requirement not updated | Step 3 — sed over all .info.yml files |
updb reports orphan module (e.g. tour) | Module removed from core, still in system.schema | Purge — see references/contrib-and-cleanup.md § Orphaned modules |
updb blocked on contrib schema gap (e.g. honeypot 8102→8104) | Intermediate hooks removed upstream | Replay the removed update, then set the schema — references/uninstall-update-hooks.md § Don't blindly force |
| Drush fails to start after a package update | Sub-module removed from package still in core.extension | Purge stale module — see references/contrib-and-cleanup.md § Orphaned modules |
cim fails on preprod/prod for a module removed locally | Removal was never paired with an uninstall hook | references/uninstall-update-hooks.md — uninstall hook (two deploys) or explicit purge hook |
| Uninstall hook "succeeded" but the module is still installed in prod | ModuleInstaller::uninstall() returns FALSE silently when the code is gone (it always is, on deploy) | Explicit purge hook — references/uninstall-update-hooks.md § Step 3 |
| Reviewer flags a generic purge hook as risky | Prefix matching, listAll('<module>.'), a safety net for modules no environment has installed | Drop the hooks for modules absent from core.extension.yml; list modules, config and tables explicitly — references/uninstall-update-hooks.md |
Prod config silently reverted after deploy (e.g. system.performance back to gzip) | config/sync (or a split patch) still holds the pre-update shape; cim undoes the post-update | Step 6 § Config-first: export updb's changes, clean the split patches |
config:status lists a config "Only in DB" after the bump | An update added langcode, locale stored the translation as a language.<default> override | Export it to config/sync/language/<default>/ |
Deploy fails on cr / old code serves the D11 schema for a few seconds | Deploy recipe runs cr before updatedb, or leaves maintenance before publish | Step 6 § Review the deploy recipe |
updb passes in prod on MariaDB < 10.6 / MySQL < 8.0 | -y forces past the requirements error | List the database version requirement in the MR "Production actions" (step 0) |
A core update aborts with The module <x> does not exist. | An unrelated orphan blocks ModuleInstaller::install() | Purge the orphan + hook_update_dependencies() to order it first — references/uninstall-update-hooks.md |
Reference files
references/contrib-and-cleanup.md— module cleanup, orphaned modules, schema gaps (local, one-off fixes)references/uninstall-update-hooks.md— the deployable counterpart: which removed modules need a hook, two-deploy uninstall vs explicit purge, verified on a pre-removal dump
Sub-skills (usable standalone too)
php-docker-upgrade— PHP version check + bump in Docker (step 2)php-deprecations-audit— upgrade_status/rector/phpstan on custom code (step 4)jquery-4-migration— jQuery 3 → 4 theme migration (step 5)
External resources
- Official guide: https://www.drupal.org/docs/upgrading-drupal/drupal-10-to-drupal-11
- Breaking changes: https://www.drupal.org/list-changes/drupal/published?to_branch=11.x
- Upgrade Status: https://www.drupal.org/project/upgrade_status
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

