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 @@
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);
+}