* Example: php bin/console make:module notifications * * Generates: * modules//module.php * modules//lib/Module//ContainerRegistrar.php * modules//lib/Module//AuthorizationPolicy.php * modules//pages/ * modules//templates/ * modules//web/css/ * modules//web/js/ * modules//tests/Module// */ final class ModuleCommand extends Command { public function name(): string { return 'make:module'; } public function description(): string { return 'Scaffold a new module with manifest and stub classes'; } public function usage(): string { return <<<'USAGE' Usage: php bin/console make:module Arguments: module-id Lowercase identifier (e.g. notifications, activity-log) Must match [a-z][a-z0-9-]* pattern. Example: php bin/console make:module notifications Generates the full module directory structure with: - module.php manifest (all fields, sensible defaults) - ContainerRegistrar stub - AuthorizationPolicy stub - Empty pages/, templates/, web/css/, web/js/, tests/ directories USAGE; } public function execute(array $args, array $options): int { $moduleId = trim($args[0] ?? ''); if ($moduleId === '') { fwrite(STDERR, $this->usage() . "\n"); return 1; } if (!preg_match('/^[a-z][a-z0-9-]*$/', $moduleId)) { fwrite(STDERR, "Error: module ID must match [a-z][a-z0-9-]* (got '{$moduleId}')\n"); return 1; } $modulesDir = $this->projectRoot() . '/modules'; $moduleDir = $modulesDir . '/' . $moduleId; if (is_dir($moduleDir)) { fwrite(STDERR, "Error: module directory already exists: modules/{$moduleId}/\n"); return 1; } $pascalName = self::toPascalCase($moduleId); $snakeName = str_replace('-', '_', $moduleId); // Create directories $dirs = [ "lib/Module/{$pascalName}", 'pages', 'templates', 'web/css', 'web/js', "tests/Module/{$pascalName}", ]; foreach ($dirs as $dir) { $path = $moduleDir . '/' . $dir; if (!mkdir($path, 0777, true) && !is_dir($path)) { fwrite(STDERR, "Error: failed to create directory: {$dir}\n"); return 1; } } // Generate files $files = [ 'module.php' => self::manifestTemplate($moduleId, $pascalName, $snakeName), "lib/Module/{$pascalName}/{$pascalName}ContainerRegistrar.php" => self::registrarTemplate($pascalName), "lib/Module/{$pascalName}/{$pascalName}AuthorizationPolicy.php" => self::policyTemplate($pascalName, $snakeName), ]; foreach ($files as $relativePath => $content) { $fullPath = $moduleDir . '/' . $relativePath; if (file_put_contents($fullPath, $content) === false) { fwrite(STDERR, "Error: failed to write file: {$relativePath}\n"); return 1; } } // Add .gitkeep to empty directories foreach (['pages', 'templates', 'web/css', 'web/js', "tests/Module/{$pascalName}"] as $dir) { touch($moduleDir . '/' . $dir . '/.gitkeep'); } fwrite(STDOUT, "Module '{$moduleId}' scaffolded at modules/{$moduleId}/\n\n"); fwrite(STDOUT, "Created:\n"); fwrite(STDOUT, " modules/{$moduleId}/module.php\n"); fwrite(STDOUT, " modules/{$moduleId}/lib/Module/{$pascalName}/{$pascalName}ContainerRegistrar.php\n"); fwrite(STDOUT, " modules/{$moduleId}/lib/Module/{$pascalName}/{$pascalName}AuthorizationPolicy.php\n"); fwrite(STDOUT, " modules/{$moduleId}/pages/\n"); fwrite(STDOUT, " modules/{$moduleId}/templates/\n"); fwrite(STDOUT, " modules/{$moduleId}/web/css/\n"); fwrite(STDOUT, " modules/{$moduleId}/web/js/\n"); fwrite(STDOUT, " modules/{$moduleId}/tests/Module/{$pascalName}/\n"); fwrite(STDOUT, "\nNext steps:\n"); fwrite(STDOUT, " 1. Add '{$moduleId}' to config/modules.php enabled_modules\n"); fwrite(STDOUT, " 2. Edit module.php to define routes, permissions, and UI slots\n"); fwrite(STDOUT, " 3. Run: php bin/console module:sync\n"); return 0; } private static function toPascalCase(string $id): string { return str_replace(' ', '', ucwords(str_replace('-', ' ', $id))); } private static function manifestTemplate(string $id, string $pascalName, string $snakeName): string { $registrarClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}ContainerRegistrar::class"; $policyClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}AuthorizationPolicy::class"; return << '{$id}', 'version' => '0.1.0', 'enabled_by_default' => false, 'load_order' => 100, 'requires' => [], 'routes' => [ // ['path' => '{$id}', 'target' => '{$id}'], ], 'public_paths' => [], 'container_registrars' => [ {$registrarClass}, ], 'ui_slots' => [], 'authorization_policies' => [ {$policyClass}, ], 'search_resources' => [], 'asset_groups' => [], 'scheduler_jobs' => [], 'layout_context_providers' => [], 'session_providers' => [], 'permissions' => [ // [ // 'key' => '{$snakeName}.view', // 'description' => 'Can view {$id}', // 'active' => true, // 'is_system' => true, // ], ], 'event_listeners' => [], 'deactivation_handler' => null, 'migrations_path' => null, ]; PHP; } private static function registrarTemplate(string $pascalName): string { return <<set(MyService::class, static fn (AppContainer \$c): MyService => new MyService( // \$c->get(SomeDependency::class) // )); } } PHP; } private static function policyTemplate(string $pascalName, string $snakeName): string { return <<permissionService->userHas(\$actorUserId, self::PERMISSION_KEY)) { return AuthorizationDecision::deny(403, 'forbidden'); } return AuthorizationDecision::allow(); } } PHP; } }