Add module generator UI and discovery service documentation

This commit is contained in:
2026-03-15 08:59:53 +02:00
parent dff2cd752c
commit a6b1cfe498
8 changed files with 1482 additions and 1 deletions

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Filament\Pages;
use App\Services\ModuleDiscoveryService;
use App\Services\ModuleGeneratorService;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Str;
class ModuleGenerator extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationIcon = 'heroicon-o-cube';
protected static ?string $navigationLabel = 'Module Generator';
protected static ?string $navigationGroup = 'Development';
protected static ?int $navigationSort = 100;
protected static string $view = 'filament.pages.module-generator';
public ?array $data = [];
public ?array $result = null;
public array $modules = [];
public array $summary = [];
public ?string $expandedModule = null;
public static function canAccess(): bool
{
// Only available in local environment
return app()->environment('local');
}
public static function shouldRegisterNavigation(): bool
{
return app()->environment('local');
}
public function mount(): void
{
$this->form->fill([
'create_git_branch' => true,
'include_api' => false,
]);
$this->loadModules();
}
public function loadModules(): void
{
$discovery = new ModuleDiscoveryService();
$this->modules = $discovery->discoverModules();
$this->summary = $discovery->getModuleSummary();
}
public function toggleModule(string $moduleName): void
{
$this->expandedModule = $this->expandedModule === $moduleName ? null : $moduleName;
}
public function form(Form $form): Form
{
return $form
->schema([
Section::make('Module Details')
->description('Create a new module skeleton with all the necessary folders and files.')
->schema([
TextInput::make('name')
->label('Module Name')
->placeholder('e.g., Accounting, Inventory, HumanResources')
->helperText('Use PascalCase. This will create app/Modules/YourModuleName/')
->required()
->maxLength(50)
->rules(['regex:/^[A-Z][a-zA-Z0-9]*$/'])
->validationMessages([
'regex' => 'Module name must be PascalCase (start with uppercase, letters and numbers only).',
])
->live(onBlur: true)
->afterStateUpdated(fn ($state, callable $set) =>
$set('name', Str::studly($state))
),
Textarea::make('description')
->label('Description')
->placeholder('Brief description of what this module does...')
->rows(2)
->maxLength(255),
]),
Section::make('Options')
->schema([
Toggle::make('create_git_branch')
->label('Create Git Branch')
->helperText('Automatically create and checkout a new branch: module/{module-name}')
->default(true),
Toggle::make('include_api')
->label('Include API Routes')
->helperText('Add Routes/api.php with Sanctum authentication')
->default(false),
])
->columns(2),
Section::make('What Will Be Created')
->schema([
\Filament\Forms\Components\Placeholder::make('structure')
->label('')
->content(fn () => new \Illuminate\Support\HtmlString('
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
<div class="font-bold mb-2">app/Modules/{ModuleName}/</div>
<div class="ml-4">
├── Config/{module_name}.php<br>
├── Database/Migrations/<br>
├── Database/Seeders/<br>
├── Filament/Resources/<br>
├── Http/Controllers/{ModuleName}Controller.php<br>
├── Http/Middleware/<br>
├── Http/Requests/<br>
├── Models/<br>
├── Policies/<br>
├── Services/<br>
├── Routes/web.php<br>
├── Resources/views/index.blade.php<br>
├── Permissions.php<br>
├── {ModuleName}ServiceProvider.php<br>
└── README.md
</div>
</div>
')),
])
->collapsible()
->collapsed(),
])
->statePath('data');
}
public function generate(): void
{
$data = $this->form->getState();
$service = new ModuleGeneratorService();
$this->result = $service->generate($data['name'], [
'description' => $data['description'] ?? '',
'create_git_branch' => $data['create_git_branch'] ?? true,
'include_api' => $data['include_api'] ?? false,
]);
if ($this->result['success']) {
Notification::make()
->title('Module Created Successfully!')
->body("Module {$this->result['module_name']} has been created.")
->success()
->persistent()
->send();
// Reset form for next module
$this->form->fill([
'name' => '',
'description' => '',
'create_git_branch' => true,
'include_api' => false,
]);
// Reload modules list
$this->loadModules();
} else {
Notification::make()
->title('Module Creation Failed')
->body($this->result['message'])
->danger()
->persistent()
->send();
}
}
public function clearResult(): void
{
$this->result = null;
}
}

View File

@@ -0,0 +1,248 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
class ModuleDiscoveryService
{
public function discoverModules(): array
{
$modulesPath = app_path('Modules');
if (!File::exists($modulesPath)) {
return [];
}
$modules = [];
$directories = File::directories($modulesPath);
foreach ($directories as $directory) {
$moduleName = basename($directory);
// Skip non-module directories (like README files)
if (!File::exists("{$directory}/{$moduleName}ServiceProvider.php")) {
continue;
}
$modules[] = $this->getModuleInfo($moduleName, $directory);
}
return $modules;
}
protected function getModuleInfo(string $name, string $path): array
{
$kebabName = Str::kebab($name);
$snakeName = Str::snake($name);
return [
'name' => $name,
'slug' => $kebabName,
'path' => $path,
'config' => $this->getConfig($path, $snakeName),
'models' => $this->getModels($path),
'views' => $this->getViews($path),
'routes' => $this->getRoutes($path, $kebabName),
'migrations' => $this->getMigrations($path),
'filament_resources' => $this->getFilamentResources($path),
'permissions' => $this->getPermissions($path),
'has_api' => File::exists("{$path}/Routes/api.php"),
];
}
protected function getConfig(string $path, string $snakeName): array
{
$configPath = "{$path}/Config/{$snakeName}.php";
if (File::exists($configPath)) {
return require $configPath;
}
return [];
}
protected function getModels(string $path): array
{
$modelsPath = "{$path}/Models";
if (!File::exists($modelsPath)) {
return [];
}
$models = [];
$files = File::files($modelsPath);
foreach ($files as $file) {
if ($file->getExtension() === 'php' && $file->getFilename() !== '.gitkeep') {
$modelName = $file->getFilenameWithoutExtension();
$models[] = [
'name' => $modelName,
'file' => $file->getFilename(),
'path' => $file->getPathname(),
];
}
}
return $models;
}
protected function getViews(string $path): array
{
$viewsPath = "{$path}/Resources/views";
if (!File::exists($viewsPath)) {
return [];
}
return $this->scanViewsRecursive($viewsPath, '');
}
protected function scanViewsRecursive(string $basePath, string $prefix): array
{
$views = [];
$items = File::files($basePath);
foreach ($items as $file) {
if ($file->getExtension() === 'php' && Str::endsWith($file->getFilename(), '.blade.php')) {
$viewName = Str::replaceLast('.blade.php', '', $file->getFilename());
$fullName = $prefix ? "{$prefix}.{$viewName}" : $viewName;
$views[] = [
'name' => $fullName,
'file' => $file->getFilename(),
'path' => $file->getPathname(),
];
}
}
// Scan subdirectories
$directories = File::directories($basePath);
foreach ($directories as $dir) {
$dirName = basename($dir);
$subPrefix = $prefix ? "{$prefix}.{$dirName}" : $dirName;
$views = array_merge($views, $this->scanViewsRecursive($dir, $subPrefix));
}
return $views;
}
protected function getRoutes(string $path, string $kebabName): array
{
$routes = [];
// Get web routes
$webRoutesPath = "{$path}/Routes/web.php";
if (File::exists($webRoutesPath)) {
$routes['web'] = $this->parseRouteFile($webRoutesPath, $kebabName);
}
// Get API routes
$apiRoutesPath = "{$path}/Routes/api.php";
if (File::exists($apiRoutesPath)) {
$routes['api'] = $this->parseRouteFile($apiRoutesPath, "api/{$kebabName}");
}
return $routes;
}
protected function parseRouteFile(string $path, string $prefix): array
{
$content = File::get($path);
$routes = [];
// Parse Route::get, Route::post, etc.
preg_match_all(
"/Route::(get|post|put|patch|delete|any)\s*\(\s*['\"]([^'\"]*)['\"].*?->name\s*\(\s*['\"]([^'\"]*)['\"]|Route::(get|post|put|patch|delete|any)\s*\(\s*['\"]([^'\"]*)['\"].*?\[.*?::class,\s*['\"](\w+)['\"]\]/",
$content,
$matches,
PREG_SET_ORDER
);
foreach ($matches as $match) {
$method = strtoupper($match[1] ?: $match[4]);
$uri = $match[2] ?: $match[5];
$fullUri = $uri === '/' ? "/{$prefix}" : "/{$prefix}{$uri}";
$routes[] = [
'method' => $method,
'uri' => $fullUri,
'name' => $match[3] ?? null,
];
}
return $routes;
}
protected function getMigrations(string $path): array
{
$migrationsPath = "{$path}/Database/Migrations";
if (!File::exists($migrationsPath)) {
return [];
}
$migrations = [];
$files = File::files($migrationsPath);
foreach ($files as $file) {
if ($file->getExtension() === 'php') {
$migrations[] = [
'name' => $file->getFilename(),
'path' => $file->getPathname(),
];
}
}
return $migrations;
}
protected function getFilamentResources(string $path): array
{
$resourcesPath = "{$path}/Filament/Resources";
if (!File::exists($resourcesPath)) {
return [];
}
$resources = [];
$files = File::files($resourcesPath);
foreach ($files as $file) {
if ($file->getExtension() === 'php' && Str::endsWith($file->getFilename(), 'Resource.php')) {
$resourceName = Str::replaceLast('Resource.php', '', $file->getFilename());
$resources[] = [
'name' => $resourceName,
'file' => $file->getFilename(),
'path' => $file->getPathname(),
];
}
}
return $resources;
}
protected function getPermissions(string $path): array
{
$permissionsPath = "{$path}/Permissions.php";
if (File::exists($permissionsPath)) {
return require $permissionsPath;
}
return [];
}
public function getModuleSummary(): array
{
$modules = $this->discoverModules();
return [
'total' => count($modules),
'with_models' => count(array_filter($modules, fn($m) => !empty($m['models']))),
'with_filament' => count(array_filter($modules, fn($m) => !empty($m['filament_resources']))),
'with_api' => count(array_filter($modules, fn($m) => $m['has_api'])),
];
}
}

View File

@@ -0,0 +1,492 @@
<?php
namespace App\Services;
use CzProject\GitPhp\Git;
use CzProject\GitPhp\GitRepository;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class ModuleGeneratorService
{
protected string $studlyName;
protected string $kebabName;
protected string $snakeName;
protected string $modulePath;
protected array $logs = [];
protected ?GitRepository $repo = null;
public function generate(string $name, array $options = []): array
{
$this->studlyName = Str::studly($name);
$this->kebabName = Str::kebab($name);
$this->snakeName = Str::snake($name);
$this->modulePath = app_path("Modules/{$this->studlyName}");
$options = array_merge([
'description' => '',
'create_git_branch' => true,
'include_api' => false,
], $options);
// Check if module already exists
if (File::exists($this->modulePath)) {
return [
'success' => false,
'message' => "Module {$this->studlyName} already exists!",
'logs' => $this->logs,
];
}
// Handle Git branching
$branchName = null;
if ($options['create_git_branch']) {
$gitResult = $this->createGitBranch();
if (!$gitResult['success']) {
return $gitResult;
}
$branchName = $gitResult['branch'];
}
// Generate module skeleton
$this->createDirectoryStructure();
$this->createServiceProvider($options['description']);
$this->createConfig($options['description']);
$this->createPermissions();
$this->createController();
$this->createRoutes($options['include_api']);
$this->createViews();
$this->createReadme($options['description']);
$this->registerServiceProvider();
// Git commit
if ($options['create_git_branch'] && $this->repo) {
$this->commitChanges();
}
return [
'success' => true,
'message' => "Module {$this->studlyName} created successfully!",
'module_name' => $this->studlyName,
'module_path' => $this->modulePath,
'branch' => $branchName,
'logs' => $this->logs,
'next_steps' => $this->getNextSteps($branchName),
];
}
protected function createGitBranch(): array
{
try {
$git = new Git();
$repoPath = base_path();
// Check if we're in a git repository
if (!File::exists($repoPath . '/.git')) {
$this->log('⚠ No Git repository found, skipping branch creation');
return ['success' => true, 'branch' => null];
}
$this->repo = $git->open($repoPath);
// Check for uncommitted changes
if ($this->repo->hasChanges()) {
return [
'success' => false,
'message' => 'Git working directory has uncommitted changes. Please commit or stash them first.',
'logs' => $this->logs,
];
}
$branchName = "module/{$this->kebabName}";
// Create and checkout new branch
$this->repo->createBranch($branchName, true);
$this->log("✓ Created and checked out branch: {$branchName}");
return ['success' => true, 'branch' => $branchName];
} catch (\Exception $e) {
return [
'success' => false,
'message' => 'Git error: ' . $e->getMessage(),
'logs' => $this->logs,
];
}
}
protected function commitChanges(): void
{
try {
$this->repo->addAllChanges();
$this->repo->commit("feat: Add {$this->studlyName} module skeleton");
$this->log("✓ Committed changes to Git");
} catch (\Exception $e) {
$this->log("⚠ Git commit failed: " . $e->getMessage());
}
}
protected function createDirectoryStructure(): void
{
$directories = [
'',
'/Config',
'/Database/Migrations',
'/Database/Seeders',
'/Filament/Resources',
'/Http/Controllers',
'/Http/Middleware',
'/Http/Requests',
'/Models',
'/Policies',
'/Services',
'/Routes',
'/Resources/views',
];
foreach ($directories as $dir) {
File::makeDirectory("{$this->modulePath}{$dir}", 0755, true);
}
// Create .gitkeep files in empty directories
$emptyDirs = [
'/Database/Migrations',
'/Database/Seeders',
'/Filament/Resources',
'/Http/Middleware',
'/Http/Requests',
'/Models',
'/Policies',
'/Services',
];
foreach ($emptyDirs as $dir) {
File::put("{$this->modulePath}{$dir}/.gitkeep", '');
}
$this->log("✓ Created directory structure");
}
protected function createServiceProvider(string $description): void
{
$stub = <<<PHP
<?php
namespace App\Modules\\{$this->studlyName};
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
class {$this->studlyName}ServiceProvider extends ServiceProvider
{
public function register(): void
{
\$this->mergeConfigFrom(
__DIR__ . '/Config/{$this->snakeName}.php',
'{$this->snakeName}'
);
}
public function boot(): void
{
\$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
\$this->loadViewsFrom(__DIR__ . '/Resources/views', '{$this->kebabName}');
\$this->registerRoutes();
\$this->registerPermissions();
}
protected function registerRoutes(): void
{
Route::middleware(['web', 'auth'])
->prefix('{$this->kebabName}')
->name('{$this->kebabName}.')
->group(__DIR__ . '/Routes/web.php');
if (file_exists(__DIR__ . '/Routes/api.php')) {
Route::middleware(['api', 'auth:sanctum'])
->prefix('api/{$this->kebabName}')
->name('api.{$this->kebabName}.')
->group(__DIR__ . '/Routes/api.php');
}
}
protected function registerPermissions(): void
{
// Permissions are registered via RolePermissionSeeder
// See: Permissions.php in this module
}
}
PHP;
File::put("{$this->modulePath}/{$this->studlyName}ServiceProvider.php", $stub);
$this->log("✓ Created ServiceProvider");
}
protected function createConfig(string $description): void
{
$stub = <<<PHP
<?php
return [
'name' => '{$this->studlyName}',
'slug' => '{$this->kebabName}',
'description' => '{$description}',
'version' => '1.0.0',
'audit' => [
'enabled' => true,
'strategy' => 'all', // 'all', 'include', 'exclude', 'none'
'include' => [],
'exclude' => [],
],
];
PHP;
File::put("{$this->modulePath}/Config/{$this->snakeName}.php", $stub);
$this->log("✓ Created Config");
}
protected function createPermissions(): void
{
$stub = <<<PHP
<?php
return [
'{$this->snakeName}.view' => 'View {$this->studlyName}',
'{$this->snakeName}.create' => 'Create {$this->studlyName} records',
'{$this->snakeName}.edit' => 'Edit {$this->studlyName} records',
'{$this->snakeName}.delete' => 'Delete {$this->studlyName} records',
];
PHP;
File::put("{$this->modulePath}/Permissions.php", $stub);
$this->log("✓ Created Permissions");
}
protected function createController(): void
{
$stub = <<<PHP
<?php
namespace App\Modules\\{$this->studlyName}\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class {$this->studlyName}Controller extends Controller
{
public function index()
{
\$this->authorize('{$this->snakeName}.view');
return view('{$this->kebabName}::index');
}
}
PHP;
File::put("{$this->modulePath}/Http/Controllers/{$this->studlyName}Controller.php", $stub);
$this->log("✓ Created Controller");
}
protected function createRoutes(bool $includeApi): void
{
// Web routes
$webStub = <<<PHP
<?php
use App\Modules\\{$this->studlyName}\Http\Controllers\\{$this->studlyName}Controller;
use Illuminate\Support\Facades\Route;
Route::get('/', [{$this->studlyName}Controller::class, 'index'])->name('index');
// Add more routes here:
// Route::get('/create', [{$this->studlyName}Controller::class, 'create'])->name('create');
// Route::post('/', [{$this->studlyName}Controller::class, 'store'])->name('store');
// Route::get('/{id}', [{$this->studlyName}Controller::class, 'show'])->name('show');
// Route::get('/{id}/edit', [{$this->studlyName}Controller::class, 'edit'])->name('edit');
// Route::put('/{id}', [{$this->studlyName}Controller::class, 'update'])->name('update');
// Route::delete('/{id}', [{$this->studlyName}Controller::class, 'destroy'])->name('destroy');
PHP;
File::put("{$this->modulePath}/Routes/web.php", $webStub);
// API routes (if requested)
if ($includeApi) {
$apiStub = <<<PHP
<?php
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
// API routes here
// Route::get('/', fn() => response()->json(['message' => '{$this->studlyName} API']));
});
PHP;
File::put("{$this->modulePath}/Routes/api.php", $apiStub);
$this->log("✓ Created Routes (web + api)");
} else {
$this->log("✓ Created Routes (web)");
}
}
protected function createViews(): void
{
$stub = <<<BLADE
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('{$this->studlyName}') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium mb-4">{{ __('{$this->studlyName} Module') }}</h3>
<p class="text-gray-600 dark:text-gray-400">
This is your new module. Start building by adding models, controllers, and views.
</p>
<div class="mt-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<h4 class="font-medium mb-2">Quick Start:</h4>
<ul class="list-disc list-inside text-sm space-y-1">
<li>Add models in <code>app/Modules/{$this->studlyName}/Models/</code></li>
<li>Add migrations in <code>app/Modules/{$this->studlyName}/Database/Migrations/</code></li>
<li>Add Filament resources in <code>app/Modules/{$this->studlyName}/Filament/Resources/</code></li>
<li>Extend routes in <code>app/Modules/{$this->studlyName}/Routes/web.php</code></li>
</ul>
</div>
</div>
</div>
</div>
</div>
</x-app-layout>
BLADE;
File::put("{$this->modulePath}/Resources/views/index.blade.php", $stub);
$this->log("✓ Created Views");
}
protected function createReadme(string $description): void
{
$stub = <<<MD
# {$this->studlyName} Module
{$description}
## Structure
```
{$this->studlyName}/
├── Config/{$this->snakeName}.php # Module configuration
├── Database/
├── Migrations/ # Database migrations
└── Seeders/ # Database seeders
├── Filament/Resources/ # Admin panel resources
├── Http/
├── Controllers/ # HTTP controllers
├── Middleware/ # Module middleware
└── Requests/ # Form requests
├── Models/ # Eloquent models
├── Policies/ # Authorization policies
├── Services/ # Business logic services
├── Routes/
├── web.php # Web routes
└── api.php # API routes (if enabled)
├── Resources/views/ # Blade templates
├── Permissions.php # Module permissions
├── {$this->studlyName}ServiceProvider.php
└── README.md
```
## Permissions
| Permission | Description |
|------------|-------------|
| `{$this->snakeName}.view` | View {$this->studlyName} |
| `{$this->snakeName}.create` | Create records |
| `{$this->snakeName}.edit` | Edit records |
| `{$this->snakeName}.delete` | Delete records |
## Routes
| Method | URI | Name | Description |
|--------|-----|------|-------------|
| GET | `/{$this->kebabName}` | `{$this->kebabName}.index` | Module index |
## Getting Started
1. **Add a Model:**
```bash
# Create model manually or use artisan
php artisan make:model Modules/{$this->studlyName}/Models/YourModel -m
```
2. **Run Migrations:**
```bash
php artisan migrate
```
3. **Seed Permissions:**
```bash
php artisan db:seed --class=RolePermissionSeeder
```
4. **Add Filament Resource:**
Create resources in `Filament/Resources/` following the Filament documentation.
## Configuration
Edit `Config/{$this->snakeName}.php` to customize module settings including audit behavior.
MD;
File::put("{$this->modulePath}/README.md", $stub);
$this->log("✓ Created README.md");
}
protected function registerServiceProvider(): void
{
$providersPath = base_path('bootstrap/providers.php');
if (File::exists($providersPath)) {
$content = File::get($providersPath);
$providerClass = "App\\Modules\\{$this->studlyName}\\{$this->studlyName}ServiceProvider::class";
if (!str_contains($content, $providerClass)) {
$content = preg_replace(
'/(\];)/',
" {$providerClass},\n$1",
$content
);
File::put($providersPath, $content);
$this->log("✓ Registered ServiceProvider");
}
} else {
$this->log("⚠ Could not auto-register ServiceProvider");
}
}
protected function getNextSteps(?string $branchName): array
{
$steps = [
"Run migrations: `php artisan migrate`",
"Seed permissions: `php artisan db:seed --class=RolePermissionSeeder`",
"Clear caches: `php artisan optimize:clear`",
"Access frontend: `/{$this->kebabName}`",
];
if ($branchName) {
$steps[] = "Push branch: `git push -u origin {$branchName}`";
$steps[] = "Create merge request when ready";
}
return $steps;
}
protected function log(string $message): void
{
$this->logs[] = $message;
}
}