Drupal has two separate ideas of whether a module exists, and, unless something actively keeps them in sync, they're allowed to disagree with each other. One lives in core.extension config: is the module marked installed, does the database have its schema, does anything still reference its config objects. The other lives on disk: is the module's code really present in vendor/ or a custom module directory. Normally these move together and you never have to think about them as two things. During one cleanup session on this site, they came apart three separate times, in three different directions, and each one needed a different fix.
Code gone, config still says installed
The stable and classy base themes were removed from Drupal core entirely as of Drupal 10; the code simply stopped shipping. showcase_lite, an older theme predating this site's current one, was gone from the codebase for a different, more mundane reason: nobody had used it in years and it had quietly stopped being carried forward. In both cases, config sync still listed them as installed. Neither was the site's default or admin theme, and nothing depended on them being active, but their presence in config was enough that drush config:import started failing validation, because Drupal was trying to reconcile a declared state ("these themes are installed") against a filesystem that had no code to back that claim up. Cleaning it up meant uninstalling all three properly and re-exporting, which also swept out roughly ten leftover showcase_lite block configs and six more from an even older generation of the theme that had been orphaned for even longer without anyone noticing.
Code and config both agree, but the wrong thing shipped
advagg was the opposite case: a module that was genuinely installed and genuinely doing work, CSS/JS aggregation and minification, right up until it became the thing blocking a Drupal 11 upgrade because it had no compatible release. Removing it correctly meant respecting the dependency between the two states rather than just deleting one: drush pm:uninstall first, while the code still existed so its uninstall hooks could run cleanly across all seven advagg submodules, and only then composer remove to take the code away. Get that sequencing right locally and the two states — installed-in-config and present-on-disk — go back to false together, cleanly, in the correct order.
Config says removed, but it shouldn't have been
The third case ran the other way entirely, and it's the one that's easiest to miss because nothing on the live site was actually broken. google_tag was fully intact: enabled in production, code present, nothing wrong with it. But an earlier local config export had mistakenly stripped it out of the exported config anyway, so local's version of core.extension claimed it wasn't installed when production's very much did. That's not a phantom module in the usual sense; nothing was broken on the actual site. It was a quieter version of the same underlying problem: the two representations of "does this module exist" had drifted apart, just in the direction of config being wrong instead of code being wrong. Fixing it was the mirror image of the theme cleanup: restoring the module to config rather than removing it, to make the export match what was really true in production.
What ties these together
None of these three needed a different diagnostic technique to spot; all three showed up as drush config:import refusing to proceed cleanly, or as a "missing module code" symptom that turned out not to be a code bug at all. What they needed was recognizing, each time, which of the two states was wrong rather than assuming code and config always drift in the same direction. "Uninstalled" is a claim Drupal's own config makes about itself. "Gone" is a fact about the filesystem. They usually change together because most workflows change them together, but nothing enforces that they have to, and a session that does a lot of module cleanup in a short window is exactly the kind of session where they'll come apart more than once. The fix each time was small. Noticing which direction the disagreement ran, before touching anything, was the part that mattered most.