diff --git a/README.md b/README.md index 46c5633..e0e65a6 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,27 @@ Run the example module command: php shift.php health ``` +Generate module scaffolding: + +```sh +php shift.php create:module Billing +``` + +Generate module-owned classes: + +```sh +php shift.php create:controller --module=Billing InvoiceController +php shift.php create:controller Billing:InvoiceController +php shift.php create:model Billing:Invoice +php shift.php create:service Billing:Invoice +php shift.php create:command Billing:SyncInvoices +php shift.php create:middleware Billing:Audit +php shift.php create:dto Billing:CreateInvoice +``` + +Generator commands normalize module and class names to PHP class conventions. Missing suffixes are added for controllers, services, middleware, and DTOs. +Generator templates live in `src/Console/Generator/stubs`. + ## Modules ShiftPHP supports a modular monolith structure under `application/modules`. @@ -276,6 +297,8 @@ application/modules/Health/ └── Commands/ ``` +Generated models, middleware, and DTOs live under `Models/`, `Middleware/`, and `Dto/` inside the target module. + A module registers itself through `Module.php`: ```php diff --git a/REFACTORING.md b/REFACTORING.md index 9498e97..54ed3be 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -26,6 +26,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled - [x] Module configuration loading. - [x] Module lifecycle hooks, for example `boot()` after service registration. - [x] Framework source moved to `src/` for package split preparation. +- [x] CLI create generators for modules and module-owned classes with file-based stubs. - [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: diff --git a/composer.json b/composer.json index 3689d3f..3443953 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "autoload": { "psr-4": { "Shift\\": "src", + "Console\\Commands\\": "src/Console/Commands", "Modules\\": "application/modules" } } diff --git a/docs/index.html b/docs/index.html index 7b93dbb..9439dd4 100644 --- a/docs/index.html +++ b/docs/index.html @@ -111,6 +111,8 @@

Modules

|-- Services/ `-- Commands/ +

Generator commands can also create Models/, Middleware/, and Dto/ directories when those artifacts are added.

+

Every module boundary implements Shift\Modules\ModuleInterface. Most modules can extend Shift\Modules\AbstractModule and override only the methods they need.

namespace Modules\Health;
@@ -386,11 +388,25 @@ 

Service Container

CLI

-

The CLI entry point is shift.php. Built-in commands live under Shift\Console\Commands, and module commands are loaded from module command mappings.

+

The CLI entry point is shift.php. Built-in commands live under Console\Commands, and module commands are loaded from module command mappings.

php shift.php route:list
 php shift.php health
+

Create commands scaffold modules and module-owned classes:

+ +
php shift.php create:module Billing
+php shift.php create:controller --module=Billing InvoiceController
+php shift.php create:controller Billing:InvoiceController
+php shift.php create:model Billing:Invoice
+php shift.php create:service Billing:Invoice
+php shift.php create:command Billing:SyncInvoices
+php shift.php create:middleware Billing:Audit
+php shift.php create:dto Billing:CreateInvoice
+ +

Controller, service, middleware, and DTO generators add the expected class suffix when it is missing. Commands accept either --module=Billing Name or Billing:Name.

+

Generator templates live in src/Console/Generator/stubs.

+

Commands implement Shift\Console\CommandInterface.

diff --git a/src/Console/Commands/CreateCommand.php b/src/Console/Commands/CreateCommand.php new file mode 100644 index 0000000..68588aa --- /dev/null +++ b/src/Console/Commands/CreateCommand.php @@ -0,0 +1,42 @@ +moduleAndClassFromArgs($args); + } catch (\InvalidArgumentException) { + $this->cli->error($this->getHelp()); + return; + } + + $class = NameFormatter::className($rawName); + $path = $this->modulePath($module) . '/Commands/' . $class . '.php'; + + $this->writeAndReport($path, $this->renderStub('command', [ + 'module' => $module, + 'class' => $class, + 'command' => NameFormatter::commandName($class), + ])); + } + + public function getHelp(): string + { + return 'Usage: php shift.php create:command --module={name} {CommandName}'; + } + + public function getDescription(): string + { + return 'Create a module CLI command.'; + } + +} diff --git a/src/Console/Commands/CreateController.php b/src/Console/Commands/CreateController.php new file mode 100644 index 0000000..61bc81c --- /dev/null +++ b/src/Console/Commands/CreateController.php @@ -0,0 +1,41 @@ +moduleAndClassFromArgs($args); + } catch (\InvalidArgumentException) { + $this->cli->error($this->getHelp()); + return; + } + + $class = NameFormatter::className($rawName, 'Controller'); + $path = $this->modulePath($module) . '/Controllers/' . $class . '.php'; + + $this->writeAndReport($path, $this->renderStub('controller', [ + 'module' => $module, + 'class' => $class, + ])); + } + + public function getHelp(): string + { + return 'Usage: php shift.php create:controller --module={name} {ControllerName}'; + } + + public function getDescription(): string + { + return 'Create a module controller.'; + } + +} diff --git a/src/Console/Commands/CreateDto.php b/src/Console/Commands/CreateDto.php new file mode 100644 index 0000000..8c025b8 --- /dev/null +++ b/src/Console/Commands/CreateDto.php @@ -0,0 +1,41 @@ +moduleAndClassFromArgs($args); + } catch (\InvalidArgumentException) { + $this->cli->error($this->getHelp()); + return; + } + + $class = NameFormatter::className($rawName, 'Dto'); + $path = $this->modulePath($module) . '/Dto/' . $class . '.php'; + + $this->writeAndReport($path, $this->renderStub('dto', [ + 'module' => $module, + 'class' => $class, + ])); + } + + public function getHelp(): string + { + return 'Usage: php shift.php create:dto --module={name} {DtoName}'; + } + + public function getDescription(): string + { + return 'Create a module request DTO.'; + } + +} diff --git a/src/Console/Commands/CreateMiddleware.php b/src/Console/Commands/CreateMiddleware.php new file mode 100644 index 0000000..dfb819e --- /dev/null +++ b/src/Console/Commands/CreateMiddleware.php @@ -0,0 +1,41 @@ +moduleAndClassFromArgs($args); + } catch (\InvalidArgumentException) { + $this->cli->error($this->getHelp()); + return; + } + + $class = NameFormatter::className($rawName, 'Middleware'); + $path = $this->modulePath($module) . '/Middleware/' . $class . '.php'; + + $this->writeAndReport($path, $this->renderStub('middleware', [ + 'module' => $module, + 'class' => $class, + ])); + } + + public function getHelp(): string + { + return 'Usage: php shift.php create:middleware --module={name} {MiddlewareName}'; + } + + public function getDescription(): string + { + return 'Create a module middleware.'; + } + +} diff --git a/src/Console/Commands/CreateModel.php b/src/Console/Commands/CreateModel.php new file mode 100644 index 0000000..af07c53 --- /dev/null +++ b/src/Console/Commands/CreateModel.php @@ -0,0 +1,41 @@ +moduleAndClassFromArgs($args); + } catch (\InvalidArgumentException) { + $this->cli->error($this->getHelp()); + return; + } + + $class = NameFormatter::className($rawName); + $path = $this->modulePath($module) . '/Models/' . $class . '.php'; + + $this->writeAndReport($path, $this->renderStub('model', [ + 'module' => $module, + 'class' => $class, + ])); + } + + public function getHelp(): string + { + return 'Usage: php shift.php create:model --module={name} {ModelName}'; + } + + public function getDescription(): string + { + return 'Create a module model.'; + } + +} diff --git a/src/Console/Commands/CreateModule.php b/src/Console/Commands/CreateModule.php new file mode 100644 index 0000000..dab860e --- /dev/null +++ b/src/Console/Commands/CreateModule.php @@ -0,0 +1,49 @@ +cli->error($this->getHelp()); + return; + } + + $module = NameFormatter::moduleName($rawName); + $slug = NameFormatter::slug($rawName); + $path = $this->modulePath($module); + + foreach (['Commands', 'Controllers', 'Services'] as $directory) { + $this->files->ensureDirectory($path . '/' . $directory); + } + + $this->writeAndReport($path . '/Module.php', $this->renderStub('module', [ + 'module' => $module, + 'slug' => $slug, + ])); + $this->writeAndReport($path . '/config.php', $this->renderStub('config', [ + 'slug' => $slug, + ])); + } + + public function getHelp(): string + { + return 'Usage: php shift.php create:module {name}'; + } + + public function getDescription(): string + { + return 'Create a new Shift module.'; + } + +} diff --git a/src/Console/Commands/CreateService.php b/src/Console/Commands/CreateService.php new file mode 100644 index 0000000..30bb5fb --- /dev/null +++ b/src/Console/Commands/CreateService.php @@ -0,0 +1,41 @@ +moduleAndClassFromArgs($args); + } catch (\InvalidArgumentException) { + $this->cli->error($this->getHelp()); + return; + } + + $class = NameFormatter::className($rawName, 'Service'); + $path = $this->modulePath($module) . '/Services/' . $class . '.php'; + + $this->writeAndReport($path, $this->renderStub('service', [ + 'module' => $module, + 'class' => $class, + ])); + } + + public function getHelp(): string + { + return 'Usage: php shift.php create:service --module={name} {ServiceName}'; + } + + public function getDescription(): string + { + return 'Create a module service.'; + } + +} diff --git a/src/Console/Generator/FileGenerator.php b/src/Console/Generator/FileGenerator.php new file mode 100644 index 0000000..3cffac6 --- /dev/null +++ b/src/Console/Generator/FileGenerator.php @@ -0,0 +1,52 @@ + */ + private array $created = []; + + /** @var list */ + private array $skipped = []; + + public function ensureDirectory(string $path): void + { + if (is_dir($path)) { + return; + } + + mkdir($path, 0775, true); + $this->created[] = $path; + } + + public function writeFile(string $path, string $content): void + { + $directory = dirname($path); + $this->ensureDirectory($directory); + + if (file_exists($path)) { + $this->skipped[] = $path; + return; + } + + file_put_contents($path, $content); + $this->created[] = $path; + } + + /** + * @return list + */ + public function created(): array + { + return $this->created; + } + + /** + * @return list + */ + public function skipped(): array + { + return $this->skipped; + } +} diff --git a/src/Console/Generator/GeneratesFiles.php b/src/Console/Generator/GeneratesFiles.php new file mode 100644 index 0000000..cc70c0e --- /dev/null +++ b/src/Console/Generator/GeneratesFiles.php @@ -0,0 +1,85 @@ +files = new FileGenerator(); + $this->stubs = new StubRenderer(); + $this->cli = new Cli(); + } + + /** + * @param list $args + * @return array{0: string, 1: string} + */ + protected function moduleAndClassFromArgs(array $args): array + { + $module = null; + $name = null; + + foreach ($args as $arg) { + if (!is_string($arg) || $arg === '') { + continue; + } + + if (str_starts_with($arg, '--module=')) { + $module = substr($arg, 9); + continue; + } + + if ($module === null && str_contains($arg, ':')) { + [$module, $name] = explode(':', $arg, 2); + continue; + } + + $name ??= $arg; + } + + if ($module === null || $module === '' || $name === null || $name === '') { + throw new \InvalidArgumentException('Module and class name are required.'); + } + + return [ + NameFormatter::moduleName($module), + $name, + ]; + } + + protected function modulePath(string $module): string + { + return rtrim($this->modulesPath, '/') . '/' . $module; + } + + protected function writeAndReport(string $path, string $content): void + { + $beforeSkipped = count($this->files->skipped()); + $this->files->writeFile($path, $content); + + if (count($this->files->skipped()) > $beforeSkipped) { + $this->cli->warning('Skipped existing file: ' . $path); + return; + } + + $this->cli->success('Created: ' . $path); + } + + /** + * @param array $variables + */ + protected function renderStub(string $stub, array $variables): string + { + return $this->stubs->render($stub, $variables); + } +} diff --git a/src/Console/Generator/NameFormatter.php b/src/Console/Generator/NameFormatter.php new file mode 100644 index 0000000..aa3f33e --- /dev/null +++ b/src/Console/Generator/NameFormatter.php @@ -0,0 +1,48 @@ + strtolower($part), $parts); + + return implode(':', $parts); + } + + public static function slug(string $name): string + { + $parts = preg_split('/[^a-zA-Z0-9]+/', $name, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $parts = array_map(static fn (string $part): string => strtolower($part), $parts); + + return implode('-', $parts); + } + + private static function studly(string $name): string + { + $parts = preg_split('/[^a-zA-Z0-9]+/', $name, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $parts = array_map(static fn (string $part): string => ucfirst($part), $parts); + + return implode('', $parts); + } +} diff --git a/src/Console/Generator/StubRenderer.php b/src/Console/Generator/StubRenderer.php new file mode 100644 index 0000000..ee28133 --- /dev/null +++ b/src/Console/Generator/StubRenderer.php @@ -0,0 +1,35 @@ + $variables + */ + public function render(string $stub, array $variables): string + { + $path = rtrim($this->stubPath, '/') . '/' . $stub . '.stub'; + + if (!is_file($path)) { + throw new \RuntimeException("Stub {$stub} not found."); + } + + $content = file_get_contents($path); + + if (!is_string($content)) { + throw new \RuntimeException("Stub {$stub} cannot be read."); + } + + foreach ($variables as $name => $value) { + $content = str_replace('{{ ' . $name . ' }}', $value, $content); + } + + return $content; + } +} diff --git a/src/Console/Generator/stubs/command.stub b/src/Console/Generator/stubs/command.stub new file mode 100644 index 0000000..5e1bf27 --- /dev/null +++ b/src/Console/Generator/stubs/command.stub @@ -0,0 +1,24 @@ +success('Command executed.'); + } + + public function getHelp(): string + { + return 'Usage: php shift.php {{ command }}'; + } + + public function getDescription(): string + { + return 'Module command.'; + } +} diff --git a/src/Console/Generator/stubs/config.stub b/src/Console/Generator/stubs/config.stub new file mode 100644 index 0000000..4e0706a --- /dev/null +++ b/src/Console/Generator/stubs/config.stub @@ -0,0 +1,6 @@ + true, + 'module' => '{{ slug }}', +]; diff --git a/src/Console/Generator/stubs/controller.stub b/src/Console/Generator/stubs/controller.stub new file mode 100644 index 0000000..1b29870 --- /dev/null +++ b/src/Console/Generator/stubs/controller.stub @@ -0,0 +1,16 @@ +json([ + 'status' => 'ok', + ]); + } +} diff --git a/src/Console/Generator/stubs/dto.stub b/src/Console/Generator/stubs/dto.stub new file mode 100644 index 0000000..6507109 --- /dev/null +++ b/src/Console/Generator/stubs/dto.stub @@ -0,0 +1,18 @@ +load($router, [ + ]); + } + + public function getCommandMappings(): array + { + return [ + [ + 'dir' => __DIR__ . '/Commands/', + 'namespace' => 'Modules\\{{ module }}\\Commands\\', + ], + ]; + } +} diff --git a/src/Console/Generator/stubs/service.stub b/src/Console/Generator/stubs/service.stub new file mode 100644 index 0000000..b60afb8 --- /dev/null +++ b/src/Console/Generator/stubs/service.stub @@ -0,0 +1,7 @@ + function (): void { + $modulesPath = makeTempModulesPath(); + + try { + (new CreateModule($modulesPath))->execute('billing'); + + assertFileExists($modulesPath . '/Billing/Module.php', 'Module.php should be created.'); + assertFileExists($modulesPath . '/Billing/config.php', 'config.php should be created.'); + assertDirectoryExists($modulesPath . '/Billing/Commands', 'Commands directory should be created.'); + assertDirectoryExists($modulesPath . '/Billing/Controllers', 'Controllers directory should be created.'); + assertDirectoryExists($modulesPath . '/Billing/Services', 'Services directory should be created.'); + + $module = file_get_contents($modulesPath . '/Billing/Module.php'); + $config = file_get_contents($modulesPath . '/Billing/config.php'); + assertStringContains('namespace Modules\\Billing;', $module, 'Module namespace should match module name.'); + assertStringContains("'namespace' => 'Modules\\\\Billing\\\\Commands\\\\'", $module, 'Module should expose command mappings.'); + assertStringContains("'module' => 'billing'", $config, 'Module config should use the module slug.'); + } finally { + removeDirectory(dirname($modulesPath)); + } + }, + 'create:controller supports module option and inline module syntax' => function (): void { + $modulesPath = makeTempModulesPath(); + + try { + (new CreateController($modulesPath))->execute('--module=billing', 'invoice'); + (new CreateController($modulesPath))->execute('billing:PaymentController'); + + assertFileExists($modulesPath . '/Billing/Controllers/InvoiceController.php', 'Controller suffix should be added.'); + assertFileExists($modulesPath . '/Billing/Controllers/PaymentController.php', 'Inline module syntax should be supported.'); + + $controller = file_get_contents($modulesPath . '/Billing/Controllers/InvoiceController.php'); + assertStringContains('namespace Modules\\Billing\\Controllers;', $controller, 'Controller namespace should match module.'); + assertStringContains('class InvoiceController extends Controller', $controller, 'Controller class should extend base controller.'); + } finally { + removeDirectory(dirname($modulesPath)); + } + }, + 'create artifact commands write module-owned classes' => function (): void { + $modulesPath = makeTempModulesPath(); + + try { + (new CreateModel($modulesPath))->execute('billing:Invoice'); + (new CreateService($modulesPath))->execute('billing:Invoice'); + (new CreateCommand($modulesPath))->execute('billing:SyncInvoices'); + (new CreateMiddleware($modulesPath))->execute('billing:Audit'); + (new CreateDto($modulesPath))->execute('billing:CreateInvoice'); + + assertFileExists($modulesPath . '/Billing/Models/Invoice.php', 'Model should be created.'); + assertFileExists($modulesPath . '/Billing/Services/InvoiceService.php', 'Service suffix should be added.'); + assertFileExists($modulesPath . '/Billing/Commands/SyncInvoices.php', 'Command should be created.'); + assertFileExists($modulesPath . '/Billing/Middleware/AuditMiddleware.php', 'Middleware suffix should be added.'); + assertFileExists($modulesPath . '/Billing/Dto/CreateInvoiceDto.php', 'DTO suffix should be added.'); + + $command = file_get_contents($modulesPath . '/Billing/Commands/SyncInvoices.php'); + assertStringContains('return \'Usage: php shift.php sync:invoices\';', $command, 'Generated command help should use CLI command syntax.'); + } finally { + removeDirectory(dirname($modulesPath)); + } + }, +]; diff --git a/tests/Support/TestSupport.php b/tests/Support/TestSupport.php index 063b195..31c78ae 100644 --- a/tests/Support/TestSupport.php +++ b/tests/Support/TestSupport.php @@ -45,3 +45,57 @@ function makeRequest(string $method, string $uri, string $body = '', array $quer $body ); } + +function assertFileExists(string $path, string $message): void +{ + if (!is_file($path)) { + throw new RuntimeException($message . "\nMissing file: {$path}"); + } +} + +function assertDirectoryExists(string $path, string $message): void +{ + if (!is_dir($path)) { + throw new RuntimeException($message . "\nMissing directory: {$path}"); + } +} + +function assertStringContains(string $needle, string|false $haystack, string $message): void +{ + if (!is_string($haystack) || !str_contains($haystack, $needle)) { + throw new RuntimeException($message . "\nNeedle: {$needle}\nHaystack: " . var_export($haystack, true)); + } +} + +function makeTempModulesPath(): string +{ + $root = sys_get_temp_dir() . '/shift-create-' . bin2hex(random_bytes(6)); + $modules = $root . '/modules'; + + mkdir($modules, 0775, true); + + return $modules; +} + +function removeDirectory(string $path): void +{ + if (!is_dir($path)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + continue; + } + + unlink($file->getPathname()); + } + + rmdir($path); +}