> */ private const UI_SLOT_REQUIRED_KEYS = [ 'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'], 'search.resource_item' => ['key', 'label', 'base_url', 'permission'], 'user.edit.aside_action' => ['key', 'type', 'label', 'permission'], 'topbar.right_item' => ['key', 'template'], 'layout.body_end_template' => ['key', 'template'], 'layout.head_style' => ['key', 'path'], 'runtime.component' => ['key', 'script'], ]; /** @var array 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 merged authorization policy FQCNs */ private array $mergedAuthorizationPolicies = []; /** * @var list, * module_id: string * }> */ private array $mergedSchedulerJobs = []; /** @var array route target => owning module id */ private array $routeTargetOwners = []; /** @var array route path => owning module id */ private array $routePathOwners = []; /** @var array permission key => owning module id */ private array $permissionOwners = []; /** @var array scheduler job key => owning module id */ private array $schedulerJobOwners = []; /** * 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 module lib dirs with Composer's PSR-4 autoloader so that // module classes (MintyPHP\Module\\*) resolve without a custom loader. self::registerModuleAutoloading($manifests); // Register and merge foreach ($manifests as $manifest) { $registry->registerModule($manifest); } $registry->finalizeMergedContributions(); 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'] ?? []; $fromEnvRaw = getenv('APP_ENABLED_MODULES'); // Semantics: // - not set => use config/modules.php // - set '' => explicit "no modules" // - set list => exact list from env if ($fromEnvRaw !== false) { $fromEnv = trim((string) $fromEnvRaw); $fromConfig = $fromEnv === '' ? [] : array_map('trim', explode(',', $fromEnv)); } return array_values(array_unique(array_filter($fromConfig, static fn (string $id): bool => trim($id) !== ''))); } /** * Register module lib/ directories with Composer's ClassLoader. * * @param list $manifests */ private static function registerModuleAutoloading(array $manifests): void { $composerLoader = null; foreach (spl_autoload_functions() as $autoloader) { if (is_array($autoloader) && $autoloader[0] instanceof ClassLoader) { $composerLoader = $autoloader[0]; break; } } if ($composerLoader === null) { return; } foreach ($manifests as $manifest) { $libDir = rtrim($manifest->basePath, '/') . '/lib'; if (is_dir($libDir)) { $composerLoader->addPsr4('MintyPHP\\', [$libDir]); } } } 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) —————————————— foreach ($manifest->routes as $rawRoute) { $route = $this->normalizeRoute($rawRoute, $manifest->id); $path = $route['path']; $target = $route['target']; if (isset($this->routeTargetOwners[$target]) && $this->routeTargetOwners[$target] !== $manifest->id) { $owner = $this->routeTargetOwners[$target]; throw new RuntimeException( "Route target conflict: '{$target}' from module '{$manifest->id}' collides with module '{$owner}'." ); } if (isset($this->routePathOwners[$path])) { $owner = $this->routePathOwners[$path]; throw new RuntimeException( "Route path conflict: '{$path}' from module '{$manifest->id}' collides with module '{$owner}'." ); } $this->routeTargetOwners[$target] = $manifest->id; $this->routePathOwners[$path] = $manifest->id; $this->mergedRoutes[] = $route; if (!empty($route['public'])) { $this->mergedPublicPaths[] = $path; } } // — 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) { $normalizedContribution = $this->normalizeUiSlotContribution($slotName, $contribution, $manifest->id, $manifest->basePath); $key = (string) $normalizedContribution['key']; 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][] = $normalizedContribution; } } // — Merge search resources (fail-fast on duplicate provider) —— foreach ($manifest->searchResources as $provider) { if (!is_string($provider) || trim($provider) === '') { throw new RuntimeException( "Search resource provider in module '{$manifest->id}' must be a non-empty class-string." ); } 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) { $permissionKey = trim((string) $permission['key']); if ($permissionKey === '') { throw new RuntimeException( "Permission in module '{$manifest->id}' must define non-empty 'key'." ); } if (isset($this->permissionOwners[$permissionKey])) { $owner = $this->permissionOwners[$permissionKey]; throw new RuntimeException( "Permission conflict: '{$permissionKey}' from module '{$manifest->id}' is already registered by module '{$owner}'." ); } $this->permissionOwners[$permissionKey] = $manifest->id; $this->mergedPermissions[] = [ 'key' => $permissionKey, 'description' => trim((string) $permission['description']), 'active' => (int) $permission['active'], 'is_system' => (int) $permission['is_system'], ]; } // — Merge layout context providers ———————————————— foreach ($manifest->layoutContextProviders as $providerClass) { if (!is_string($providerClass) || trim($providerClass) === '') { throw new RuntimeException( "Layout context provider in module '{$manifest->id}' must be a non-empty class-string." ); } $this->mergedLayoutContextProviders[] = $providerClass; } // — Merge session providers —————————————————————— foreach ($manifest->sessionProviders as $providerClass) { if (!is_string($providerClass) || trim($providerClass) === '') { throw new RuntimeException( "Session provider in module '{$manifest->id}' must be a non-empty class-string." ); } $this->mergedSessionProviders[] = $providerClass; } // — Merge authorization policies (fail-fast on duplicate) ——— foreach ($manifest->authorizationPolicies as $policyClass) { if (!is_string($policyClass) || trim($policyClass) === '') { throw new RuntimeException( "Authorization policy in module '{$manifest->id}' must be a non-empty class-string." ); } if (in_array($policyClass, $this->mergedAuthorizationPolicies, true)) { throw new RuntimeException( "Authorization policy conflict: '{$policyClass}' from module '{$manifest->id}' is already registered." ); } $this->mergedAuthorizationPolicies[] = $policyClass; } // — Merge scheduler jobs ————————————————————————— foreach ($manifest->schedulerJobs as $schedulerJob) { $jobKey = trim((string) $schedulerJob['job_key']); if ($jobKey === '') { throw new RuntimeException( "Scheduler job in module '{$manifest->id}' must define non-empty 'job_key'." ); } if (isset($this->schedulerJobOwners[$jobKey])) { $owner = $this->schedulerJobOwners[$jobKey]; throw new RuntimeException( "Scheduler job conflict: '{$jobKey}' from module '{$manifest->id}' is already registered by module '{$owner}'." ); } $this->schedulerJobOwners[$jobKey] = $manifest->id; $this->mergedSchedulerJobs[] = [ 'job_key' => $jobKey, 'handler' => trim((string) $schedulerJob['handler']), 'label' => trim((string) $schedulerJob['label']), 'description' => trim((string) $schedulerJob['description']), 'default_enabled' => (int) $schedulerJob['default_enabled'], 'default_timezone' => trim((string) $schedulerJob['default_timezone']), 'default_schedule_type' => trim((string) $schedulerJob['default_schedule_type']), 'default_schedule_interval' => (int) $schedulerJob['default_schedule_interval'], 'default_schedule_time' => $schedulerJob['default_schedule_time'], 'default_schedule_weekdays_csv' => $schedulerJob['default_schedule_weekdays_csv'], 'default_catchup_once' => (int) $schedulerJob['default_catchup_once'], 'allowed_schedule_types' => array_values($schedulerJob['allowed_schedule_types']), 'module_id' => $manifest->id, ]; } } private function finalizeMergedContributions(): void { $this->mergedPublicPaths = array_values(array_unique(array_filter( $this->mergedPublicPaths, static fn (mixed $path): bool => trim((string) $path) !== '' ))); foreach ($this->mergedUiSlots as $slotName => $items) { usort($items, static function (array $a, array $b): int { $orderCmp = ((int) ($a['order'] ?? 100)) <=> ((int) ($b['order'] ?? 100)); if ($orderCmp !== 0) { return $orderCmp; } $moduleCmp = strcmp((string) ($a['__module_id'] ?? ''), (string) ($b['__module_id'] ?? '')); if ($moduleCmp !== 0) { return $moduleCmp; } return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? '')); }); foreach ($items as &$item) { unset($item['__module_id']); } unset($item); $this->mergedUiSlots[$slotName] = $items; } } /** * @return array{path: string, target: string, module_id: string, public?: bool} */ private function normalizeRoute(mixed $rawRoute, string $moduleId): array { if (!is_array($rawRoute)) { throw new RuntimeException("Route entries in module '{$moduleId}' must be arrays."); } $path = trim((string) ($rawRoute['path'] ?? '')); $target = trim((string) ($rawRoute['target'] ?? '')); if ($path === '' || $target === '') { throw new RuntimeException("Routes in module '{$moduleId}' must define non-empty 'path' and 'target'."); } $normalized = [ 'path' => $path, 'target' => $target, 'module_id' => $moduleId, ]; if (array_key_exists('public', $rawRoute)) { $normalized['public'] = (bool) $rawRoute['public']; } return $normalized; } /** * @return array */ private function normalizeUiSlotContribution(string $slotName, mixed $rawContribution, string $moduleId, string $basePath): array { if (!is_array($rawContribution)) { throw new RuntimeException( "UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must be an array." ); } $key = trim((string) ($rawContribution['key'] ?? '')); if ($key === '') { throw new RuntimeException( "UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must define a non-empty 'key'." ); } // Resolve relative template paths to absolute using the module's basePath. foreach (['panel_template', 'template'] as $templateKey) { if (!isset($rawContribution[$templateKey])) { continue; } $templatePath = trim((string) $rawContribution[$templateKey]); if ($templatePath !== '' && !str_starts_with($templatePath, '/')) { $rawContribution[$templateKey] = rtrim($basePath, '/') . '/' . $templatePath; } } foreach (self::UI_SLOT_REQUIRED_KEYS[$slotName] ?? [] as $requiredKey) { $requiredValue = trim((string) ($rawContribution[$requiredKey] ?? '')); if ($requiredValue === '') { throw new RuntimeException( "UI slot contribution for slot '{$slotName}' in module '{$moduleId}' is missing required key '{$requiredKey}'." ); } } $this->validateUiSlotContributionByType($slotName, $rawContribution, $moduleId, $key); $normalized = $rawContribution; $normalized['key'] = $key; $normalized['order'] = (int) ($rawContribution['order'] ?? 100); $normalized['__module_id'] = $moduleId; $normalized['module_id'] = $moduleId; return $normalized; } /** * @param array $rawContribution */ private function validateUiSlotContributionByType( string $slotName, array $rawContribution, string $moduleId, string $slotKey ): void { if (in_array($slotName, ['aside.tab_panel', 'topbar.right_item', 'layout.body_end_template'], true)) { $templatePath = trim((string) ($rawContribution['panel_template'] ?? $rawContribution['template'] ?? '')); if ($templatePath === '' || !is_file($templatePath)) { throw new RuntimeException( "UI slot contribution '{$slotKey}' for slot '{$slotName}' in module '{$moduleId}' references missing template '{$templatePath}'." ); } } if ($slotName === 'layout.head_style') { $path = trim((string) ($rawContribution['path'] ?? '')); if ($path === '' || str_contains($path, '..')) { throw new RuntimeException( "UI slot contribution '{$slotKey}' for slot 'layout.head_style' in module '{$moduleId}' has invalid path '{$path}'." ); } } if ($slotName === 'user.edit.aside_action') { $type = strtolower(trim((string) ($rawContribution['type'] ?? 'link'))); if ($type !== 'link' && $type !== 'form') { throw new RuntimeException( "UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' has invalid type '{$type}'." ); } if ($type === 'form') { $action = trim((string) ($rawContribution['action'] ?? '')); if ($action === '') { throw new RuntimeException( "UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' requires non-empty 'action' for type=form." ); } } else { $href = trim((string) ($rawContribution['href'] ?? '')); if ($href === '') { throw new RuntimeException( "UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' requires non-empty 'href' for type=link." ); } } } if ($slotName === 'runtime.component') { $script = trim((string) ($rawContribution['script'] ?? '')); if ($script === '' || str_contains($script, '..')) { throw new RuntimeException( "UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid script '{$script}'." ); } $phase = strtolower(trim((string) ($rawContribution['phase'] ?? 'late'))); if (!in_array($phase, ['early', 'default', 'late'], true)) { throw new RuntimeException( "UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid phase '{$phase}'." ); } $configPath = trim((string) ($rawContribution['config_path'] ?? '')); if ($configPath !== '' && !preg_match('/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z0-9_]+)+$/', $configPath)) { throw new RuntimeException( "UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid config_path '{$configPath}'." ); } } } // ─── 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 getPermissionKeys(): array { return array_values(array_map( static fn (array $permission): string => (string) $permission['key'], $this->mergedPermissions )); } /** @return list */ public function getAuthorizationPolicies(): array { return $this->mergedAuthorizationPolicies; } /** * Collect unique ability strings from all module UI slot contributions. * * Used by UiAccessService to resolve module abilities into the layout auth map. * Each ability maps to itself (key = ability, value = ability). * * @return array */ public function getModuleUiAbilities(): array { $abilities = []; foreach ($this->mergedUiSlots as $items) { foreach ($items as $item) { $ability = trim((string) ($item['permission'] ?? '')); if ($ability !== '') { $abilities[$ability] = $ability; } } } return $abilities; } /** * @return list, * module_id: string * }> */ public function getSchedulerJobs(): array { return $this->mergedSchedulerJobs; } }