Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@
/.env

Engine/storage/views/
storage/cache/*
!storage/cache/.gitkeep

\.idea/
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion REFACTORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
6 changes: 5 additions & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ <h2>Modules</h2>
}</code></pre>

<p><code>Shift\Modules\ModuleLoader</code> discovers modules by convention from <code>application/modules/*/Module.php</code>. Module config can be returned from <code>getConfig()</code> or from a module-level <code>config.php</code> file. Merged config is available through <code>$modules-&gt;getConfig()</code> and the container singleton <code>modules.config</code>.</p>
<p>For production, discovered module metadata can be cached in <code>storage/cache/modules.php</code> with <code>./shift cache:modules</code>. Clear it with <code>./shift cache:clear</code> after changing module boundaries or module config.</p>
</section>

<section id="routing">
Expand Down Expand Up @@ -514,7 +515,10 @@ <h2>CLI</h2>
./shift about
./shift env:check
./shift db:check
./shift module:list</code></pre>
./shift module:list
./shift cache:modules
./shift cache:status
./shift cache:clear</code></pre>

<p>Create commands scaffold modules and module-owned classes:</p>

Expand Down
33 changes: 33 additions & 0 deletions src/Console/Commands/CacheClear.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Console\Commands;

use Shift\Console\Cli;
use Shift\Console\CommandInterface;
use Shift\Modules\ModuleLoader;

class CacheClear implements CommandInterface
{
public function execute(mixed ...$args): void
{
$cli = new Cli();
$cleared = (new ModuleLoader())->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.';
}
}
34 changes: 34 additions & 0 deletions src/Console/Commands/CacheModules.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace Console\Commands;

use Shift\Console\Cli;
use Shift\Console\CommandInterface;
use Shift\Modules\ModuleLoader;

class CacheModules implements CommandInterface
{
public function execute(mixed ...$args): void
{
$cli = new Cli();
$loader = new ModuleLoader();
$count = $loader->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.';
}
}
31 changes: 31 additions & 0 deletions src/Console/Commands/CacheStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace Console\Commands;

use Shift\Console\Cli;
use Shift\Console\CommandInterface;
use Shift\Modules\ModuleLoader;

class CacheStatus implements CommandInterface
{
public function execute(mixed ...$args): void
{
$loader = new ModuleLoader();
$cacheFile = $loader->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.';
}
}
148 changes: 133 additions & 15 deletions src/Modules/ModuleLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<array{file: string, class: string, name: string, config: array}>}
*/
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<array{file?: string, class?: string, name?: string, config?: array}>} $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<array{file: string, class: string, name: string, config: array}>} $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,
"<?php\n\nreturn " . var_export($snapshot, true) . ";\n"
);
}

private function loadConfigFile(string $modulePath): array
{
$configFile = $modulePath . '/config.php';
Expand Down
1 change: 1 addition & 0 deletions storage/cache/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

53 changes: 53 additions & 0 deletions tests/Feature/ModuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,57 @@
assertSameValue(200, $emitter->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', "<?php\n\nreturn ['enabled' => " . ($enabled ? 'true' : 'false') . "];\n");
file_put_contents($modulePath . '/Module.php', <<<PHP
<?php

namespace Modules\\{$moduleName};

use Shift\\Modules\\AbstractModule;

class Module extends AbstractModule
{
public function getName(): string
{
return '{$moduleSlug}';
}
}
PHP);
}
Loading