From 6706e66e044453855f241fc8643ed47217d51a9b Mon Sep 17 00:00:00 2001 From: rtcoder Date: Wed, 17 Jun 2026 18:02:31 +0200 Subject: [PATCH] Add module discovery cache --- .gitignore | 2 + README.md | 10 ++ REFACTORING.md | 2 +- docs/index.html | 6 +- src/Console/Commands/CacheClear.php | 33 ++++++ src/Console/Commands/CacheModules.php | 34 ++++++ src/Console/Commands/CacheStatus.php | 31 ++++++ src/Modules/ModuleLoader.php | 148 +++++++++++++++++++++++--- storage/cache/.gitkeep | 1 + tests/Feature/ModuleTest.php | 53 +++++++++ 10 files changed, 303 insertions(+), 17 deletions(-) create mode 100644 src/Console/Commands/CacheClear.php create mode 100644 src/Console/Commands/CacheModules.php create mode 100644 src/Console/Commands/CacheStatus.php create mode 100644 storage/cache/.gitkeep diff --git a/.gitignore b/.gitignore index 787214c..93a8507 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,7 @@ /.env Engine/storage/views/ +storage/cache/* +!storage/cache/.gitkeep \.idea/ diff --git a/README.md b/README.md index da97790..d090230 100644 --- a/README.md +++ b/README.md @@ -429,6 +429,16 @@ List discovered modules: ./shift module:list ``` +Cache discovered modules for production: + +```sh +./shift cache:modules +./shift cache:status +./shift cache:clear +``` + +The module cache is stored in `storage/cache/modules.php`. Without that file, ShiftPHP discovers modules from `application/modules` on each run. After changing module boundaries, module config, or module command mappings in production, rebuild the cache. + Run database migrations: ```sh diff --git a/REFACTORING.md b/REFACTORING.md index c97d8d9..3640504 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -34,6 +34,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled - [x] CLI help listing and command-specific usage. - [x] Centralized CLI command registry shared by the dispatcher and help command. - [x] Database migrations with create, migrate, status, and rollback commands. +- [x] Module discovery cache for production. - [x] Removal of view storage and example page assets from runtime. - [x] Removal of legacy `application/controllers` and `application/routes.php`. - [x] Domain-oriented framework namespaces: @@ -160,6 +161,5 @@ Internal errors return a generic `500` message unless `display_errors` is enable ## Next - [ ] Structured logging for exceptions. -- [ ] Module discovery cache for production. - [ ] CLI command aliases and richer command metadata. - [ ] Basic package-quality checks, for example static analysis and coding style. diff --git a/docs/index.html b/docs/index.html index 5fdb6e3..689131f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -167,6 +167,7 @@

Modules

}

Shift\Modules\ModuleLoader discovers modules by convention from application/modules/*/Module.php. Module config can be returned from getConfig() or from a module-level config.php file. Merged config is available through $modules->getConfig() and the container singleton modules.config.

+

For production, discovered module metadata can be cached in storage/cache/modules.php with ./shift cache:modules. Clear it with ./shift cache:clear after changing module boundaries or module config.

@@ -514,7 +515,10 @@

CLI

./shift about ./shift env:check ./shift db:check -./shift module:list +./shift module:list +./shift cache:modules +./shift cache:status +./shift cache:clear

Create commands scaffold modules and module-owned classes:

diff --git a/src/Console/Commands/CacheClear.php b/src/Console/Commands/CacheClear.php new file mode 100644 index 0000000..9e34597 --- /dev/null +++ b/src/Console/Commands/CacheClear.php @@ -0,0 +1,33 @@ +clearCache(); + + if ($cleared) { + $cli->success('Module cache cleared.'); + return; + } + + $cli->info('Module cache is already empty.'); + } + + public function getHelp(): string + { + return 'Usage: ./shift cache:clear'; + } + + public function getDescription(): string + { + return 'Clear framework cache files.'; + } +} diff --git a/src/Console/Commands/CacheModules.php b/src/Console/Commands/CacheModules.php new file mode 100644 index 0000000..964f72e --- /dev/null +++ b/src/Console/Commands/CacheModules.php @@ -0,0 +1,34 @@ +cache(); + $cacheFile = $loader->getCacheFile(); + + $cli->success('Cached modules: ' . $count); + + if ($cacheFile !== null) { + $cli->debug('Cache file: ' . $cacheFile); + } + } + + public function getHelp(): string + { + return 'Usage: ./shift cache:modules'; + } + + public function getDescription(): string + { + return 'Cache discovered modules for production.'; + } +} diff --git a/src/Console/Commands/CacheStatus.php b/src/Console/Commands/CacheStatus.php new file mode 100644 index 0000000..fc420ca --- /dev/null +++ b/src/Console/Commands/CacheStatus.php @@ -0,0 +1,31 @@ +getCacheFile(); + $exists = $loader->isCached(); + + (new Cli())->table(['Cache', 'Status', 'Path'], [ + ['modules', $exists ? 'cached' : 'empty', $cacheFile ?? 'disabled'], + ]); + } + + public function getHelp(): string + { + return 'Usage: ./shift cache:status'; + } + + public function getDescription(): string + { + return 'Show framework cache status.'; + } +} diff --git a/src/Modules/ModuleLoader.php b/src/Modules/ModuleLoader.php index ed8c25e..c1f8bd4 100644 --- a/src/Modules/ModuleLoader.php +++ b/src/Modules/ModuleLoader.php @@ -11,41 +11,159 @@ class ModuleLoader private array $modules = []; private array $config = []; - public function __construct(private readonly string $modulesPath = APP_PATH . '/modules') - { + public function __construct( + private readonly string $modulesPath = APP_PATH . '/modules', + private readonly ?string $cacheFile = APP_ROOT . '/storage/cache/modules.php' + ) { } public function load(): self { - if (!is_dir($this->modulesPath)) { - return $this; + $this->modules = []; + $this->config = []; + + if ($this->cacheFile !== null && is_file($this->cacheFile)) { + return $this->loadFromCache(); + } + + return $this->loadFromSnapshot($this->discover()); + } + + public function cache(): int + { + $snapshot = $this->discover(); + $this->writeCache($snapshot); + $this->modules = []; + $this->config = []; + $this->loadFromSnapshot($snapshot); + + return count($snapshot['modules']); + } + + public function clearCache(): bool + { + if ($this->cacheFile === null || !is_file($this->cacheFile)) { + return false; } - foreach (glob($this->modulesPath . '/*/Module.php') ?: [] as $moduleFile) { - require_once $moduleFile; + return unlink($this->cacheFile); + } + + public function isCached(): bool + { + return $this->cacheFile !== null && is_file($this->cacheFile); + } - $modulePath = dirname($moduleFile); - $moduleName = basename($modulePath); - $moduleClass = 'Modules\\' . $moduleName . '\\Module'; + public function getCacheFile(): ?string + { + return $this->cacheFile; + } - if (!class_exists($moduleClass)) { + /** + * @return array{generated_at: string, modules_path: string, modules: list} + */ + private function discover(): array + { + $modules = []; + + if (is_dir($this->modulesPath)) { + foreach (glob($this->modulesPath . '/*/Module.php') ?: [] as $moduleFile) { + require_once $moduleFile; + + $modulePath = dirname($moduleFile); + $moduleName = basename($modulePath); + $moduleClass = 'Modules\\' . $moduleName . '\\Module'; + + if (!class_exists($moduleClass)) { + continue; + } + + $module = new $moduleClass(); + + if ($module instanceof ModuleInterface) { + $modules[] = [ + 'file' => $moduleFile, + 'class' => $moduleClass, + 'name' => $module->getName(), + 'config' => array_replace_recursive( + $this->loadConfigFile($modulePath), + $module->getConfig() + ), + ]; + } + } + } + + return [ + 'generated_at' => date(DATE_ATOM), + 'modules_path' => $this->modulesPath, + 'modules' => $modules, + ]; + } + + private function loadFromCache(): self + { + $snapshot = require $this->cacheFile; + + if (!is_array($snapshot)) { + return $this->loadFromSnapshot($this->discover()); + } + + return $this->loadFromSnapshot($snapshot); + } + + /** + * @param array{modules?: list} $snapshot + */ + private function loadFromSnapshot(array $snapshot): self + { + foreach ($snapshot['modules'] ?? [] as $entry) { + $file = $entry['file'] ?? null; + $class = $entry['class'] ?? null; + + if (!is_string($file) || !is_string($class) || !is_file($file)) { continue; } - $module = new $moduleClass(); + require_once $file; + + if (!class_exists($class)) { + continue; + } + + $module = new $class(); if ($module instanceof ModuleInterface) { + $name = is_string($entry['name'] ?? null) ? $entry['name'] : $module->getName(); $this->modules[] = $module; - $this->config[$module->getName()] = array_replace_recursive( - $this->loadConfigFile($modulePath), - $module->getConfig() - ); + $this->config[$name] = is_array($entry['config'] ?? null) ? $entry['config'] : []; } } return $this; } + /** + * @param array{generated_at: string, modules_path: string, modules: list} $snapshot + */ + private function writeCache(array $snapshot): void + { + if ($this->cacheFile === null) { + return; + } + + $directory = dirname($this->cacheFile); + + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + file_put_contents( + $this->cacheFile, + "statusCode, 'Module route should emit successful status.'); assertSameValue('health', $payload['module'] ?? null, 'Module controller should resolve its service.'); }, + 'module loader can cache discovered module metadata' => function (): void { + $root = sys_get_temp_dir() . '/shift-module-cache-' . bin2hex(random_bytes(6)); + $modulesPath = $root . '/modules'; + $cacheFile = $root . '/cache/modules.php'; + $moduleName = 'Cached' . bin2hex(random_bytes(4)); + $moduleSlug = strtolower($moduleName); + + try { + writeCachedTestModule($modulesPath, $moduleName, $moduleSlug, true); + + $loader = new ModuleLoader($modulesPath, $cacheFile); + $cached = $loader->cache(); + + assertSameValue(1, $cached, 'Module cache should include discovered modules.'); + assertFileExists($cacheFile, 'Module cache file should be written.'); + + writeCachedTestModule($modulesPath, $moduleName, $moduleSlug, false); + + $cachedLoader = (new ModuleLoader($modulesPath, $cacheFile))->load(); + assertSameValue(true, $cachedLoader->isCached(), 'Loader should detect an existing module cache.'); + assertSameValue(true, $cachedLoader->getConfig($moduleSlug)['enabled'] ?? null, 'Cached config should be loaded from snapshot.'); + assertSameValue(true, $cachedLoader->clearCache(), 'Module cache should be removable.'); + assertSameValue(false, is_file($cacheFile), 'Cache file should be removed after clear.'); + } finally { + removeDirectory($root); + } + }, ]; + +function writeCachedTestModule(string $modulesPath, string $moduleName, string $moduleSlug, bool $enabled): void +{ + $modulePath = $modulesPath . '/' . $moduleName; + + if (!is_dir($modulePath)) { + mkdir($modulePath, 0775, true); + } + + file_put_contents($modulePath . '/config.php', " " . ($enabled ? 'true' : 'false') . "];\n"); + file_put_contents($modulePath . '/Module.php', <<