On a config-as-code Drupal project, removing a custom module looks trivial: delete the module folder, drop its entry from core.extension.yml, commit, and merge. Everything is green locally. Then the next deployment fails before it even reaches your application code — and the error names a module you were certain you just removed.
This is a deployment-ordering trap that bites almost every mature Drupal team eventually. The fix is a small, repeatable two-phase removal pattern. Here is what goes wrong, why, and how to remove a module cleanly across every environment.
Your local environment is fine, because the module is already uninstalled there. But your staging and production databases still have the module installed — its machine name is still listed as enabled in each environment’s active configuration. When the new release ships, the code for that module is simply gone from the image.
Most automated Drupal deployments run a sequence like drush updatedb, then drush config:import, then a handful of environment-specific drush steps. Every drush command has to bootstrap the full container first, and bootstrapping reads the active module list. When Drupal tries to load a module that is enabled in the database but whose code no longer exists on disk, it throws:
In ExtensionList.php line 542:
The module my_module does not exist.The deploy dies there. Worse, if your config:import step is wrapped so that its failures are non-fatal, the import silently fails to uninstall the orphan and the environment limps along in a half-broken state until a later command hits the same wall — with a far more confusing message than the real cause.
Drupal expects a module to be uninstalled — removed from active configuration, with its uninstall hooks run — before its code disappears. Deleting the folder skips that step entirely. Config import can normally uninstall a module that has been dropped from core.extension.yml, but only while the module’s code is still present to load and run the uninstall. Pull the code out from under an installed module and you have created an orphan that no part of the deploy can cleanly resolve.
Phase one — uninstall, with the code still present. In the release that retires the module:
.info.yml and an empty .module file so Drupal can still discover and load it, but strip out all real logic, libraries, and dependencies.core.extension.yml so config import knows it should be uninstalled.hook_post_update_N() in a module that survives the release, to uninstall the target deterministically during drush updatedb — which runs before the fragile config-import and environment steps:function mymodule_post_update_uninstall_legacy(): ?string {
if (!\Drupal::moduleHandler()->moduleExists('legacy_module')) {
return NULL;
}
\Drupal::service('module_installer')->uninstall(['legacy_module']);
return 'Uninstalled the retired legacy_module.';
}The guard makes it idempotent: it does nothing on environments where the module is already gone (local, fresh installs, or a re-run). Because the stub code is still on disk, the uninstall runs cleanly, and every subsequent drush step bootstraps without tripping over an orphan.
Phase two — delete the code. Once the first release has deployed everywhere and every environment has run the uninstall, ship a second, trivial release that deletes the stub folder and the now-spent post-update hook. There is no longer an installed module anywhere for the missing code to contradict.
Config-as-code makes most of Drupal reproducible from Git, but installed-module state lives in each environment’s database, not in your repository. Removing a module is therefore a migration, not a file deletion: it needs an uninstall step that runs against every running environment before the code is allowed to vanish. Bake that into your release checklist and an entire class of mid-deploy failures simply stops happening.
The pattern is small, but it converts a scary, hard-to-diagnose production failure into a boring two-step chore — which is exactly what infrastructure work should be.