diff --git a/Modules/Developer/Config/config.php b/Modules/Developer/Config/config.php
new file mode 100644
index 0000000..f3bc1fc
--- /dev/null
+++ b/Modules/Developer/Config/config.php
@@ -0,0 +1,8 @@
+ 'Modules',
+ 'modules_path' => base_path('Modules'),
+ 'themes_path' => resource_path('themes'),
+ 'overwrite' => false,
+];
diff --git a/Modules/Developer/Console/Commands/InspectModulesCommand.php b/Modules/Developer/Console/Commands/InspectModulesCommand.php
new file mode 100644
index 0000000..7d95618
--- /dev/null
+++ b/Modules/Developer/Console/Commands/InspectModulesCommand.php
@@ -0,0 +1,32 @@
+argument('name');
+ $rows = [];
+ foreach ($files as $file) {
+ $manifest = json_decode((string) file_get_contents($file), true);
+ if (! is_array($manifest)) { $this->error("Invalid JSON: {$file}"); return self::FAILURE; }
+ if ($name && strcasecmp((string) ($manifest['name'] ?? ''), (string) $name) !== 0) { continue; }
+ $rows[] = [
+ 'name' => $manifest['name'] ?? basename(dirname($file)),
+ 'version' => $manifest['version'] ?? 'unknown',
+ 'requires' => implode(', ', $manifest['requires'] ?? []),
+ 'providers' => count($manifest['providers'] ?? []),
+ ];
+ }
+ if ($this->option('json')) { $this->line(json_encode($rows, JSON_PRETTY_PRINT)); return self::SUCCESS; }
+ $this->table(['Module', 'Version', 'Requires', 'Providers'], $rows);
+ return self::SUCCESS;
+ }
+}
diff --git a/Modules/Developer/Console/Commands/MakeCrudCommand.php b/Modules/Developer/Console/Commands/MakeCrudCommand.php
new file mode 100644
index 0000000..8c83989
--- /dev/null
+++ b/Modules/Developer/Console/Commands/MakeCrudCommand.php
@@ -0,0 +1,28 @@
+option('module') ?: $this->choice('Select the target module', $this->modules());
+ try { $files = $generator->generate((string) $module, $this->argument('model'), (bool) $this->option('force')); }
+ catch (Throwable $e) { $this->error($e->getMessage()); return self::FAILURE; }
+ foreach ($files as $file) { $this->line("created {$file}"); }
+ $this->warn('Register the generated controller routes in the target module Routes/web.php file.');
+ return self::SUCCESS;
+ }
+
+ private function modules(): array
+ {
+ return collect(glob(base_path('Modules/*/module.json')) ?: [])->map(fn (string $file) => basename(dirname($file)))->sort()->values()->all();
+ }
+}
diff --git a/Modules/Developer/Console/Commands/MakeModuleCommand.php b/Modules/Developer/Console/Commands/MakeModuleCommand.php
new file mode 100644
index 0000000..53e48d9
--- /dev/null
+++ b/Modules/Developer/Console/Commands/MakeModuleCommand.php
@@ -0,0 +1,22 @@
+generate($this->argument('name'), (bool) $this->option('force')); }
+ catch (Throwable $e) { $this->error($e->getMessage()); return self::FAILURE; }
+ foreach ($files as $file) { $this->line("created {$file}"); }
+ $this->newLine(); $this->info('Enable the module in modules_statuses.json, then run composer dump-autoload.');
+ return self::SUCCESS;
+ }
+}
diff --git a/Modules/Developer/Console/Commands/MakeThemeCommand.php b/Modules/Developer/Console/Commands/MakeThemeCommand.php
new file mode 100644
index 0000000..50c803b
--- /dev/null
+++ b/Modules/Developer/Console/Commands/MakeThemeCommand.php
@@ -0,0 +1,21 @@
+generate($this->argument('name'), (bool) $this->option('force')); }
+ catch (Throwable $e) { $this->error($e->getMessage()); return self::FAILURE; }
+ foreach ($files as $file) { $this->line("created {$file}"); }
+ return self::SUCCESS;
+ }
+}
diff --git a/Modules/Developer/Http/Controllers/DeveloperController.php b/Modules/Developer/Http/Controllers/DeveloperController.php
new file mode 100644
index 0000000..d921e99
--- /dev/null
+++ b/Modules/Developer/Http/Controllers/DeveloperController.php
@@ -0,0 +1,27 @@
+map(function (string $file): array {
+ $manifest = json_decode((string) file_get_contents($file), true) ?: [];
+
+ return [
+ 'name' => $manifest['name'] ?? basename(dirname($file)),
+ 'version' => $manifest['version'] ?? 'unknown',
+ 'description' => $manifest['description'] ?? '',
+ 'requires' => $manifest['requires'] ?? [],
+ ];
+ })
+ ->sortBy('name')
+ ->values();
+
+ return view('developer::admin.index', compact('modules'));
+ }
+}
diff --git a/Modules/Developer/Providers/DeveloperServiceProvider.php b/Modules/Developer/Providers/DeveloperServiceProvider.php
new file mode 100644
index 0000000..a5f09a9
--- /dev/null
+++ b/Modules/Developer/Providers/DeveloperServiceProvider.php
@@ -0,0 +1,32 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Developer', 'Config/config.php'), 'developer');
+ }
+
+ public function boot(): void
+ {
+ $this->loadViewsFrom(module_path('Developer', 'Resources/views'), 'developer');
+
+ if ($this->app->runningInConsole()) {
+ $this->commands([
+ MakeModuleCommand::class,
+ MakeCrudCommand::class,
+ MakeThemeCommand::class,
+ InspectModulesCommand::class,
+ ]);
+ }
+ }
+}
diff --git a/Modules/Developer/Providers/RouteServiceProvider.php b/Modules/Developer/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..50cbdde
--- /dev/null
+++ b/Modules/Developer/Providers/RouteServiceProvider.php
@@ -0,0 +1,14 @@
+group(module_path('Developer', 'Routes/web.php'));
+ }
+}
diff --git a/Modules/Developer/Resources/views/admin/index.blade.php b/Modules/Developer/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..ca9fe9f
--- /dev/null
+++ b/Modules/Developer/Resources/views/admin/index.blade.php
@@ -0,0 +1,26 @@
+
+
+
+
{{ __('Developer Tools') }}
+
{{ __('Generate modules, CRUD resources and themes using the platform conventions.') }}
+
+
+
+
{{ __('Module Generator') }}
php artisan module:make Products
+
{{ __('CRUD Generator') }}
php artisan crud:make Product --module=Products
+
{{ __('Theme Generator') }}
php artisan theme:make Corporate
+
+
+
+
{{ __('Installed modules') }}
+
+ @foreach ($modules as $module)
+
+
{{ $module['name'] }}
{{ $module['description'] }}
+
v{{ $module['version'] }}
{{ implode(', ', $module['requires']) }}
+
+ @endforeach
+
+
+
+
diff --git a/Modules/Developer/Routes/web.php b/Modules/Developer/Routes/web.php
new file mode 100644
index 0000000..5a8712a
--- /dev/null
+++ b/Modules/Developer/Routes/web.php
@@ -0,0 +1,11 @@
+prefix('administration/developer')
+ ->name('administration.developer.')
+ ->group(function (): void {
+ Route::get('/', DeveloperController::class)->name('index');
+ });
diff --git a/Modules/Developer/Support/CrudGenerator.php b/Modules/Developer/Support/CrudGenerator.php
new file mode 100644
index 0000000..c1b7c07
--- /dev/null
+++ b/Modules/Developer/Support/CrudGenerator.php
@@ -0,0 +1,68 @@
+studly($module);
+ $model = $this->studly($model);
+ $table = str($model)->plural()->snake()->toString();
+ $route = str($model)->plural()->kebab()->toString();
+ $root = rtrim(config('developer.modules_path', base_path('Modules')), '/').'/'.$module;
+ $timestamp = now()->format('Y_m_d_His');
+
+ $files = [
+ "$root/Models/{$model}.php" => $this->model($module, $model),
+ "$root/Http/Controllers/{$model}Controller.php" => $this->controller($module, $model),
+ "$root/Http/Requests/Store{$model}Request.php" => $this->request($module, $model),
+ "$root/Database/Migrations/{$timestamp}_create_{$table}_table.php" => $this->migration($table),
+ "$root/Resources/views/admin/{$route}/index.blade.php" => $this->indexView($model, $route),
+ "$root/Resources/views/admin/{$route}/form.blade.php" => $this->formView($model),
+ "$root/Tests/Feature/{$model}CrudTest.php" => $this->crudTest($module, $model),
+ ];
+
+ foreach ($files as $path => $contents) {
+ $this->write($path, $contents, $force);
+ }
+
+ return array_keys($files);
+ }
+
+ private function model(string $module, string $model): string
+ {
+ return " 'boolean'];\n }\n}\n";
+ }
+
+ private function controller(string $module, string $model): string
+ {
+ $plural = str($model)->plural()->kebab();
+ return "snake()."::admin.{$plural}.index', ['records' => {$model}::query()->latest()->paginate()]);\n }\n\n public function store(Store{$model}Request \$request): RedirectResponse\n {\n {$model}::query()->create(\$request->validated());\n return back()->with('status', '{$model} created.');\n }\n}\n";
+ }
+
+ private function request(string $module, string $model): string
+ {
+ return "user() !== null; }\n public function rules(): array { return ['name' => ['required', 'string', 'max:255'], 'is_active' => ['sometimes', 'boolean']]; }\n}\n";
+ }
+
+ private function migration(string $table): string
+ {
+ return "id(); \$table->string('name'); \$table->boolean('is_active')->default(true); \$table->timestamps(); }); }\n public function down(): void { Schema::dropIfExists('{$table}'); }\n};\n";
+ }
+
+ private function indexView(string $model, string $route): string
+ {
+ return "\n {$model}
@foreach (\$records as \$record)
{{ \$record->name }}
@endforeach{{ \$records->links() }}
\n\n";
+ }
+
+ private function formView(string $model): string
+ {
+ return "
\n";
+ }
+
+ private function crudTest(string $module, string $model): string
+ {
+ return "create(['name' => 'Example']))->toBeInstanceOf({$model}::class); });\n";
+ }
+}
diff --git a/Modules/Developer/Support/Generator.php b/Modules/Developer/Support/Generator.php
new file mode 100644
index 0000000..a8c8bf8
--- /dev/null
+++ b/Modules/Developer/Support/Generator.php
@@ -0,0 +1,43 @@
+files->isDirectory($path)) {
+ $this->files->makeDirectory($path, 0755, true);
+ }
+ }
+
+ protected function write(string $path, string $contents, bool $force = false): void
+ {
+ if ($this->files->exists($path) && ! $force) {
+ throw new RuntimeException("File already exists: {$path}");
+ }
+
+ $this->createDirectory(dirname($path));
+ $this->files->put($path, $contents);
+ }
+
+ protected function studly(string $value): string
+ {
+ return str($value)->studly()->toString();
+ }
+
+ protected function snake(string $value): string
+ {
+ return str($value)->snake()->toString();
+ }
+
+ protected function kebab(string $value): string
+ {
+ return str($value)->kebab()->toString();
+ }
+}
diff --git a/Modules/Developer/Support/ModuleGenerator.php b/Modules/Developer/Support/ModuleGenerator.php
new file mode 100644
index 0000000..a9668e3
--- /dev/null
+++ b/Modules/Developer/Support/ModuleGenerator.php
@@ -0,0 +1,55 @@
+studly($name);
+ $alias = $this->kebab($name);
+ $root = rtrim(config('developer.modules_path', base_path('Modules')), '/').'/'.$name;
+ $provider = "Modules\\{$name}\\Providers\\{$name}ServiceProvider";
+
+ $files = [
+ "$root/module.json" => json_encode([
+ 'name' => $name,
+ 'alias' => $alias,
+ 'version' => '1.0.0',
+ 'description' => "{$name} module.",
+ 'keywords' => [$alias],
+ 'priority' => 100,
+ 'providers' => [$provider],
+ 'aliases' => (object) [],
+ 'files' => [],
+ 'requires' => ['Core', 'Administration'],
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL,
+ "$root/Config/config.php" => " '{$name}'];\n",
+ "$root/Providers/{$name}ServiceProvider.php" => $this->provider($name),
+ "$root/Providers/RouteServiceProvider.php" => $this->routeProvider($name),
+ "$root/Routes/web.php" => "prefix('administration/{$alias}')->name('administration.{$alias}.')->group(function (): void {\n // Register module routes here.\n});\n",
+ "$root/Tests/Feature/{$name}ModuleBootTest.php" => $this->test($name),
+ ];
+
+ foreach ($files as $path => $contents) {
+ $this->write($path, $contents, $force);
+ }
+
+ return array_keys($files);
+ }
+
+ private function provider(string $name): string
+ {
+ return "app->register(RouteServiceProvider::class);\n \$this->mergeConfigFrom(module_path('{$name}', 'Config/config.php'), '".str($name)->snake()."');\n }\n\n public function boot(): void\n {\n \$this->loadMigrationsFrom(module_path('{$name}', 'Database/Migrations'));\n \$this->loadViewsFrom(module_path('{$name}', 'Resources/views'), '".str($name)->snake()."');\n }\n}\n";
+ }
+
+ private function routeProvider(string $name): string
+ {
+ return "group(module_path('{$name}', 'Routes/web.php'));\n }\n}\n";
+ }
+
+ private function test(string $name): string
+ {
+ return "providerIsLoaded(Modules\\{$name}\\Providers\\{$name}ServiceProvider::class))->toBeTrue();\n});\n";
+ }
+}
diff --git a/Modules/Developer/Support/ThemeGenerator.php b/Modules/Developer/Support/ThemeGenerator.php
new file mode 100644
index 0000000..e1ed4b7
--- /dev/null
+++ b/Modules/Developer/Support/ThemeGenerator.php
@@ -0,0 +1,21 @@
+kebab($name);
+ $root = rtrim(config('developer.themes_path', resource_path('themes')), '/').'/'.$name;
+ $files = [
+ "$root/theme.json" => json_encode(['name' => str($name)->headline()->toString(), 'slug' => $name, 'version' => '1.0.0', 'parent' => 'common'], JSON_PRETTY_PRINT).PHP_EOL,
+ "$root/views/layouts/app.blade.php" => "getLocale()) }}\">{{ \$title ?? config('app.name') }}@vite(['resources/css/app.css', 'resources/js/app.js']){{ \$slot }}\n",
+ "$root/views/home.blade.php" => "{{ config('app.name') }}
\n",
+ "$root/assets/css/theme.css" => ":root { --theme-radius: 0.5rem; }\n",
+ "$root/README.md" => "# ".str($name)->headline()." Theme\n\nGenerated by `php artisan theme:make {$name}`.\n",
+ ];
+ foreach ($files as $path => $contents) { $this->write($path, $contents, $force); }
+ return array_keys($files);
+ }
+}
diff --git a/Modules/Developer/Tests/Feature/DeveloperModuleBootTest.php b/Modules/Developer/Tests/Feature/DeveloperModuleBootTest.php
new file mode 100644
index 0000000..80f181c
--- /dev/null
+++ b/Modules/Developer/Tests/Feature/DeveloperModuleBootTest.php
@@ -0,0 +1,11 @@
+providerIsLoaded(DeveloperServiceProvider::class))->toBeTrue();
+});
+
+it('registers developer commands', function (): void {
+ $this->artisan('module:inspect')->assertSuccessful();
+});
diff --git a/Modules/Developer/module.json b/Modules/Developer/module.json
new file mode 100644
index 0000000..7df5745
--- /dev/null
+++ b/Modules/Developer/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Developer",
+ "alias": "developer",
+ "version": "1.0.0",
+ "description": "Developer generators, module inspection and scaffolding tools.",
+ "keywords": ["developer", "generator", "scaffold"],
+ "priority": 80,
+ "providers": ["Modules\\Developer\\Providers\\DeveloperServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php
index 0e0e0ef..ed3ad40 100644
--- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php
+++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php
@@ -58,6 +58,7 @@ final class RolePermissionSeeder extends Seeder
'sites.view',
'sites.manage',
'sites.assign',
+ 'developer.access',
];
foreach ($permissions as $permission) {
diff --git a/modules_statuses.json b/modules_statuses.json
index d30050b..32b21e4 100644
--- a/modules_statuses.json
+++ b/modules_statuses.json
@@ -33,5 +33,6 @@
"Locations": true,
"Workflow": true,
"Localization": true,
- "Sites": true
+ "Sites": true,
+ "Developer": true
}
diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php
index 883cdff..dcd1009 100644
--- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php
+++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php
@@ -32,6 +32,7 @@
{{ __('Testimonials') }}
{{ __('Sites') }}{{ __('Dashboard') }}{{ __('Administration') }}
+{{ __('Developer Tools') }}
{{ __('Review Queue') }}
{{ __('Users') }}{{ __('Roles & Permissions') }}{{ __('Media') }}{{ __('Search') }}
{{ __('Localisation') }}{{ __('SEO') }}{{ __('Settings') }}{{ __('Modules') }}{{ __('Audit Log') }}