Upgrade make:module command with full ServiceProvider structure
Enhanced make:module command: - Added --model flag for creating model + migration + Filament resource - Added --api flag for including API routes - Added --no-filament flag to skip Filament resource - Creates full ServiceProvider structure with auto-registration - Creates Config, Permissions, Routes, Views, Tests - Module migrations stored in module folder - Filament resources stored in module folder - Auto-registers ServiceProvider in bootstrap/providers.php Also added: - ModuleAuditable trait for audit trail support - Updated Modules README to document new command options
This commit is contained in:
@@ -8,82 +8,322 @@
|
||||
|
||||
class MakeModuleCommand extends Command
|
||||
{
|
||||
protected $signature = 'make:module {name : The name of the module}';
|
||||
protected $signature = 'make:module
|
||||
{name : The name of the module (PascalCase)}
|
||||
{--model= : Create a model with the given name}
|
||||
{--api : Include API routes}
|
||||
{--no-filament : Skip Filament resource generation}';
|
||||
|
||||
protected $description = 'Create a new module with standard structure';
|
||||
protected $description = 'Create a new module with full structure (ServiceProvider, Config, Routes, Views, Permissions)';
|
||||
|
||||
protected string $studlyName;
|
||||
protected string $kebabName;
|
||||
protected string $snakeName;
|
||||
protected string $modulePath;
|
||||
protected ?string $modelName;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$name = $this->argument('name');
|
||||
$studlyName = Str::studly($name);
|
||||
$kebabName = Str::kebab($name);
|
||||
$this->studlyName = Str::studly($name);
|
||||
$this->kebabName = Str::kebab($name);
|
||||
$this->snakeName = Str::snake($name);
|
||||
$this->modelName = $this->option('model') ? Str::studly($this->option('model')) : null;
|
||||
|
||||
$modulePath = app_path("Modules/{$studlyName}");
|
||||
$this->modulePath = app_path("Modules/{$this->studlyName}");
|
||||
|
||||
if (File::exists($modulePath)) {
|
||||
$this->error("Module {$studlyName} already exists!");
|
||||
if (File::exists($this->modulePath)) {
|
||||
$this->error("Module {$this->studlyName} already exists!");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Creating module: {$studlyName}");
|
||||
$this->info("Creating module: {$this->studlyName}");
|
||||
$this->newLine();
|
||||
|
||||
$this->createDirectories($modulePath);
|
||||
$this->createModel($modulePath, $studlyName);
|
||||
$this->createMigration($studlyName);
|
||||
$this->createController($modulePath, $studlyName);
|
||||
$this->createRoutes($modulePath, $studlyName, $kebabName);
|
||||
$this->createViews($modulePath, $studlyName, $kebabName);
|
||||
$this->createFilamentResource($studlyName);
|
||||
$this->createTests($studlyName);
|
||||
$this->createDirectoryStructure();
|
||||
$this->createServiceProvider();
|
||||
$this->createConfig();
|
||||
$this->createPermissions();
|
||||
$this->createController();
|
||||
$this->createRoutes();
|
||||
$this->createViews();
|
||||
|
||||
if ($this->modelName) {
|
||||
$this->createModel();
|
||||
$this->createMigration();
|
||||
|
||||
if (!$this->option('no-filament')) {
|
||||
$this->createFilamentResource();
|
||||
}
|
||||
}
|
||||
|
||||
$this->createTests();
|
||||
$this->registerServiceProvider();
|
||||
|
||||
$this->info("\n✅ Module {$studlyName} created successfully!");
|
||||
$this->info("\nNext steps:");
|
||||
$this->info("1. Run migrations: php artisan migrate");
|
||||
$this->info("2. Register routes in routes/web.php:");
|
||||
$this->info(" require __DIR__.'/../app/Modules/{$studlyName}/routes.php';");
|
||||
$this->info("3. Access at: /{$kebabName}");
|
||||
$this->newLine();
|
||||
$this->info("✅ Module {$this->studlyName} created successfully!");
|
||||
$this->newLine();
|
||||
$this->warn("Next steps:");
|
||||
$this->line(" 1. Run migrations: <info>php artisan migrate</info>");
|
||||
$this->line(" 2. Seed permissions: <info>php artisan db:seed --class=RolePermissionSeeder</info>");
|
||||
$this->line(" 3. Clear caches: <info>php artisan optimize:clear</info>");
|
||||
$this->newLine();
|
||||
$this->line(" Access at:");
|
||||
$this->line(" - Frontend: <info>/{$this->kebabName}</info>");
|
||||
if ($this->modelName && !$this->option('no-filament')) {
|
||||
$this->line(" - Admin: <info>/admin → {$this->studlyName}</info>");
|
||||
}
|
||||
if ($this->option('api')) {
|
||||
$this->line(" - API: <info>/api/{$this->kebabName}</info>");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function createDirectories(string $path): void
|
||||
protected function createDirectoryStructure(): void
|
||||
{
|
||||
File::makeDirectory($path, 0755, true);
|
||||
File::makeDirectory("{$path}/Models", 0755, true);
|
||||
File::makeDirectory("{$path}/Controllers", 0755, true);
|
||||
File::makeDirectory("{$path}/Views", 0755, true);
|
||||
$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);
|
||||
}
|
||||
|
||||
$this->info("✓ Created directory structure");
|
||||
}
|
||||
|
||||
protected function createModel(string $path, string $name): void
|
||||
protected function createServiceProvider(): void
|
||||
{
|
||||
$stub = <<<PHP
|
||||
<?php
|
||||
|
||||
namespace App\Modules\\{$name}\Models;
|
||||
namespace App\Modules\\{$this->studlyName};
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class {$name} extends Model
|
||||
class {$this->studlyName}ServiceProvider extends ServiceProvider
|
||||
{
|
||||
use HasFactory;
|
||||
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->info("✓ Created ServiceProvider");
|
||||
}
|
||||
|
||||
protected function createConfig(): void
|
||||
{
|
||||
$stub = <<<PHP
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => '{$this->studlyName}',
|
||||
'slug' => '{$this->kebabName}',
|
||||
'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->info("✓ 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->info("✓ 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->info("✓ Created Controller");
|
||||
}
|
||||
|
||||
protected function createRoutes(): 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');
|
||||
PHP;
|
||||
|
||||
File::put("{$this->modulePath}/Routes/web.php", $webStub);
|
||||
|
||||
// API routes (if requested)
|
||||
if ($this->option('api')) {
|
||||
$apiStub = <<<PHP
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
// API routes here
|
||||
});
|
||||
PHP;
|
||||
|
||||
File::put("{$this->modulePath}/Routes/api.php", $apiStub);
|
||||
}
|
||||
|
||||
$this->info("✓ Created Routes" . ($this->option('api') ? ' (web + api)' : ''));
|
||||
}
|
||||
|
||||
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">
|
||||
{{ __('{$this->studlyName} module is working!') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
BLADE;
|
||||
|
||||
File::put("{$this->modulePath}/Resources/views/index.blade.php", $stub);
|
||||
$this->info("✓ Created Views");
|
||||
}
|
||||
|
||||
protected function createModel(): void
|
||||
{
|
||||
$tableName = Str::snake(Str::plural($this->modelName));
|
||||
|
||||
$stub = <<<PHP
|
||||
<?php
|
||||
|
||||
namespace App\Modules\\{$this->studlyName}\Models;
|
||||
|
||||
use App\Traits\ModuleAuditable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
class {$this->modelName} extends Model implements Auditable
|
||||
{
|
||||
use HasFactory, ModuleAuditable;
|
||||
|
||||
protected \$table = '{$tableName}';
|
||||
|
||||
protected \$fillable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected \$casts = [
|
||||
//
|
||||
];
|
||||
}
|
||||
PHP;
|
||||
|
||||
File::put("{$path}/Models/{$name}.php", $stub);
|
||||
$this->info("✓ Created Model: {$name}");
|
||||
File::put("{$this->modulePath}/Models/{$this->modelName}.php", $stub);
|
||||
$this->info("✓ Created Model: {$this->modelName}");
|
||||
}
|
||||
|
||||
protected function createMigration(string $name): void
|
||||
protected function createMigration(): void
|
||||
{
|
||||
$table = Str::snake(Str::plural($name));
|
||||
$tableName = Str::snake(Str::plural($this->modelName));
|
||||
$timestamp = date('Y_m_d_His');
|
||||
$migrationName = "create_{$table}_table";
|
||||
$migrationName = "create_{$tableName}_table";
|
||||
|
||||
$stub = <<<PHP
|
||||
<?php
|
||||
@@ -96,7 +336,7 @@ protected function createMigration(string $name): void
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('{$table}', function (Blueprint \$table) {
|
||||
Schema::create('{$tableName}', function (Blueprint \$table) {
|
||||
\$table->id();
|
||||
\$table->string('name');
|
||||
\$table->text('description')->nullable();
|
||||
@@ -106,231 +346,252 @@ public function up(): void
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('{$table}');
|
||||
Schema::dropIfExists('{$tableName}');
|
||||
}
|
||||
};
|
||||
PHP;
|
||||
|
||||
$migrationPath = database_path("migrations/{$timestamp}_{$migrationName}.php");
|
||||
$migrationPath = "{$this->modulePath}/Database/Migrations/{$timestamp}_{$migrationName}.php";
|
||||
File::put($migrationPath, $stub);
|
||||
$this->info("✓ Created Migration: {$migrationName}");
|
||||
}
|
||||
|
||||
protected function createController(string $path, string $name): void
|
||||
protected function createFilamentResource(): void
|
||||
{
|
||||
$plural = Str::plural($name);
|
||||
$variable = Str::camel($name);
|
||||
$pluralVariable = Str::camel($plural);
|
||||
$modelClass = "App\\Modules\\{$this->studlyName}\\Models\\{$this->modelName}";
|
||||
$tableName = Str::snake(Str::plural($this->modelName));
|
||||
|
||||
$stub = <<<PHP
|
||||
// Create the resource
|
||||
$resourceStub = <<<PHP
|
||||
<?php
|
||||
|
||||
namespace App\Modules\\{$name}\Controllers;
|
||||
namespace App\Modules\\{$this->studlyName}\Filament\Resources;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\\{$name}\Models\\{$name};
|
||||
use Illuminate\Http\Request;
|
||||
use App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource\Pages;
|
||||
use App\Modules\\{$this->studlyName}\Models\\{$this->modelName};
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class {$name}Controller extends Controller
|
||||
class {$this->modelName}Resource extends Resource
|
||||
{
|
||||
public function index()
|
||||
protected static ?string \$model = {$this->modelName}::class;
|
||||
|
||||
protected static ?string \$navigationIcon = 'heroicon-o-rectangle-stack';
|
||||
|
||||
protected static ?string \$navigationGroup = '{$this->studlyName}';
|
||||
|
||||
protected static ?int \$navigationSort = 1;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
\${$pluralVariable} = {$name}::latest()->paginate(15);
|
||||
return view('{$name}::index', compact('{$pluralVariable}'));
|
||||
return auth()->user()?->can('{$this->snakeName}.view') ?? false;
|
||||
}
|
||||
|
||||
public function create()
|
||||
public static function form(Form \$form): Form
|
||||
{
|
||||
return view('{$name}::create');
|
||||
return \$form
|
||||
->schema([
|
||||
Forms\Components\Section::make('{$this->modelName} Details')
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\Textarea::make('description')
|
||||
->rows(3),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request \$request)
|
||||
public static function table(Table \$table): Table
|
||||
{
|
||||
\$validated = \$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
{$name}::create(\$validated);
|
||||
|
||||
return redirect()->route('{$variable}.index')
|
||||
->with('success', '{$name} created successfully.');
|
||||
return \$table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('description')
|
||||
->limit(50),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show({$name} \${$variable})
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return view('{$name}::show', compact('{$variable}'));
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public function edit({$name} \${$variable})
|
||||
public static function getPages(): array
|
||||
{
|
||||
return view('{$name}::edit', compact('{$variable}'));
|
||||
}
|
||||
|
||||
public function update(Request \$request, {$name} \${$variable})
|
||||
{
|
||||
\$validated = \$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
\${$variable}->update(\$validated);
|
||||
|
||||
return redirect()->route('{$variable}.index')
|
||||
->with('success', '{$name} updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy({$name} \${$variable})
|
||||
{
|
||||
\${$variable}->delete();
|
||||
|
||||
return redirect()->route('{$variable}.index')
|
||||
->with('success', '{$name} deleted successfully.');
|
||||
return [
|
||||
'index' => Pages\List{$this->modelName}s::route('/'),
|
||||
'create' => Pages\Create{$this->modelName}::route('/create'),
|
||||
'edit' => Pages\Edit{$this->modelName}::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
|
||||
File::put("{$path}/Controllers/{$name}Controller.php", $stub);
|
||||
$this->info("✓ Created Controller: {$name}Controller");
|
||||
}
|
||||
File::put("{$this->modulePath}/Filament/Resources/{$this->modelName}Resource.php", $resourceStub);
|
||||
|
||||
protected function createRoutes(string $path, string $name, string $kebabName): void
|
||||
{
|
||||
$variable = Str::camel($name);
|
||||
|
||||
$stub = <<<PHP
|
||||
// Create Pages directory
|
||||
File::makeDirectory("{$this->modulePath}/Filament/Resources/{$this->modelName}Resource/Pages", 0755, true);
|
||||
|
||||
// Create List page
|
||||
$listStub = <<<PHP
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\\{$name}\Controllers\\{$name}Controller;
|
||||
namespace App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource\Pages;
|
||||
|
||||
Route::middleware(['web', 'auth'])->group(function () {
|
||||
Route::resource('{$kebabName}', {$name}Controller::class)->names('{$variable}');
|
||||
});
|
||||
use App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class List{$this->modelName}s extends ListRecords
|
||||
{
|
||||
protected static string \$resource = {$this->modelName}Resource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
|
||||
File::put("{$path}/routes.php", $stub);
|
||||
$this->info("✓ Created Routes");
|
||||
}
|
||||
File::put("{$this->modulePath}/Filament/Resources/{$this->modelName}Resource/Pages/List{$this->modelName}s.php", $listStub);
|
||||
|
||||
protected function createViews(string $path, string $name, string $kebabName): void
|
||||
{
|
||||
$plural = Str::plural($name);
|
||||
$variable = Str::camel($name);
|
||||
$pluralVariable = Str::camel($plural);
|
||||
|
||||
$indexView = <<<BLADE
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('{$plural}') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 text-gray-900">
|
||||
<div class="flex justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">{$plural} List</h3>
|
||||
<a href="{{ route('{$variable}.create') }}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Create New
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Description</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse(\${$pluralVariable} as \${$variable})
|
||||
<tr>
|
||||
<td class="px-6 py-4">{{ \${$variable}->name }}</td>
|
||||
<td class="px-6 py-4">{{ \${$variable}->description }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<a href="{{ route('{$variable}.edit', \${$variable}) }}" class="text-blue-600 hover:text-blue-900">Edit</a>
|
||||
<form action="{{ route('{$variable}.destroy', \${$variable}) }}" method="POST" class="inline">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-red-600 hover:text-red-900 ml-2">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="3" class="px-6 py-4 text-center">No {$pluralVariable} found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="mt-4">
|
||||
{{ \${$pluralVariable}->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
BLADE;
|
||||
|
||||
File::put("{$path}/Views/index.blade.php", $indexView);
|
||||
$this->info("✓ Created Views");
|
||||
}
|
||||
|
||||
protected function createFilamentResource(string $name): void
|
||||
{
|
||||
$this->call('make:filament-resource', [
|
||||
'name' => $name,
|
||||
'--generate' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createTests(string $name): void
|
||||
{
|
||||
$variable = Str::camel($name);
|
||||
|
||||
$stub = <<<PHP
|
||||
// Create Create page
|
||||
$createStub = <<<PHP
|
||||
<?php
|
||||
|
||||
use App\Modules\\{$name}\Models\\{$name};
|
||||
use App\Models\User;
|
||||
namespace App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource\Pages;
|
||||
|
||||
it('can list {$variable}s', function () {
|
||||
\$user = User::factory()->create();
|
||||
{$name}::factory()->count(3)->create();
|
||||
use App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
\$response = \$this->actingAs(\$user)->get(route('{$variable}.index'));
|
||||
|
||||
\$response->assertStatus(200);
|
||||
});
|
||||
|
||||
it('can create {$variable}', function () {
|
||||
\$user = User::factory()->create();
|
||||
|
||||
\$response = \$this->actingAs(\$user)->post(route('{$variable}.store'), [
|
||||
'name' => 'Test {$name}',
|
||||
'description' => 'Test description',
|
||||
]);
|
||||
|
||||
\$response->assertRedirect(route('{$variable}.index'));
|
||||
\$this->assertDatabaseHas('{$variable}s', ['name' => 'Test {$name}']);
|
||||
});
|
||||
class Create{$this->modelName} extends CreateRecord
|
||||
{
|
||||
protected static string \$resource = {$this->modelName}Resource::class;
|
||||
}
|
||||
PHP;
|
||||
|
||||
$testPath = base_path("tests/Modules/{$name}");
|
||||
File::put("{$this->modulePath}/Filament/Resources/{$this->modelName}Resource/Pages/Create{$this->modelName}.php", $createStub);
|
||||
|
||||
// Create Edit page
|
||||
$editStub = <<<PHP
|
||||
<?php
|
||||
|
||||
namespace App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource\Pages;
|
||||
|
||||
use App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class Edit{$this->modelName} extends EditRecord
|
||||
{
|
||||
protected static string \$resource = {$this->modelName}Resource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
|
||||
File::put("{$this->modulePath}/Filament/Resources/{$this->modelName}Resource/Pages/Edit{$this->modelName}.php", $editStub);
|
||||
|
||||
$this->info("✓ Created Filament Resource: {$this->modelName}Resource");
|
||||
}
|
||||
|
||||
protected function createTests(): void
|
||||
{
|
||||
$testPath = base_path("tests/Feature/Modules/{$this->studlyName}");
|
||||
File::makeDirectory($testPath, 0755, true);
|
||||
File::put("{$testPath}/{$name}Test.php", $stub);
|
||||
|
||||
$stub = <<<PHP
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Modules\\{$this->studlyName};
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class {$this->studlyName}Test extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_can_view_{$this->snakeName}_index(): void
|
||||
{
|
||||
\$user = User::factory()->create();
|
||||
\$user->givePermissionTo('{$this->snakeName}.view');
|
||||
|
||||
\$response = \$this->actingAs(\$user)
|
||||
->get('/{$this->kebabName}');
|
||||
|
||||
\$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_unauthorized_user_cannot_view_{$this->snakeName}(): void
|
||||
{
|
||||
\$user = User::factory()->create();
|
||||
|
||||
\$response = \$this->actingAs(\$user)
|
||||
->get('/{$this->kebabName}');
|
||||
|
||||
\$response->assertStatus(403);
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
|
||||
File::put("{$testPath}/{$this->studlyName}Test.php", $stub);
|
||||
$this->info("✓ Created Tests");
|
||||
}
|
||||
|
||||
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)) {
|
||||
// Add the provider before the closing bracket
|
||||
$content = preg_replace(
|
||||
'/(\];)/',
|
||||
" {$providerClass},\n$1",
|
||||
$content
|
||||
);
|
||||
File::put($providersPath, $content);
|
||||
$this->info("✓ Registered ServiceProvider in bootstrap/providers.php");
|
||||
}
|
||||
} else {
|
||||
$this->warn("⚠ Could not auto-register ServiceProvider. Add manually to config/app.php:");
|
||||
$this->line(" App\\Modules\\{$this->studlyName}\\{$this->studlyName}ServiceProvider::class");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +1,128 @@
|
||||
# Module System
|
||||
|
||||
> **🤖 AI Agents**: For EXACT file templates, see [CLAUDE.md](../../../CLAUDE.md)
|
||||
|
||||
This Laravel application uses a modular architecture to organize features into self-contained modules.
|
||||
|
||||
## Creating a New Module
|
||||
|
||||
Use the artisan command to scaffold a complete module:
|
||||
|
||||
```bash
|
||||
# Basic module (no model)
|
||||
php artisan make:module ProductCatalog
|
||||
|
||||
# With a model + Filament resource
|
||||
php artisan make:module ProductCatalog --model=Product
|
||||
|
||||
# With API routes
|
||||
php artisan make:module ProductCatalog --model=Product --api
|
||||
|
||||
# Without Filament admin
|
||||
php artisan make:module ProductCatalog --model=Product --no-filament
|
||||
```
|
||||
|
||||
This creates:
|
||||
- **Model**: `app/Modules/ProductCatalog/Models/ProductCatalog.php`
|
||||
- **Controller**: `app/Modules/ProductCatalog/Controllers/ProductCatalogController.php`
|
||||
- **Routes**: `app/Modules/ProductCatalog/routes.php`
|
||||
- **Views**: `app/Modules/ProductCatalog/Views/`
|
||||
- **Migration**: `database/migrations/YYYY_MM_DD_HHMMSS_create_product_catalogs_table.php`
|
||||
- **Filament Resource**: `app/Filament/Resources/ProductCatalogResource.php`
|
||||
- **Tests**: `tests/Modules/ProductCatalog/ProductCatalogTest.php`
|
||||
|
||||
## Module Structure
|
||||
## What Gets Created
|
||||
|
||||
### Basic module (`make:module Name`):
|
||||
```
|
||||
app/Modules/
|
||||
└── ProductCatalog/
|
||||
├── Models/
|
||||
│ └── ProductCatalog.php
|
||||
├── Controllers/
|
||||
│ └── ProductCatalogController.php
|
||||
├── Views/
|
||||
│ ├── index.blade.php
|
||||
│ ├── create.blade.php
|
||||
│ ├── edit.blade.php
|
||||
│ └── show.blade.php
|
||||
└── routes.php
|
||||
app/Modules/Name/
|
||||
├── Config/name.php # Module config
|
||||
├── Database/Migrations/ # Module migrations
|
||||
├── Database/Seeders/
|
||||
├── Filament/Resources/ # Admin resources
|
||||
├── Http/Controllers/NameController.php
|
||||
├── Http/Middleware/
|
||||
├── Http/Requests/
|
||||
├── Models/
|
||||
├── Policies/
|
||||
├── Services/
|
||||
├── Routes/web.php # Frontend routes
|
||||
├── Resources/views/index.blade.php
|
||||
├── Permissions.php # Module permissions
|
||||
└── NameServiceProvider.php # Auto-registered
|
||||
```
|
||||
|
||||
## Registering Module Routes
|
||||
### With `--model=Product`:
|
||||
Adds:
|
||||
- `Models/Product.php` (with auditing)
|
||||
- `Database/Migrations/create_products_table.php`
|
||||
- `Filament/Resources/ProductResource.php` (full CRUD)
|
||||
|
||||
After creating a module, register its routes in `routes/web.php`:
|
||||
|
||||
```php
|
||||
require __DIR__.'/../app/Modules/ProductCatalog/routes.php';
|
||||
```
|
||||
### With `--api`:
|
||||
Adds:
|
||||
- `Routes/api.php` (Sanctum-protected)
|
||||
|
||||
## Module Features
|
||||
|
||||
Each module includes:
|
||||
|
||||
✅ **CRUD Operations** - Full Create, Read, Update, Delete functionality
|
||||
✅ **Authentication** - Routes protected by auth middleware
|
||||
✅ **Filament Admin** - Auto-generated admin panel resource
|
||||
✅ **Blade Views** - Responsive Tailwind CSS templates
|
||||
✅ **Tests** - Pest test suite for module functionality
|
||||
✅ **Permissions** - Ready for role-based access control
|
||||
✅ **ServiceProvider** - Auto-registered, loads routes/views/migrations
|
||||
✅ **Config** - Module-specific settings with audit configuration
|
||||
✅ **Permissions** - Pre-defined CRUD permissions
|
||||
✅ **Filament Admin** - Full CRUD resource with navigation group
|
||||
✅ **Auditing** - Track all changes via ModuleAuditable trait
|
||||
✅ **Tests** - Feature tests with permission checks
|
||||
|
||||
## Example Usage
|
||||
|
||||
```bash
|
||||
# Create an Inventory module
|
||||
php artisan make:module Inventory
|
||||
# Create a Stock Management module with Product model
|
||||
php artisan make:module StockManagement --model=Product --api
|
||||
|
||||
# Run migrations
|
||||
php artisan migrate
|
||||
|
||||
# Register routes in routes/web.php
|
||||
require __DIR__.'/../app/Modules/Inventory/routes.php';
|
||||
# Seed permissions
|
||||
php artisan db:seed --class=RolePermissionSeeder
|
||||
|
||||
# Access at: http://localhost:8080/inventory
|
||||
# Admin panel: http://localhost:8080/admin/inventories
|
||||
# Clear caches
|
||||
php artisan optimize:clear
|
||||
```
|
||||
|
||||
## Adding Permissions
|
||||
**Access:**
|
||||
- Frontend: `http://localhost:8080/stock-management`
|
||||
- Admin: `http://localhost:8080/admin` → Stock Management section
|
||||
- API: `http://localhost:8080/api/stock-management`
|
||||
|
||||
To add module-specific permissions:
|
||||
## Permissions
|
||||
|
||||
Each module creates these permissions in `Permissions.php`:
|
||||
|
||||
```php
|
||||
// In database/seeders/RolePermissionSeeder.php
|
||||
$permissions = [
|
||||
'inventory.view',
|
||||
'inventory.create',
|
||||
'inventory.edit',
|
||||
'inventory.delete',
|
||||
return [
|
||||
'stock_management.view' => 'View Stock Management',
|
||||
'stock_management.create' => 'Create Stock Management records',
|
||||
'stock_management.edit' => 'Edit Stock Management records',
|
||||
'stock_management.delete' => 'Delete Stock Management records',
|
||||
];
|
||||
```
|
||||
|
||||
Use in Blade:
|
||||
```blade
|
||||
@can('stock_management.view')
|
||||
<a href="{{ route('stock-management.index') }}">Stock</a>
|
||||
@endcan
|
||||
```
|
||||
|
||||
Use in Controller:
|
||||
```php
|
||||
$this->authorize('stock_management.view');
|
||||
```
|
||||
|
||||
## Audit Configuration
|
||||
|
||||
Edit `Config/module_name.php`:
|
||||
|
||||
```php
|
||||
'audit' => [
|
||||
'enabled' => true,
|
||||
'strategy' => 'all', // 'all', 'include', 'exclude', 'none'
|
||||
'include' => [], // Fields to audit (if strategy='include')
|
||||
'exclude' => ['password'], // Fields to exclude
|
||||
],
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep modules self-contained** - Each module should be independent
|
||||
2. **Use namespaces** - Follow the `App\Modules\{ModuleName}` pattern
|
||||
3. **Test your modules** - Run `php artisan test` after creating
|
||||
4. **Document changes** - Update module README if you modify structure
|
||||
5. **Use Filament** - Leverage the admin panel for quick CRUD interfaces
|
||||
1. **Keep modules independent** - Avoid tight coupling between modules
|
||||
2. **Use Services** - Put business logic in `Services/`, not controllers
|
||||
3. **Define clear permissions** - One permission per action
|
||||
4. **Test your modules** - Run `php artisan test` after creating
|
||||
5. **Use Filament** - Leverage admin panel for quick CRUD
|
||||
|
||||
77
src/app/Traits/ModuleAuditable.php
Normal file
77
src/app/Traits/ModuleAuditable.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use OwenIt\Auditing\Auditable;
|
||||
|
||||
trait ModuleAuditable
|
||||
{
|
||||
use Auditable;
|
||||
|
||||
/**
|
||||
* Get the audit configuration from module config.
|
||||
*/
|
||||
public function getAuditConfig(): array
|
||||
{
|
||||
$moduleName = $this->getModuleName();
|
||||
|
||||
return config("{$moduleName}.audit", [
|
||||
'enabled' => true,
|
||||
'strategy' => 'all',
|
||||
'include' => [],
|
||||
'exclude' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if auditing is enabled for this model.
|
||||
*/
|
||||
public function isAuditingEnabled(): bool
|
||||
{
|
||||
$config = $this->getAuditConfig();
|
||||
|
||||
return $config['enabled'] ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fields to include in audit (if strategy is 'include').
|
||||
*/
|
||||
public function getAuditInclude(): array
|
||||
{
|
||||
$config = $this->getAuditConfig();
|
||||
|
||||
if (($config['strategy'] ?? 'all') === 'include') {
|
||||
return $config['include'] ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fields to exclude from audit.
|
||||
*/
|
||||
public function getAuditExclude(): array
|
||||
{
|
||||
$config = $this->getAuditConfig();
|
||||
|
||||
return array_merge(
|
||||
$this->auditExclude ?? [],
|
||||
$config['exclude'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the module name from the model's namespace.
|
||||
*/
|
||||
protected function getModuleName(): string
|
||||
{
|
||||
$class = get_class($this);
|
||||
|
||||
// Extract module name from namespace: App\Modules\{ModuleName}\Models\...
|
||||
if (preg_match('/App\\\\Modules\\\\([^\\\\]+)\\\\/', $class, $matches)) {
|
||||
return \Illuminate\Support\Str::snake($matches[1]);
|
||||
}
|
||||
|
||||
return 'app';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user