From 540b1de0c00f03b9fca456230bc75953c8a12e22 Mon Sep 17 00:00:00 2001 From: rtcoder Date: Mon, 15 Jun 2026 22:00:17 +0200 Subject: [PATCH] Add modular monolith module support --- Engine/Console/Commands/RouteList.php | 2 + Engine/Console/Shift.php | 6 +- Engine/Modules/AbstractModule.php | 22 ++++++ Engine/Modules/ModuleInterface.php | 17 +++++ Engine/Modules/ModuleLoader.php | 74 +++++++++++++++++++ README.md | 65 ++++++++++++++++ REFACTORING.md | 22 ++++++ .../modules/Health/Commands/Health.php | 30 ++++++++ .../Health/Controllers/HealthController.php | 22 ++++++ application/modules/Health/Module.php | 40 ++++++++++ .../modules/Health/Services/HealthService.php | 14 ++++ composer.json | 3 +- index.php | 4 + tests/ApiCoreTest.php | 37 ++++++++++ 14 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 Engine/Modules/AbstractModule.php create mode 100644 Engine/Modules/ModuleInterface.php create mode 100644 Engine/Modules/ModuleLoader.php create mode 100644 application/modules/Health/Commands/Health.php create mode 100644 application/modules/Health/Controllers/HealthController.php create mode 100644 application/modules/Health/Module.php create mode 100644 application/modules/Health/Services/HealthService.php diff --git a/Engine/Console/Commands/RouteList.php b/Engine/Console/Commands/RouteList.php index 1ec8489..12c6481 100644 --- a/Engine/Console/Commands/RouteList.php +++ b/Engine/Console/Commands/RouteList.php @@ -4,6 +4,7 @@ use Engine\Console\Cli; use Engine\Console\CommandInterface; +use Engine\Modules\ModuleLoader; use Engine\Router; class RouteList implements CommandInterface @@ -12,6 +13,7 @@ public function execute(mixed ...$args): void { $cli = new Cli(); $router = new Router(); + (new ModuleLoader())->load()->registerRoutes($router); $routesFile = APP_PATH . '/routes.php'; if (!file_exists($routesFile)) { diff --git a/Engine/Console/Shift.php b/Engine/Console/Shift.php index 74a9476..9a6f862 100644 --- a/Engine/Console/Shift.php +++ b/Engine/Console/Shift.php @@ -8,7 +8,7 @@ namespace Engine\Console; - +use Engine\Modules\ModuleLoader; use ReflectionClass; use ReflectionException; @@ -70,6 +70,10 @@ public function run(): void 'namespace' => 'Console\\Commands\\' ], ]; + $mappings = array_merge( + $mappings, + (new ModuleLoader())->load()->getCommandMappings() + ); $found = false; foreach ($mappings as $mapping) { if (!$found && file_exists($mapping['dir'] . $commandName . '.php')) { diff --git a/Engine/Modules/AbstractModule.php b/Engine/Modules/AbstractModule.php new file mode 100644 index 0000000..37d530f --- /dev/null +++ b/Engine/Modules/AbstractModule.php @@ -0,0 +1,22 @@ +modulesPath)) { + return $this; + } + + foreach (glob($this->modulesPath . '/*/Module.php') ?: [] as $moduleFile) { + require_once $moduleFile; + + $moduleName = basename(dirname($moduleFile)); + $moduleClass = 'Modules\\' . $moduleName . '\\Module'; + + if (!class_exists($moduleClass)) { + continue; + } + + $module = new $moduleClass(); + + if ($module instanceof ModuleInterface) { + $this->modules[] = $module; + } + } + + return $this; + } + + public function registerServices(ServiceContainer $container): void + { + foreach ($this->modules as $module) { + $module->registerServices($container); + } + } + + public function registerRoutes(Router $router): void + { + foreach ($this->modules as $module) { + $module->registerRoutes($router); + } + } + + public function getCommandMappings(): array + { + $mappings = []; + + foreach ($this->modules as $module) { + foreach ($module->getCommandMappings() as $mapping) { + $mappings[] = $mapping; + } + } + + return $mappings; + } + + public function getModules(): array + { + return $this->modules; + } +} diff --git a/README.md b/README.md index e0e764c..8cdfb73 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,71 @@ List registered API routes: php shift.php route:list ``` +Run the example module command: + +```sh +php shift.php health +``` + +## Modules + +ShiftPHP supports a modular monolith structure under `application/modules`. + +Each module can own its controllers, routes, services, and CLI commands: + +```text +application/modules/Health/ +├── Module.php +├── Controllers/ +├── Services/ +└── Commands/ +``` + +A module registers itself through `Module.php`: + +```php +namespace Modules\Health; + +use Engine\Modules\AbstractModule; +use Engine\Router; +use Engine\Routing\AttributeRouteLoader; +use Engine\ServiceContainer; +use Modules\Health\Controllers\HealthController; +use Modules\Health\Services\HealthService; + +class Module extends AbstractModule +{ + public function getName(): string + { + return 'health'; + } + + public function registerServices(ServiceContainer $container): void + { + $container->singleton(HealthService::class, HealthService::class); + } + + public function registerRoutes(Router $router): void + { + (new AttributeRouteLoader())->load($router, [ + HealthController::class, + ]); + } + + public function getCommandMappings(): array + { + return [ + [ + 'dir' => __DIR__ . '/Commands/', + 'namespace' => 'Modules\\Health\\Commands\\', + ], + ]; + } +} +``` + +Modules are loaded automatically by convention from `application/modules/*/Module.php`. + ## Tests Run the lightweight API core test suite: diff --git a/REFACTORING.md b/REFACTORING.md index df6a88e..3d2cfde 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -18,8 +18,30 @@ Implemented in this branch: - `route:list` CLI command, - PHP 8 attributes for controller routing, - PHP 8 attributes for response metadata and parameter binding, +- modular monolith support through `application/modules/*/Module.php`, - removal of view storage and example page assets from runtime. +## Modular Monolith Direction + +Each module can own: + +- controllers, +- routes, +- services, +- commands. + +Module layout: + +```text +application/modules/{ModuleName}/ +├── Module.php +├── Controllers/ +├── Services/ +└── Commands/ +``` + +`Module.php` is the module boundary. It registers services into the container, routes into the router, and command mappings into the CLI. + ## Runtime Flow ```text diff --git a/application/modules/Health/Commands/Health.php b/application/modules/Health/Commands/Health.php new file mode 100644 index 0000000..4047cb5 --- /dev/null +++ b/application/modules/Health/Commands/Health.php @@ -0,0 +1,30 @@ +status() as $key => $value) { + $cli->success($key . ': ' . $value); + } + } + + public function getHelp(): string + { + return 'Usage: php shift.php health'; + } + + public function getDescription(): string + { + return 'Show health module status.'; + } +} diff --git a/application/modules/Health/Controllers/HealthController.php b/application/modules/Health/Controllers/HealthController.php new file mode 100644 index 0000000..edb7514 --- /dev/null +++ b/application/modules/Health/Controllers/HealthController.php @@ -0,0 +1,22 @@ +getContainer()->resolve(HealthService::class); + + return $this->json($health->status()); + } +} diff --git a/application/modules/Health/Module.php b/application/modules/Health/Module.php new file mode 100644 index 0000000..138a22f --- /dev/null +++ b/application/modules/Health/Module.php @@ -0,0 +1,40 @@ +singleton(HealthService::class, HealthService::class); + } + + public function registerRoutes(Router $router): void + { + (new AttributeRouteLoader())->load($router, [ + HealthController::class, + ]); + } + + public function getCommandMappings(): array + { + return [ + [ + 'dir' => __DIR__ . '/Commands/', + 'namespace' => 'Modules\\Health\\Commands\\', + ], + ]; + } +} diff --git a/application/modules/Health/Services/HealthService.php b/application/modules/Health/Services/HealthService.php new file mode 100644 index 0000000..e32f424 --- /dev/null +++ b/application/modules/Health/Services/HealthService.php @@ -0,0 +1,14 @@ + 'ok', + 'module' => 'health', + ]; + } +} diff --git a/composer.json b/composer.json index df1b8c0..adb04c2 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "Console\\": "Engine/Console", "AppConsole\\": "application/console", "Engine\\": "Engine", - "Controllers\\": "application/controller" + "Controllers\\": "application/controller", + "Modules\\": "application/modules" } } } diff --git a/index.php b/index.php index eb24efe..f0ac462 100644 --- a/index.php +++ b/index.php @@ -1,6 +1,7 @@ load(); +$modules->registerServices($app->getContainer()); +$modules->registerRoutes($app->getRouter()); $routes = APP_PATH . '/routes.php'; if (file_exists($routes)) { diff --git a/tests/ApiCoreTest.php b/tests/ApiCoreTest.php index 9a02963..f39e100 100644 --- a/tests/ApiCoreTest.php +++ b/tests/ApiCoreTest.php @@ -7,7 +7,10 @@ use Engine\Request; use Engine\ResponseEmitter; use Engine\Router; +use Engine\ServiceContainer; use Engine\Routing\AttributeRouteLoader; +use Engine\Modules\ModuleLoader; +use Modules\Health\Services\HealthService; require_once __DIR__ . '/../bootstrap.php'; @@ -174,6 +177,40 @@ function makeRequest(string $method, string $uri, string $body = '', array $quer assertSameValue('Shift', $payload['name'] ?? null, 'Body attribute should bind JSON body key.'); }; +$tests['module loader registers services and routes'] = function (): void { + $loader = (new ModuleLoader())->load(); + $router = new Router(); + $container = new ServiceContainer(); + + $loader->registerServices($container); + $loader->registerRoutes($router); + + assertSameValue(true, $container->has(HealthService::class), 'Health module service should be registered.'); + + $routes = array_map( + static fn (\Engine\Route $route): string => $route->getMethod() . ' ' . $route->getPath(), + $router->getRoutes() + ); + + assertSameValue(['GET /health'], $routes, 'Health module route should be registered.'); +}; + +$tests['app dispatches module controller with service'] = function (): void { + $loader = (new ModuleLoader())->load(); + $router = new Router(); + $emitter = new CapturingEmitter(); + $app = new App(makeRequest('GET', '/health'), $router, $emitter); + + $loader->registerServices($app->getContainer()); + $loader->registerRoutes($router); + $app->start(); + + $payload = json_decode($emitter->content, true); + + assertSameValue(200, $emitter->statusCode, 'Module route should emit successful status.'); + assertSameValue('health', $payload['module'] ?? null, 'Module controller should resolve its service.'); +}; + $failed = 0; foreach ($tests as $name => $test) {