/Module.php -> PlugixaCrm\Modules\\Module * * An extension adds a whole directory of modules through `plugixa_crm_module_roots`, or single * module classes through `plugixa_crm_modules`. * * FIVE LOOPS, AND A SWITCHED-OFF APP SITS OUT TWO OF THEM. The App manager needed no new mechanism, * only a decision about which loops a disabled module skips: * * discover() RUNS — it must still be listable, describable and migratable * registerLifecycle() RUNS — cleanup, merge, erasure, uninstall: see LifecycleInterface * registerHooks() skipped — no screens' strings, no cron, no entity, no search, no figures * registerRoutes() skipped — an honest 404, never a 403 (see the method) * migrate() RUNS — "keeps all of its data" is a promise about the schema too * * @see AppState for how a stored flag becomes an answer. * @see AppInterface for how a module says what it is without Core naming it. * @see LifecycleInterface for what keeps listening while an app is off. */ final class ModuleRegistry { /** * Booted modules, keyed by id. * * @var array */ private array $modules = array(); /** * What each module says it is, keyed by id. Normalised, so consumers need no guards. * * @var array> */ private array $descriptors = array(); /** * Which modules actually run this request, keyed by id. * * @var array */ private array $enabled = array(); public function __construct( private readonly Container $container, private readonly AppState $state = new AppState() ) {} /** * Discover and instantiate every module, then let each register its services. */ public function discover(): void { /** * Filter the directories modules are discovered in. * * Each key is an absolute directory holding `/Module.php` folders; its value is the * namespace prefix those classes live under (with a trailing backslash). The extension that * adds a directory is responsible for autoloading its classes. * * @param array $roots Directory => namespace prefix. */ $roots = (array) apply_filters( 'plugixa_crm_module_roots', array( PLUGIXA_CRM_PATH . 'includes/Modules' => 'PlugixaCrm\\Modules\\' ) ); $classes = array(); foreach ( $roots as $dir => $namespace_prefix ) { if ( is_string( $dir ) && is_string( $namespace_prefix ) ) { $classes = array_merge( $classes, $this->scan( $dir, $namespace_prefix ) ); } } /** * Filter the discovered module classes. * * Lets a third-party add-on contribute a module without living inside this * plugin's directory. * * @param string[] $classes Fully-qualified class names implementing ModuleInterface. */ $classes = (array) apply_filters( 'plugixa_crm_modules', $classes ); foreach ( $classes as $class_name ) { if ( ! is_string( $class_name ) || ! class_exists( $class_name ) ) { continue; } if ( ! is_subclass_of( $class_name, ModuleInterface::class ) ) { continue; } // A broken module must not take the whole CRM (and its REST API) down. try { $module = $this->container->get( $class_name ); $module->registerServices( $this->container ); $this->modules[ $module->id() ] = $module; } catch ( Throwable $e ) { $this->log_failure( $class_name, $e ); } } $this->describe(); } /** * Let every module register its hooks: EVERY module's lifecycle, and each ENABLED module's * features. * * Skipping a disabled module's `registerHooks()` is what stops it: its screens' strings, its * entity descriptor, its cron listeners, its search provider and dashboard figures all go unsaid. * A disabled app is not a running app that refuses, it is one that is not there. * * Its `registerLifecycle()` runs regardless — see LifecycleInterface for what must keep listening * while an app is off, and why. This is also the method activation and uninstall call * (`Activator::ensure_module_hooks()`), so a switched-off module still cleans up after itself * when the plugin is deleted. */ public function registerHooks(): void { foreach ( $this->modules as $id => $module ) { try { if ( $module instanceof LifecycleInterface ) { $module->registerLifecycle( $this->container ); } if ( $this->enabled( $id ) ) { $module->registerHooks( $this->container ); } } catch ( Throwable $e ) { $this->log_failure( $module::class, $e ); } } } /** * Let every ENABLED module register its REST routes. Called on rest_api_init. * * NOT REGISTERING BEATS REGISTERING AND REFUSING. A registered route is listed in the REST index * at `/wp-json/plugixa-crm/v1` whether or not anybody may call it, so a switched-off module would * announce itself in the one place everybody looks. A disabled app answers `rest_no_route` — an * honest 404. An endpoint that must outlive the switch (an unsubscribe link already in somebody's * inbox) is registered from `registerLifecycle()` instead. */ public function registerRoutes(): void { foreach ( $this->modules as $id => $module ) { if ( ! $this->enabled( $id ) ) { continue; } try { $module->registerRoutes( $this->container ); } catch ( Throwable $e ) { $this->log_failure( $module::class, $e ); } } } /** * Run EVERY module's migration, enabled or not. Idempotent. * * DELIBERATELY UNGATED. The App manager promises that an app switched off keeps its data and can * be picked up where it was left. A disabled module whose schema stopped being upgraded would come * back one release later to a table shaped for an older version — the data still there and no * longer loading, which is worse than deleting it honestly. * * @return array Module id => what did not finish; empty when all did. */ public function migrate(): array { $failures = array(); // Still caught per module — one table the database refuses must not cost every other // module its migration — but RETURNED, so the activator does not record a version for a // schema that is not there. foreach ( $this->modules as $module ) { try { $module->migrate(); } catch ( MigrationFailed $e ) { $failures[ $module->id() ] = array( 'tables' => $e->tables(), 'reason' => $e->reason(), ); $this->log_failure( $module::class, $e ); } catch ( Throwable $e ) { // Not a table that failed to appear, but a migration that did not finish all the same. $failures[ $module->id() ] = array( 'tables' => array(), 'reason' => $e->getMessage(), ); $this->log_failure( $module::class, $e ); } } return $failures; } /** * Does this module run this request? * * A module with no descriptor — one an add-on contributes through `plugixa_crm_modules`, which * knows nothing about the App manager — is enabled. See `AppState::resolve()`. * * @param string $id Module id. */ public function enabled( string $id ): bool { return $this->enabled[ $id ] ?? true; } /** * Re-read the flags and work out what runs now. * * CALL THIS AFTER WRITING A FLAG, OR THE ANSWER HANDED BACK IS THE ONE FROM BEFORE THE WRITE. It * does not re-register anything: hooks and routes were decided earlier in this request and * cannot be taken back. The new state applies from the next request, which is what the page * reload after a toggle is for. */ public function refresh(): void { $this->enabled = $this->state->resolve( $this->descriptors ); } /** * Every module's self-description, normalised. * * @return array> */ public function descriptors(): array { return $this->descriptors; } /** * Module id => actually running. * * @return array */ public function enabledMap(): array { return $this->enabled; } /** * The record types owned by apps that are switched OFF. * * For the access policy: a type that is merely unregistered would otherwise be treated as one the * policy has no rule for, and any code that still reaches its table would read it unscoped. * * @return string[] */ public function dormantEntities(): array { $types = array(); foreach ( $this->descriptors as $id => $descriptor ) { if ( ! $this->enabled( $id ) ) { $types = array_merge( $types, $descriptor['entities'] ); } } return array_values( array_unique( $types ) ); } /** * The instance behind an id, so the App manager can ask it for counts. * * @param string $id Module id. */ public function module( string $id ): ?ModuleInterface { return $this->modules[ $id ] ?? null; } /** * The flag store, so the App manager writes through the instance that already read it — two * instances would disagree the moment one saved. */ public function state(): AppState { return $this->state; } /** * Every capability introduced by every module, merged. * * @return array Capability => roles. */ public function capabilities(): array { $caps = array(); foreach ( $this->modules as $module ) { foreach ( $module->capabilities() as $cap => $roles ) { $caps[ $cap ] = array_unique( array_merge( $caps[ $cap ] ?? array(), $roles ) ); } } return $caps; } /** * @return array Booted modules, keyed by id. */ public function all(): array { return $this->modules; } /** * Collect what each module says it is, then resolve the flags into one answer per module. * * Only `app()` is called here. `usage()` and `blocker()` may run COUNT queries and belong to the * one screen that asks for them — never to `plugins_loaded`. */ private function describe(): void { $this->descriptors = array(); foreach ( $this->modules as $id => $module ) { if ( ! $module instanceof AppInterface ) { continue; } try { $this->descriptors[ $id ] = $this->normalise( $id, $module->app() ); } catch ( Throwable $e ) { $this->log_failure( $module::class, $e ); } } $this->enabled = $this->state->resolve( $this->descriptors ); } /** * Fill in what a descriptor left out, so nothing downstream needs a guard. * * An unrecognised category is filed under `tools` rather than dropped: the module still exists * and still has to be switchable. * * @param string $id Module id. * @param array $descriptor What the module returned. * * @return array */ private function normalise( string $id, array $descriptor ): array { $category = (string) ( $descriptor['category'] ?? 'tools' ); $strings = static fn( $list ): array => array_values( array_filter( (array) $list, 'is_string' ) ); return array( 'id' => $id, // Reading order inside a category. Everything unranked lands after everything ranked. 'order' => (int) ( $descriptor['order'] ?? 100 ), 'category' => in_array( $category, AppState::CATEGORIES, true ) ? $category : 'tools', 'core' => (bool) ( $descriptor['core'] ?? false ), 'deps' => $strings( $descriptor['deps'] ?? array() ), 'deps_any' => $strings( $descriptor['deps_any'] ?? array() ), 'entities' => $strings( $descriptor['entities'] ?? array() ), 'default' => (bool) ( $descriptor['default'] ?? true ), 'label_key' => (string) ( $descriptor['label_key'] ?? 'app_' . $id ), 'desc_key' => (string) ( $descriptor['desc_key'] ?? 'app_' . $id . '_desc' ), 'icon' => (string) ( $descriptor['icon'] ?? 'apps' ), 'cron' => $strings( $descriptor['cron'] ?? array() ), 'hidden' => (bool) ( $descriptor['hidden'] ?? false ), ); } /** * Map each /Module.php under a root to its fully-qualified class name. * * @param string $dir Absolute directory to scan. * @param string $namespace_prefix PSR-4 prefix the directory maps to. * * @return string[] */ private function scan( string $dir, string $namespace_prefix ): array { if ( ! is_dir( $dir ) ) { return array(); } $classes = array(); foreach ( (array) glob( $dir . '/*/Module.php' ) as $file ) { $name = basename( dirname( (string) $file ) ); $classes[] = $namespace_prefix . $name . '\\Module'; } return $classes; } /** * @param string $class_name The module that failed. * @param Throwable $e The failure. */ private function log_failure( string $class_name, Throwable $e ): void { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log sprintf( '[Plugixa CRM] Module "%s" failed: %s', $class_name, $e->getMessage() ) ); } } }