keyed by module id */ private array $modules = []; /** @var array merged routes */ private array $mergedRoutes = []; /** @var list merged public paths */ private array $mergedPublicPaths = []; /** @var list merged container registrar FQCNs */ private array $mergedContainerRegistrars = []; /** @var array> merged UI slots keyed by slot name */ private array $mergedUiSlots = []; /** @var list merged search resource provider FQCNs */ private array $mergedSearchResources = []; /** @var array> merged asset groups */ private array $mergedAssetGroups = []; /** @var list merged layout context provider FQCNs */ private array $mergedLayoutContextProviders = []; /** @var list merged session provider FQCNs */ private array $mergedSessionProviders = []; /** @var list merged permissions */ private array $mergedPermissions = []; /** @var list */ private array $mergedSchedulerJobs = []; /** * Boot the registry from a modules directory and activation config. * * @param string $modulesDir Absolute path to the modules/ directory * @param list $enabledModuleIds Module IDs to activate (from config + env) */ public static function boot(string $modulesDir, array $enabledModuleIds): self { $registry = new self(); if (!is_dir($modulesDir)) { return $registry; } // Discover and load manifests for enabled modules $manifests = []; foreach ($enabledModuleIds as $moduleId) { $moduleId = trim($moduleId); if ($moduleId === '') { continue; } $modulePath = rtrim($modulesDir, '/') . '/' . $moduleId; $manifestFile = $modulePath . '/module.php'; if (!is_file($manifestFile)) { throw new RuntimeException( "Module '{$moduleId}' is enabled but manifest not found at: {$manifestFile}" ); } $raw = require $manifestFile; if (!is_array($raw)) { throw new RuntimeException( "Module manifest at '{$manifestFile}' must return an array." ); } $manifest = ModuleManifest::fromArray($raw, $modulePath); if ($manifest->id !== $moduleId) { throw new RuntimeException( "Module directory '{$moduleId}' does not match manifest id '{$manifest->id}'." ); } $manifests[] = $manifest; } // Sort deterministically: load_order ASC, then id ASC usort($manifests, static function (ModuleManifest $a, ModuleManifest $b): int { return $a->loadOrder <=> $b->loadOrder ?: strcmp($a->id, $b->id); }); // Register and merge foreach ($manifests as $manifest) { $registry->registerModule($manifest); } return $registry; } /** * Resolve enabled module IDs from config array + ENV override. * * @param array{enabled_modules?: list} $config From config/modules.php * @return list */ public static function resolveEnabledModules(array $config): array { $fromConfig = $config['enabled_modules'] ?? []; $fromEnv = trim((string) getenv('APP_ENABLED_MODULES')); if ($fromEnv !== '') { $fromConfig = array_map('trim', explode(',', $fromEnv)); } return array_values(array_unique(array_filter($fromConfig, static fn (string $id): bool => trim($id) !== ''))); } private function registerModule(ModuleManifest $manifest): void { if (isset($this->modules[$manifest->id])) { throw new RuntimeException( "Duplicate module id: '{$manifest->id}' is already registered." ); } $this->modules[$manifest->id] = $manifest; // — Merge routes (fail-fast on collision) —————————————— $existingTargets = array_column($this->mergedRoutes, 'target'); foreach ($manifest->routes as $route) { $target = $route['target']; if (in_array($target, $existingTargets, true)) { throw new RuntimeException( "Route target conflict: '{$target}' from module '{$manifest->id}' collides with an existing route." ); } $this->mergedRoutes[] = $route; } // — Merge public paths ——————————————————————————— array_push($this->mergedPublicPaths, ...$manifest->publicPaths); // — Merge container registrars ———————————————————— array_push($this->mergedContainerRegistrars, ...$manifest->containerRegistrars); // — Merge UI slots (fail-fast on duplicate keys within same slot) —— foreach ($manifest->uiSlots as $slotName => $contributions) { if (!isset($this->mergedUiSlots[$slotName])) { $this->mergedUiSlots[$slotName] = []; } $contributionList = is_array($contributions) ? $contributions : [$contributions]; foreach ($contributionList as $contribution) { $key = is_array($contribution) ? ($contribution['key'] ?? null) : null; if ($key !== null) { foreach ($this->mergedUiSlots[$slotName] as $existing) { $existingKey = is_array($existing) ? ($existing['key'] ?? null) : null; if ($existingKey === $key) { throw new RuntimeException( "UI slot conflict: slot '{$slotName}' key '{$key}' from module '{$manifest->id}' already exists." ); } } } $this->mergedUiSlots[$slotName][] = $contribution; } } // — Merge search resources (fail-fast on duplicate provider) —— foreach ($manifest->searchResources as $provider) { if (in_array($provider, $this->mergedSearchResources, true)) { throw new RuntimeException( "Search resource conflict: provider '{$provider}' from module '{$manifest->id}' is already registered." ); } $this->mergedSearchResources[] = $provider; } // — Merge asset groups ———————————————————————————— foreach ($manifest->assetGroups as $groupName => $files) { if (isset($this->mergedAssetGroups[$groupName])) { throw new RuntimeException( "Asset group conflict: group '{$groupName}' from module '{$manifest->id}' already exists." ); } $this->mergedAssetGroups[$groupName] = $files; } // — Merge permissions (fail-fast on duplicate) ——————— foreach ($manifest->permissions as $permission) { if (in_array($permission, $this->mergedPermissions, true)) { throw new RuntimeException( "Permission conflict: '{$permission}' from module '{$manifest->id}' is already registered." ); } $this->mergedPermissions[] = $permission; } // — Merge layout context providers ———————————————— array_push($this->mergedLayoutContextProviders, ...$manifest->layoutContextProviders); // — Merge session providers —————————————————————— array_push($this->mergedSessionProviders, ...$manifest->sessionProviders); // — Merge scheduler jobs ————————————————————————— array_push($this->mergedSchedulerJobs, ...$manifest->schedulerJobs); } // ─── Getters ──────────────────────────────────────────────────────── /** @return array */ public function getModules(): array { return $this->modules; } public function hasModule(string $id): bool { return isset($this->modules[$id]); } public function getModule(string $id): ModuleManifest { if (!isset($this->modules[$id])) { throw new RuntimeException("Module '{$id}' is not registered."); } return $this->modules[$id]; } /** @return array */ public function getRoutes(): array { return $this->mergedRoutes; } /** @return list */ public function getPublicPaths(): array { return $this->mergedPublicPaths; } /** @return list */ public function getContainerRegistrars(): array { return $this->mergedContainerRegistrars; } /** @return array> */ public function getUiSlots(): array { return $this->mergedUiSlots; } /** * @return list Contributions for a specific slot */ public function getSlotContributions(string $slotName): array { return $this->mergedUiSlots[$slotName] ?? []; } /** @return list */ public function getSearchResources(): array { return $this->mergedSearchResources; } /** @return array> */ public function getAssetGroups(): array { return $this->mergedAssetGroups; } /** @return list */ public function getLayoutContextProviders(): array { return $this->mergedLayoutContextProviders; } /** @return list */ public function getSessionProviders(): array { return $this->mergedSessionProviders; } /** @return list */ public function getPermissions(): array { return $this->mergedPermissions; } /** @return list */ public function getSchedulerJobs(): array { return $this->mergedSchedulerJobs; } }