container = new Container(); $this->container->instance( Container::class, $this->container ); $this->modules = new ModuleRegistry( $this->container ); $this->container->instance( ModuleRegistry::class, $this->modules ); } public static function instance(): self { self::$instance ??= new self(); return self::$instance; } public function container(): Container { return $this->container; } /** * Start the plugin. Called on plugins_loaded, ONCE. * * Once is a real precondition, not a figure of speech. Booting twice registers every module's hooks * twice, because `add_action()` deduplicates a callback by identity and two closures are never the * same object — the second registration appends rather than replaces, and every listener fires * twice. There is deliberately no guard here: the one caller is the `plugins_loaded` hook in * plugixa-crm.php, and a boot() that quietly absorbed a second call would hide the mistake rather * than prevent it. `PluginBootTest` asserts no hook ends up with a duplicate listener. * * The reason this is written down at all: tests/bootstrap.php used to call boot() itself as well, * so the suite ran with every module's hooks registered twice — twelve listeners on * `plugixa_crm_before_purge` for six modules. Nothing noticed, because every listener was a DELETE * ("forget this record's notes/tags/links") and deleting twice is indistinguishable from deleting * once. The first listener that INSERTED — a notification — put two rows in the bell for one * reminder, which is what finally gave it away. */ public function boot(): void { $this->modules->discover(); /* * Another plugin contributes modules through `plugixa_crm_modules` on `plugins_loaded` * before this runs. The host never looks for anyone's code; extensions find the host, through * the same door any third party would use. */ $this->modules->registerHooks(); // AFTER the modules, deliberately. It reads the filter catalogue, which the modules answer, // and a request-scoped narrowing that registered before its own vocabulary existed would // see an empty catalogue and refuse every filter as unknown. $this->container->get( FilterRequest::class )->register_hooks(); ( new Admin() )->register_hooks(); ( new Assets() )->register_hooks(); ( new RawResponse() )->register_hooks(); ( new RefusedWrite() )->register_hooks(); // The same application, mounted on a page of the site instead. Core rather than a module: // it registers no entity, owns no table and adds no authorisation rule — it is a second // door onto what Admin and Assets already open, and ModuleRegistry only discovers // includes/Modules/*/Module.php anyway. ( new AppShortcode() )->register_hooks(); // The control floor the lead form, the portal and the help centre all depend on. Registered // here rather than in any of them because all three need it and none owns it. It only ever // registers; each surface declares it as a dependency, which is what enqueues it. ( new ControlAssets() )->register_hooks(); // A "who am I / sign out" strip on the front end. Renders nothing unless the site has // declared itself non-production via WP_ENVIRONMENT_TYPE — see the class docblock for why // that gate and not WP_DEBUG. ( new DevBar() )->register_hooks(); // The daily currency-rate fetch. Core rather than Sales because the rate table is Core's and more // than deals read it; it schedules nothing until an administrator switches it on. $this->container->get( ExchangeFeed::class )->register_hooks(); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); // Schema drift guard: a plugin updated by file copy never fires its activation // hook, so new tables would silently not exist. Compare the stored version AND the set of // modules it was migrated with — see Activator::schema_is_current() for why the version alone // can leave a newly added module's tables missing after an update. // // ON `init` AT PRIORITY 0, NOT HERE ON `plugins_loaded`. A migration translates strings — the portal // role's name, for one — and before `after_setup_theme` that logs "translation loading was triggered // too early" on the very request that updated the site (Linux E2E re-run, 2026-09-11, the first // request after an in-place update). Nothing in the plugin reads its tables before `init`, // and priority 0 still runs ahead of every module's own `init` work, REST, admin and cron. add_action( 'init', function (): void { // `due()` keeps a migration that CANNOT finish from running on every request. if ( ! Activator::schema_is_current( $this->modules ) && Activator::due() ) { Activator::migrate(); } }, 0 ); // Hooked on every request, not only inside wp-admin: the two hooks fire nowhere else, and a // line that runs only in wp-admin is a line no test and no WP-CLI command ever executes. // Fully qualified: this file imports `Admin\Admin`, so a relative `Admin\MigrationNotice` // would resolve to `Admin\Admin\MigrationNotice` and fatal (plugixa-exam shipped that once). \PlugixaCrm\Core\Admin\MigrationNotice::register_hooks(); /** * Fires once the CRM is fully booted. */ do_action( 'plugixa_crm_loaded', $this->container ); } /** * Register core and module REST routes. */ public function register_routes(): void { $this->container->get( BootstrapController::class )->register_routes(); // Core owns these endpoints but knows none of their content: the modules contribute // the dashboard's figures, the search providers and the settings they own, all // through filters. $this->container->get( DashboardController::class )->register_routes(); $this->container->get( SearchController::class )->register_routes(); $this->container->get( SettingsController::class )->register_routes(); $this->container->get( UsersController::class )->register_routes(); $this->container->get( FiltersController::class )->register_routes(); // Acting on many records of a type known only at runtime — the same reason RecordWriter lives // in Core rather than in whichever module happened to need it first. $this->container->get( MassActionController::class )->register_routes(); // The example content spans four modules' tables, so no one module can own restoring it. $this->container->get( ExamplesController::class )->register_routes(); $this->modules->registerRoutes(); /** * Register additional CRM REST routes. * * @param string $namespace The CRM REST namespace. * @param Container $container The plugin container. */ do_action( 'plugixa_crm_register_rest_routes', 'plugixa-crm/v1', $this->container ); } public function modules(): ModuleRegistry { return $this->modules; } }