diff --git a/CRM/Contribute/WorkflowMessage/Contribution/BasicContribution.ex.php b/CRM/Contribute/WorkflowMessage/Contribution/BasicContribution.php
similarity index 100%
rename from CRM/Contribute/WorkflowMessage/Contribution/BasicContribution.ex.php
rename to CRM/Contribute/WorkflowMessage/Contribution/BasicContribution.php
diff --git a/CRM/Contribute/WorkflowMessage/RecurringEdit/AlexCancelled.ex.php b/CRM/Contribute/WorkflowMessage/RecurringEdit/AlexCancelled.php
similarity index 100%
rename from CRM/Contribute/WorkflowMessage/RecurringEdit/AlexCancelled.ex.php
rename to CRM/Contribute/WorkflowMessage/RecurringEdit/AlexCancelled.php
diff --git a/CRM/Contribute/WorkflowMessage/RecurringEdit/BarbPending.ex.php b/CRM/Contribute/WorkflowMessage/RecurringEdit/BarbPending.php
similarity index 100%
rename from CRM/Contribute/WorkflowMessage/RecurringEdit/BarbPending.ex.php
rename to CRM/Contribute/WorkflowMessage/RecurringEdit/BarbPending.php
diff --git a/CRM/Extension/Manager/Module.php b/CRM/Extension/Manager/Module.php
index 4dc170aaf35539523a8c6375198e98888c6c1b5b..b8d385cd59a3e1c384d7147fa38c763268c72cae 100644
--- a/CRM/Extension/Manager/Module.php
+++ b/CRM/Extension/Manager/Module.php
@@ -97,6 +97,15 @@ class CRM_Extension_Manager_Module extends CRM_Extension_Manager_Base {
     $this->callHook($info, 'enable');
   }
 
+  public function onPostReplace(CRM_Extension_Info $oldInfo, CRM_Extension_Info $newInfo) {
+    // Like everything, ClassScanner is probably affected by pre-existing/long-standing issue dev/core#3686.
+    // This may mitigate a couple edge-cases. But really #3686 needs a different+deeper fix.
+    \Civi\Core\ClassScanner::cache('structure')->flush();
+    \Civi\Core\ClassScanner::cache('index')->flush();
+
+    parent::onPostReplace($oldInfo, $newInfo);
+  }
+
   /**
    * @param CRM_Extension_Info $info
    */
diff --git a/CRM/Utils/Hook.php b/CRM/Utils/Hook.php
index c44273c8422974d632b177ed31f5b0f7d8252d81..b3a20a312f90bb264818f128502db5f2df4924f6 100644
--- a/CRM/Utils/Hook.php
+++ b/CRM/Utils/Hook.php
@@ -1676,6 +1676,22 @@ abstract class CRM_Utils_Hook {
     );
   }
 
+  /**
+   * (EXPERIMENTAL) Scan extensions for a list of auto-registered interfaces.
+   *
+   * This hook is currently experimental. It is a means to implementing `mixin/scan-classes@1`.
+   * If there are no major difficulties circa 5.55, then it can be marked stable.
+   *
+   * @param string[] $classes
+   *   List of classes which may be of interest to the class-scanner.
+   */
+  public static function scanClasses(array &$classes) {
+    self::singleton()->invoke(['classes'], $classes, self::$_nullObject,
+      self::$_nullObject, self::$_nullObject, self::$_nullObject, self::$_nullObject,
+      'civicrm_scanClasses'
+    );
+  }
+
   /**
    * This hook is called when we are determining the contactID for a specific
    * email address
diff --git a/CRM/Utils/System.php b/CRM/Utils/System.php
index 72946f43eed09f8b64e41fbd54b0fa204b896ef3..d92b359d7e031a44d7325c2175d2a69239cd7233 100644
--- a/CRM/Utils/System.php
+++ b/CRM/Utils/System.php
@@ -1487,13 +1487,7 @@ class CRM_Utils_System {
     // a bit aggressive, but livable for now
     CRM_Utils_Cache::singleton()->flush();
 
-    // Traditionally, systems running on memory-backed caches were quite
-    // zealous about destroying *all* memory-backed caches during a flush().
-    // These flushes simulate that legacy behavior. However, they should probably
-    // be removed at some point.
-    $localDrivers = ['CRM_Utils_Cache_ArrayCache', 'CRM_Utils_Cache_NoCache'];
-    if (Civi\Core\Container::isContainerBooted()
-      && !in_array(get_class(CRM_Utils_Cache::singleton()), $localDrivers)) {
+    if (Civi\Core\Container::isContainerBooted()) {
       Civi::cache('long')->flush();
       Civi::cache('settings')->flush();
       Civi::cache('js_strings')->flush();
@@ -1503,6 +1497,7 @@ class CRM_Utils_System {
       Civi::cache('customData')->flush();
       Civi::cache('contactTypes')->clear();
       Civi::cache('metadata')->clear();
+      \Civi\Core\ClassScanner::cache('index')->flush();
       CRM_Extension_System::singleton()->getCache()->flush();
       CRM_Cxn_CiviCxnHttp::singleton()->getCache()->flush();
     }
diff --git a/Civi/Api4/Action/ExampleData/Get.php b/Civi/Api4/Action/ExampleData/Get.php
index 9f91c82221f5f13990c3815e87878162416da78d..69a7eebec9f3d622187443c077a06e52c3d3aed6 100644
--- a/Civi/Api4/Action/ExampleData/Get.php
+++ b/Civi/Api4/Action/ExampleData/Get.php
@@ -19,7 +19,7 @@ use Civi\Test\ExampleDataLoader;
 /**
  * Get a list of example data-sets.
  *
- * Examples are generated by scanning `*.ex.php` files. The scanner caches
+ * Examples are generated by scanning `ExampleDataInterface` files. The scanner caches
  * metadata fields (`name`, `title`, `tags`, `file`) to avoid extraneous scanning, but
  * substantive fields (`data`) are computed as-needed.
  *
diff --git a/Civi/Core/ClassScanner.php b/Civi/Core/ClassScanner.php
new file mode 100644
index 0000000000000000000000000000000000000000..d1b352361603c2aff3c822901508597c507835d3
--- /dev/null
+++ b/Civi/Core/ClassScanner.php
@@ -0,0 +1,245 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Core;
+
+/**
+ * The ClassScanner is a helper for finding/loading classes based on their tagged interfaces.
+ *
+ * The implementation of scanning+caching are generally built on these assumptions:
+ *
+ * - Scanning the filesystem can be expensive. One scan should serve many consumers.
+ * - Consumers want to know about specific interfaces (`get(['interface' => 'CRM_Foo_BarInterface'])`.
+ *
+ * We reconcile these goals by performing a single scan and then storing separate cache-items for each
+ * known interface (eg `$cache->get(md5('CRM_Foo_BarInterface'))`).
+ */
+class ClassScanner {
+
+  /**
+   * We cache information about classes that support each interface. Which interfaces should we track?
+   */
+  const CIVI_INTERFACE_REGEX = ';^(CRM_|Civi\\\);';
+
+  /**
+   * We load PHP files to find classes. Which files should we load?
+   */
+  const CIVI_CLASS_FILE_REGEX = '/^([A-Z][A-Za-z0-9]*)\.php$/';
+
+  const TTL = 3 * 24 * 60 * 60;
+
+  /**
+   * @var array
+   */
+  private static $caches;
+
+  /**
+   * @param array $criteria
+   *   Ex: ['interface' => 'Civi\Core\HookInterface']
+   * @return string[]
+   *   List of matching classes.
+   */
+  public static function get(array $criteria): array {
+    if (!isset($criteria['interface'])) {
+      throw new \RuntimeException("Malformed request: ClassScanner::get() must specify an interface filter");
+    }
+
+    $cache = static::cache('index');
+    $interface = $criteria['interface'];
+    $interfaceId = md5($interface);
+
+    $knownInterfaces = $cache->get('knownInterfaces');
+    if ($knownInterfaces === NULL) {
+      $knownInterfaces = static::buildIndex($cache);
+      $cache->set('knownInterfaces', $knownInterfaces, static::TTL);
+    }
+    if (!in_array($interface, $knownInterfaces)) {
+      return [];
+    }
+
+    $classes = $cache->get($interfaceId);
+    if ($classes === NULL) {
+      // Some cache backends don't guarantee the completeness of the set.
+      //I suppose this one got purged early. We'll need to rebuild the whole set.
+      $knownInterfaces = static::buildIndex($cache);
+      $cache->set('knownInterfaces', $knownInterfaces, static::TTL);
+      $classes = $cache->get($interfaceId);
+    }
+
+    return static::filterLiveClasses($classes ?: [], $criteria);
+  }
+
+  /**
+   * Fill the 'index' cache with information about all available interfaces.
+   *
+   * Every extant interface will be stored as a separate cache-item.
+   *
+   * Example:
+   *   assert $cache->get(md5(HookInterface::class)) == ['CRM_Foo_Bar', 'Civi\Whiz\Bang']
+   *
+   * @return string[]
+   *   List of PHP interfaces that were detected
+   */
+  private static function buildIndex(\CRM_Utils_Cache_Interface $cache): array {
+    $allClasses = static::scanClasses();
+    $byInterface = [];
+    foreach ($allClasses as $class) {
+      foreach (static::getRelevantInterfaces($class) as $interface) {
+        $byInterface[$interface][] = $class;
+      }
+    }
+
+    $cache->flush();
+    foreach ($byInterface as $interface => $classes) {
+      $cache->set(md5($interface), $classes, static::TTL);
+    }
+
+    return array_keys($byInterface);
+  }
+
+  /**
+   * @return array
+   *   Ex: ['CRM_Foo_Bar', 'Civi\Whiz\Bang']
+   */
+  private static function scanClasses(): array {
+    $classes = static::scanCoreClasses();
+    if (\CRM_Utils_Constant::value('CIVICRM_UF') !== 'UnitTests') {
+      \CRM_Utils_Hook::scanClasses($classes);
+    }
+    return $classes;
+  }
+
+  /**
+   * @return array
+   *   Ex: ['CRM_Foo_Bar', 'Civi\Whiz\Bang']
+   */
+  private static function scanCoreClasses(): array {
+    $cache = static::cache('structure');
+    $cacheKey = 'ClassScanner_core';
+    $classes = $cache->get($cacheKey);
+    if ($classes !== NULL) {
+      return $classes;
+    }
+
+    $civicrmRoot = \Civi::paths()->getPath('[civicrm.root]/');
+
+    // TODO: Consider expanding this search.
+    $classes = [];
+    static::scanFolders($classes, $civicrmRoot, 'Civi/Test/ExampleData', '\\');
+    static::scanFolders($classes, $civicrmRoot, 'CRM/*/WorkflowMessage', '_');
+    static::scanFolders($classes, $civicrmRoot, 'Civi/*/WorkflowMessage', '\\');
+    static::scanFolders($classes, $civicrmRoot, 'Civi/WorkflowMessage', '\\');
+    if (\CRM_Utils_Constant::value('CIVICRM_UF') === 'UnitTests') {
+      static::scanFolders($classes, $civicrmRoot . 'tests/phpunit', 'CRM/*/WorkflowMessage', '_');
+      static::scanFolders($classes, $civicrmRoot . 'tests/phpunit', 'Civi/*/WorkflowMessage', '\\');
+    }
+
+    $cache->set($cacheKey, $classes, static::TTL);
+    return $classes;
+  }
+
+  private static function filterLiveClasses(array $classes, array $criteria): array {
+    return array_filter($classes, function($class) use ($criteria) {
+      if (!class_exists($class)) {
+        return FALSE;
+      }
+      $reflClass = new \ReflectionClass($class);
+      return !$reflClass->isAbstract() && ($reflClass)->implementsInterface($criteria['interface']);
+    });
+  }
+
+  private static function getRelevantInterfaces(string $class): array {
+    $rawInterfaceNames = (new \ReflectionClass($class))->getInterfaceNames();
+    return preg_grep(static::CIVI_INTERFACE_REGEX, $rawInterfaceNames);
+  }
+
+  /**
+   * Search some $classRoot folder for a list of classes.
+   *
+   * Return any classes that implement a Civi-related interface, such as ExampleDataInterface
+   * or HookInterface. (Specifically, interfaces matchinv CIVI_INTERFACE_REGEX.)
+   *
+   * @internal
+   *   Currently reserved for use within civicrm-core. Signature may change.
+   * @param string[] $classes
+   *   List of known/found classes.
+   * @param string $classRoot
+   *   The base folder in which to search.
+   *   Ex: The $civicrm_root or some extension's basedir.
+   * @param string $classDir
+   *   Folder to search (within the $classRoot).
+   *   May use wildcards.
+   *   Ex: "CRM" or "Civi"
+   * @param string $classDelim
+   *   Namespace separator, eg underscore or backslash.
+   */
+  public static function scanFolders(array &$classes, string $classRoot, string $classDir, string $classDelim): void {
+    $classRoot = \CRM_Utils_File::addTrailingSlash($classRoot, '/');
+
+    $baseDirs = (array) glob($classRoot . $classDir);
+    foreach ($baseDirs as $baseDir) {
+      foreach (\CRM_Utils_File::findFiles($baseDir, '*.php') as $absFile) {
+        if (!preg_match(static::CIVI_CLASS_FILE_REGEX, basename($absFile))) {
+          continue;
+        }
+        $absFile = str_replace(DIRECTORY_SEPARATOR, '/', $absFile);
+        $relFile = \CRM_Utils_File::relativize($absFile, $classRoot);
+        $class = str_replace('/', $classDelim, substr($relFile, 0, -4));
+        if (class_exists($class)) {
+          $interfaces = static::getRelevantInterfaces($class);
+          if ($interfaces) {
+            $classes[] = $class;
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * @param string $name
+   *   - The 'index' cache describes the list of live classes that match an interface. It persists for the
+   *     duration of the system-configuration (eg cleared by system-flush or enable/disable extension).
+   *   - The 'structure' cache describes the class-structure within each extension. It persists for the
+   *     duration of the current page-view and is essentially write-once. This minimizes extra scans during testing.
+   *     (It could almost use Civi::$statics, except we want it to survive throughout testing.)
+   *   - Note: Typical runtime usage should only hit the 'index' cache. The 'structure' cache should only
+   *     be relevant following a system-flush.
+   * @return \CRM_Utils_Cache_Interface
+   * @internal
+   */
+  public static function cache(string $name): \CRM_Utils_Cache_Interface {
+    // Class-scanner runs before container is available. Manage our own cache. (Similar to extension-cache.)
+    // However, unlike extension-cache, we do not want to prefetch all interface lists on all pageloads.
+
+    if (!isset(static::$caches[$name])) {
+      switch ($name) {
+        case 'index':
+          if (empty($_DB_DATAOBJECT['CONFIG'])) {
+            // Atypical example: You have a test with a @dataProvider that relies on ClassScanner. Runs before bot.
+            return new \CRM_Utils_Cache_ArrayCache([]);
+          }
+          static::$caches[$name] = \CRM_Utils_Cache::create([
+            'name' => 'classes',
+            'type' => ['*memory*', 'SqlGroup', 'ArrayCache'],
+            'fastArray' => TRUE,
+          ]);
+
+        case 'structure':
+          static::$caches[$name] = new \CRM_Utils_Cache_ArrayCache([]);
+          break;
+
+      }
+    }
+
+    return static::$caches[$name];
+  }
+
+}
diff --git a/Civi/Test/ExampleData/Contact/Alex.ex.php b/Civi/Test/ExampleData/Contact/Alex.php
similarity index 100%
rename from Civi/Test/ExampleData/Contact/Alex.ex.php
rename to Civi/Test/ExampleData/Contact/Alex.php
diff --git a/Civi/Test/ExampleData/Contact/Barb.ex.php b/Civi/Test/ExampleData/Contact/Barb.php
similarity index 100%
rename from Civi/Test/ExampleData/Contact/Barb.ex.php
rename to Civi/Test/ExampleData/Contact/Barb.php
diff --git a/Civi/Test/ExampleData/Contribution/Euro5990.ex.php b/Civi/Test/ExampleData/Contribution/Euro5990.php
similarity index 100%
rename from Civi/Test/ExampleData/Contribution/Euro5990.ex.php
rename to Civi/Test/ExampleData/Contribution/Euro5990.php
diff --git a/Civi/Test/ExampleData/ContributionRecur/Euro5990.ex.php b/Civi/Test/ExampleData/ContributionRecur/Euro5990.php
similarity index 100%
rename from Civi/Test/ExampleData/ContributionRecur/Euro5990.ex.php
rename to Civi/Test/ExampleData/ContributionRecur/Euro5990.php
diff --git a/Civi/Test/ExampleDataLoader.php b/Civi/Test/ExampleDataLoader.php
index e26cb4198b0fa1097ed3e5da80c6322e7233290d..faa819d571952ec09b985923f4fb3896c5564b6e 100644
--- a/Civi/Test/ExampleDataLoader.php
+++ b/Civi/Test/ExampleDataLoader.php
@@ -11,6 +11,8 @@
 
 namespace Civi\Test;
 
+use Civi\Core\ClassScanner;
+
 class ExampleDataLoader {
 
   /**
@@ -64,7 +66,7 @@ class ExampleDataLoader {
       return NULL;
     }
 
-    $obj = $this->createObj($example['file'], $example['class']);
+    $obj = $this->createObj($example['class']);
     $obj->build($example);
     return $example;
   }
@@ -77,22 +79,15 @@ class ExampleDataLoader {
    * @throws \ReflectionException
    */
   protected function findMetas(): array {
-    $classes = array_merge(
-      // This scope of search is decidedly narrow - it should probably be expanded.
-      $this->scanExampleClasses(\Civi::paths()->getPath('[civicrm.root]/'), 'Civi/Test/ExampleData', '\\'),
-      $this->scanExampleClasses(\Civi::paths()->getPath('[civicrm.root]/'), 'CRM/*/WorkflowMessage', '_'),
-      $this->scanExampleClasses(\Civi::paths()->getPath('[civicrm.root]/'), 'Civi/*/WorkflowMessage', '\\'),
-      $this->scanExampleClasses(\Civi::paths()->getPath('[civicrm.root]/'), 'Civi/WorkflowMessage', '\\'),
-      $this->scanExampleClasses(\Civi::paths()->getPath('[civicrm.root]/tests/phpunit/'), 'CRM/*/WorkflowMessage', '_'),
-      $this->scanExampleClasses(\Civi::paths()->getPath('[civicrm.root]/tests/phpunit/'), 'Civi/*/WorkflowMessage', '\\')
-    );
+    $classes = ClassScanner::get(['interface' => ExampleDataInterface::class]);
 
     $all = [];
-    foreach ($classes as $file => $class) {
-      $obj = $this->createObj($file, $class);
+    foreach ($classes as $class) {
+      $reflClass = new \ReflectionClass($class);
+      $obj = $this->createObj($class);
       $offset = 0;
       foreach ($obj->getExamples() as $example) {
-        $example['file'] = $file;
+        $example['file'] = \CRM_Utils_File::relativize($reflClass->getFileName(), \Civi::paths()->getPath('[civicrm.root]/'));
         $example['class'] = $class;
         if (!isset($example['name'])) {
           $example['name'] = $example['class'] . '#' . $offset;
@@ -105,43 +100,9 @@ class ExampleDataLoader {
     return $all;
   }
 
-  /**
-   * @param $classRoot
-   *   Ex: Civi root dir.
-   * @param $classDir
-   *   Folder to search (within the parent).
-   * @param $classDelim
-   *   Namespace separator, eg underscore or backslash.
-   * @return array
-   *   Array(string $includeFile => string $className).
-   */
-  private function scanExampleClasses($classRoot, $classDir, $classDelim): array {
-    $civiRoot = \Civi::paths()->getPath('[civicrm.root]/');
-    $classRoot = \CRM_Utils_File::addTrailingSlash($classRoot, '/');
-    // Prefer include-paths relative to civiRoot - eg make tests/phpunit/* loadable at runtime.
-    $includeRoot = \CRM_Utils_File::isChildPath($civiRoot, $classRoot) ? $civiRoot : $classRoot;
-
-    $r = [];
-    $exDirs = (array) glob($classRoot . $classDir);
-    foreach ($exDirs as $exDir) {
-      foreach (\CRM_Utils_File::findFiles($exDir, '*.ex.php') as $file) {
-        $file = str_replace(DIRECTORY_SEPARATOR, '/', $file);
-        $includeFile = \CRM_Utils_File::relativize($file, $includeRoot);
-        $classFile = \CRM_Utils_File::relativize($file, $classRoot);
-        $class = str_replace('/', $classDelim, preg_replace('/\.ex\.php$/', '',
-          $classFile));
-        $r[$includeFile] = $class;
-      }
-    }
-    return $r;
-  }
-
-  private function createObj(?string $file, ?string $class): ExampleDataInterface {
-    if ($file) {
-      include_once $file;
-    }
+  private function createObj(?string $class): ExampleDataInterface {
     if (!class_exists($class)) {
-      throw new \CRM_Core_Exception("Failed to read example (class '{$class}' in file '{$file}')");
+      throw new \CRM_Core_Exception("Failed to read example (class '{$class}')");
     }
 
     return new $class();
diff --git a/Civi/WorkflowMessage/GenericWorkflowMessage/Alex.ex.php b/Civi/WorkflowMessage/GenericWorkflowMessage/Alex.php
similarity index 100%
rename from Civi/WorkflowMessage/GenericWorkflowMessage/Alex.ex.php
rename to Civi/WorkflowMessage/GenericWorkflowMessage/Alex.php
diff --git a/Civi/WorkflowMessage/WorkflowMessage.php b/Civi/WorkflowMessage/WorkflowMessage.php
index 7ecf283bdbe50a8bbaa6b5ab827a9ff17263cfb4..8d9296b67e3a2cdaa484069dacbfbe4647e63eae 100644
--- a/Civi/WorkflowMessage/WorkflowMessage.php
+++ b/Civi/WorkflowMessage/WorkflowMessage.php
@@ -13,6 +13,7 @@
 namespace Civi\WorkflowMessage;
 
 use Civi\Api4\Utils\ReflectionUtils;
+use Civi\Core\ClassScanner;
 use Civi\WorkflowMessage\Exception\WorkflowMessageException;
 
 /**
@@ -146,18 +147,9 @@ class WorkflowMessage {
     $map = $cache->get($cacheKey);
     if ($map === NULL) {
       $map = [];
-      $map['generic'] = GenericWorkflowMessage::class;
-      $baseDirs = explode(PATH_SEPARATOR, get_include_path());
-      foreach ($baseDirs as $baseDir) {
-        $baseDir = \CRM_Utils_File::addTrailingSlash($baseDir);
-        $glob = (array) glob($baseDir . 'CRM/*/WorkflowMessage/*.php');
-        $glob = preg_grep('/\.ex\.php$/', $glob, PREG_GREP_INVERT);
-        foreach ($glob as $file) {
-          $class = strtr(preg_replace('/\.php$/', '', \CRM_Utils_File::relativize($file, $baseDir)), ['/' => '_', '\\' => '_']);
-          if (class_exists($class) && (new \ReflectionClass($class))->implementsInterface(WorkflowMessageInterface::class)) {
-            $map[$class::WORKFLOW] = $class;
-          }
-        }
+      foreach (ClassScanner::get(['interface' => WorkflowMessageInterface::class]) as $wfClass) {
+        $wfName = ($wfClass === GenericWorkflowMessage::class) ? 'generic' : $wfClass::WORKFLOW;
+        $map[$wfName] = $wfClass;
       }
       $cache->set($cacheKey, $map);
     }
diff --git a/mixin/scan-classes@1/example/CRM/Shimmy/ShimmyMessage.php b/mixin/scan-classes@1/example/CRM/Shimmy/ShimmyMessage.php
new file mode 100644
index 0000000000000000000000000000000000000000..8c6811ab16da673d0ca44b28f2198ca5a1ebfe39
--- /dev/null
+++ b/mixin/scan-classes@1/example/CRM/Shimmy/ShimmyMessage.php
@@ -0,0 +1,13 @@
+<?php
+
+class CRM_Shimmy_ShimmyMessage extends Civi\WorkflowMessage\GenericWorkflowMessage {
+
+  public const WORKFLOW = 'shimmy_message_example';
+
+  /**
+   * @var string
+   * @scope tplParams
+   */
+  protected $foobar;
+
+}
diff --git a/mixin/scan-classes@1/example/tests/mixin/ScanClassesTest.php b/mixin/scan-classes@1/example/tests/mixin/ScanClassesTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e993c84e17f6cbb52934374dd5a25ad3acaebf48
--- /dev/null
+++ b/mixin/scan-classes@1/example/tests/mixin/ScanClassesTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Civi\Shimmy\Mixins;
+
+/**
+ * Assert that the 'scan-classes' mixin is working properly.
+ *
+ * This class defines the assertions to run when installing or uninstalling the extension.
+ * It is called as part of E2E_Shimmy_LifecycleTest.
+ *
+ * @see E2E_Shimmy_LifecycleTest
+ */
+class ScanClassesTest extends \PHPUnit\Framework\Assert {
+
+  public function testPreConditions($cv) {
+    $this->assertFileExists(static::getPath('/CRM/Shimmy/ShimmyMessage.php'), 'The shimmy extension must have example PHP files.');
+  }
+
+  public function testInstalled($cv) {
+    // Assert that WorkflowMessageInterface's are registered.
+    $items = $cv->api4('WorkflowMessage', 'get', ['where' => [['name', '=', 'shimmy_message_example']]]);
+    $this->assertEquals('CRM_Shimmy_ShimmyMessage', $items[0]['class']);
+  }
+
+  public function testDisabled($cv) {
+    // Assert that WorkflowMessageInterface's are removed.
+    $items = $cv->api4('WorkflowMessage', 'get', ['where' => [['name', '=', 'shimmy_message_example']]]);
+    $this->assertEmpty($items);
+  }
+
+  public function testUninstalled($cv) {
+    // Assert that WorkflowMessageInterface's are removed.
+    $items = $cv->api4('WorkflowMessage', 'get', ['where' => [['name', '=', 'shimmy_message_example']]]);
+    $this->assertEmpty($items);
+  }
+
+  protected static function getPath($suffix = ''): string {
+    return dirname(__DIR__, 2) . $suffix;
+  }
+
+}
diff --git a/mixin/scan-classes@1/mixin.php b/mixin/scan-classes@1/mixin.php
new file mode 100644
index 0000000000000000000000000000000000000000..f23dabaf523a95c012099c0a252cb1de32e77e77
--- /dev/null
+++ b/mixin/scan-classes@1/mixin.php
@@ -0,0 +1,66 @@
+<?php
+
+/**
+ * Scan for files which implement common Civi-PHP interfaces.
+ *
+ * Specifically, this listens to `hook_scanClasses` and reports any classes with Civi-related
+ * interfaces (eg `CRM_Foo_BarInterface` or `Civi\Foo\BarInterface`). For example:
+ *
+ *   - \Civi\Core\HookInterface
+ *   - \Civi\Test\ExampleDataInterface
+ *   - \Civi\WorkflowMessage\WorkflowMessageInterface
+ *
+ * If you are adding this to an existing extension, take care that you meet these assumptions:
+ *
+ *   - Classes live in 'CRM_' ('./CRM/**.php') or 'Civi\' ('./Civi/**.php').
+ *   - Class files only begin with uppercase letters.
+ *   - Class files only contain alphanumerics.
+ *   - Class files never have multiple dots in the name. ("CRM/Foo.php" is a class; "CRM/Foo.bar.php" is not).
+ *   - The ONLY files which match these patterns are STRICTLY class files.
+ *   - The ONLY classes which match these patterns are SAFE/INTENDED for use with `hook_scanClasses`.
+ *
+ * To minimize unintended activations, this only loads Civi interfaces. It skips other interfaces.
+ *
+ * @mixinName scan-classes
+ * @mixinVersion 1.0.0
+ * @since 5.52
+ *
+ * @param CRM_Extension_MixInfo $mixInfo
+ *   On newer deployments, this will be an instance of MixInfo. On older deployments, Civix may polyfill with a work-a-like.
+ * @param \CRM_Extension_BootCache $bootCache
+ *   On newer deployments, this will be an instance of MixInfo. On older deployments, Civix may polyfill with a work-a-like.
+ */
+
+/**
+ * @param \CRM_Extension_MixInfo $mixInfo
+ * @param \CRM_Extension_BootCache $bootCache
+ */
+return function ($mixInfo, $bootCache) {
+  /**
+   * @param \Civi\Core\Event\GenericHookEvent $event
+   */
+  Civi::dispatcher()->addListener('hook_civicrm_scanClasses', function ($event) use ($mixInfo) {
+    if (!$mixInfo->isActive()) {
+      return;
+    }
+
+    $cache = \Civi\Core\ClassScanner::cache('structure');
+    $cacheKey = $mixInfo->longName;
+    $all = $cache->get($cacheKey);
+    if ($all === NULL) {
+      $baseDir = CRM_Utils_File::addTrailingSlash($mixInfo->getPath());
+      $all = [];
+
+      \Civi\Core\ClassScanner::scanFolders($all, $baseDir, 'CRM', '_');
+      \Civi\Core\ClassScanner::scanFolders($all, $baseDir, 'Civi', '\\');
+      if (defined('CIVICRM_TEST')) {
+        \Civi\Core\ClassScanner::scanFolders($all, "$baseDir/tests/phpunit", 'CRM', '_');
+        \Civi\Core\ClassScanner::scanFolders($all, "$baseDir/tests/phpunit", 'Civi', '\\');
+      }
+      $cache->set($cacheKey, $all, \Civi\Core\ClassScanner::TTL);
+    }
+
+    $event->classes = array_merge($event->classes, $all);
+  });
+
+};
diff --git a/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseAdhocExample.ex.php b/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseAdhocExample.php
similarity index 100%
rename from tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseAdhocExample.ex.php
rename to tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseAdhocExample.php
diff --git a/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseModelExample.ex.php b/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseModelExample.php
similarity index 100%
rename from tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseModelExample.ex.php
rename to tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseModelExample.php
diff --git a/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivityTest.php b/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivityTest.php
index 8d29866cf64069e45a27a9f8d8ecf548400566a1..ce34bc710a5abde9116f62832f0d4f580728e4ee 100644
--- a/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivityTest.php
+++ b/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivityTest.php
@@ -52,7 +52,7 @@ class CRM_Case_WorkflowMessage_CaseActivityTest extends CiviUnitTestCase {
    * @throws \Civi\API\Exception\UnauthorizedException
    */
   public function testExampleGet() {
-    $file = \Civi::paths()->getPath('[civicrm.root]/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseModelExample.ex.php');
+    $file = \Civi::paths()->getPath('[civicrm.root]/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseModelExample.php');
     $name = 'workflow/case_activity/CaseModelExample';
 
     $this->assertTrue(file_exists($file), "Expect find canary file ($file)");
diff --git a/tests/phpunit/api/v4/Entity/ExampleDataTest.php b/tests/phpunit/api/v4/Entity/ExampleDataTest.php
index 82839674cbef708eca0ab473e73b1b14d35323cc..e7d3d73a386a16bac44d067cc239288dcdbeb7cc 100644
--- a/tests/phpunit/api/v4/Entity/ExampleDataTest.php
+++ b/tests/phpunit/api/v4/Entity/ExampleDataTest.php
@@ -33,7 +33,7 @@ class ExampleDataTest extends Api4TestBase {
    * @throws \Civi\API\Exception\UnauthorizedException
    */
   public function testGet() {
-    $file = \Civi::paths()->getPath('[civicrm.root]/Civi/WorkflowMessage/GenericWorkflowMessage/Alex.ex.php');
+    $file = \Civi::paths()->getPath('[civicrm.root]/Civi/WorkflowMessage/GenericWorkflowMessage/Alex.php');
     $name = 'workflow/generic/Alex';
 
     $this->assertTrue(file_exists($file), "Expect find canary file ($file)");