Advanced patterns
Custom Thunderclap Generators
This guide covers extending Thunderclap to create custom generators, modify stub templates, and build domain-specific scaffolding.
Overview
Thunderclap's generator system is extensible, allowing you to:
- Create custom stub templates
- Add new generator commands
- Hook into generation lifecycle
- Build domain-specific generators (approval workflows, audit logs, etc.)
When to Create Custom Generators:
- Repetitive patterns in your domain (approval flows, versioning)
- Company-specific conventions (naming, structure)
- Integration with third-party services (payment gateways, CRMs)
- Compliance requirements (audit trails, data retention)
Understanding Thunderclap Architecture
Generator Lifecycle
Plain Text
1. Command Execution ↓2. Parse Arguments (model name, options) ↓3. Load Stub Templates ↓4. Replace Placeholders ↓5. Fire "Generating" Event ↓6. Write Files to Disk ↓7. Fire "Generated" Event ↓8. Run Post-Generation HooksKey Components
- Stubs: Template files with placeholders (
{{ model }},{{ namespace }}) - Generators: Classes that orchestrate file creation
- Events: Hooks for custom logic (
Thunderclap\Events\ModelGenerated) - Config:
config/thunderclap.phpfor paths and namespaces
Custom Stub Templates
Step 1: Publish Default Stubs
Bash
php artisan vendor:publish --tag=thunderclap-stubsThis creates resources/stubs/thunderclap/ with default templates:
Plain Text
resources/stubs/thunderclap/├── model.stub├── migration.stub├── controller.stub├── request.stub├── resource.stub├── factory.stub└── test.stubStep 2: Customize Model Stub
PHP
// resources/stubs/thunderclap/model.stub<?phpnamespace {{ namespace }};use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\SoftDeletes;use Illuminate\Database\Eloquent\Factories\HasFactory;use App\Traits\Auditable; // Custom traitclass {{ model }} extends Model{ use HasFactory, SoftDeletes, Auditable; protected $fillable = [ {{ fillable }} ]; protected $casts = [ {{ casts }} ]; // Custom: Auto-generate UUID on creation protected static function booted() { static::creating(function ($model) { if (empty($model->uuid)) { $model->uuid = \Illuminate\Support\Str::uuid(); } }); } // Custom: Audit trail relationship public function auditLogs() { return $this->morphMany(\App\Models\AuditLog::class, 'auditable'); }}Step 3: Add Custom Placeholders
PHP
// config/thunderclap.phpreturn [ 'stubs' => [ 'model' => resource_path('stubs/thunderclap/model.stub'), // ... other stubs ], 'placeholders' => [ 'author' => env('THUNDERCLAP_AUTHOR', 'Generated by Thunderclap'), 'company' => env('COMPANY_NAME', 'Acme Corp'), 'license' => 'MIT', ],];PHP
// Updated stub with custom placeholders/** * {{ model }} Model * * @author {{ author }} * @copyright {{ company }} * @license {{ license }} */class {{ model }} extends Model{ // ...}Step 4: Generate with Custom Stub
Bash
php artisan thunderclap:generate Product# Generated model now includes:# - Auditable trait# - UUID auto-generation# - Audit log relationship# - Custom docblockCreating a Custom Generator Command
Example: Approval Workflow Generator
This generator creates a complete approval workflow with states, transitions, and notifications.
Step 1: Create Generator Command
Bash
php artisan make:command GenerateApprovalWorkflowStep 2: Implement Generator Logic
PHP
<?phpnamespace App\Console\Commands;use Illuminate\Console\Command;use Illuminate\Support\Str;use Illuminate\Support\Facades\File;class GenerateApprovalWorkflow extends Command{ protected $signature = 'generate:approval-workflow {model : The model name (e.g., PurchaseOrder)} {--states=pending,approved,rejected : Comma-separated workflow states} {--domain= : Domain namespace (optional)}'; protected $description = 'Generate approval workflow for a model'; public function handle() { $model = $this->argument('model'); $states = explode(',', $this->option('states')); $domain = $this->option('domain'); $this->info("Generating approval workflow for {$model}..."); // 1. Generate base model with Thunderclap $this->call('thunderclap:generate', [ 'model' => $model, '--domain' => $domain, ]); // 2. Add workflow state column migration $this->generateStateMigration($model, $states); // 3. Generate state enum $this->generateStateEnum($model, $states, $domain); // 4. Add workflow trait to model $this->addWorkflowTrait($model, $domain); // 5. Generate approval controller $this->generateApprovalController($model, $domain); // 6. Generate approval notification $this->generateApprovalNotification($model, $domain); // 7. Add routes $this->addApprovalRoutes($model); $this->info("✅ Approval workflow generated successfully!"); $this->info("Next steps:"); $this->info(" 1. Run: php artisan migrate"); $this->info(" 2. Review: app/Domains/{$domain}/Enums/{$model}State.php"); $this->info(" 3. Customize: app/Domains/{$domain}/Http/Controllers/{$model}ApprovalController.php"); } protected function generateStateMigration(string $model, array $states) { $table = Str::snake(Str::pluralStudly($model)); $timestamp = date('Y_m_d_His'); $filename = "database/migrations/{$timestamp}_add_workflow_to_{$table}_table.php"; $stub = <<<PHP<?phpuse Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;return new class extends Migration{ public function up() { Schema::table('{$table}', function (Blueprint \$table) { \$table->string('status')->default('{$states[0]}')->after('id'); \$table->foreignId('approved_by')->nullable()->constrained('users'); \$table->timestamp('approved_at')->nullable(); \$table->text('approval_notes')->nullable(); }); } public function down() { Schema::table('{$table}', function (Blueprint \$table) { \$table->dropColumn(['status', 'approved_by', 'approved_at', 'approval_notes']); }); }};PHP; File::put(base_path($filename), $stub); $this->info("✓ Created migration: {$filename}"); } protected function generateStateEnum(string $model, array $states, ?string $domain) { $namespace = $domain ? "App\\Domains\\{$domain}\\Enums" : "App\\Enums"; $directory = $domain ? "app/Domains/{$domain}/Enums" : "app/Enums"; File::ensureDirectoryExists(base_path($directory)); $cases = collect($states) ->map(fn($state) => " case " . Str::studly($state) . " = '{$state}';") ->implode("\n"); $labels = collect($states) ->map(fn($state) => " self::" . Str::studly($state) . " => '" . Str::title($state) . "',") ->implode("\n"); $stub = <<<PHP<?phpnamespace {$namespace};enum {$model}State: string{{$cases} public function label(): string { return match(\$this) {{$labels} }; } public function canTransitionTo(self \$newState): bool { return match(\$this) { self::Pending => in_array(\$newState, [self::Approved, self::Rejected]), self::Approved => false, // Terminal state self::Rejected => in_array(\$newState, [self::Pending]), // Allow resubmission }; }}PHP; $filename = "{$directory}/{$model}State.php"; File::put(base_path($filename), $stub); $this->info("✓ Created enum: {$filename}"); } protected function addWorkflowTrait(string $model, ?string $domain) { $modelPath = $domain ? "app/Domains/{$domain}/Models/{$model}.php" : "app/Models/{$model}.php"; $content = File::get(base_path($modelPath)); // Add trait use statement $traitUse = "use App\\Traits\\HasApprovalWorkflow;"; if (!Str::contains($content, $traitUse)) { $content = Str::replaceFirst( "use Illuminate\Database\Eloquent\Model;", "use Illuminate\Database\Eloquent\Model;\n{$traitUse}", $content ); } // Add trait to class $traitInClass = "use HasApprovalWorkflow;"; if (!Str::contains($content, $traitInClass)) { $content = Str::replaceFirst( "use HasFactory;", "use HasFactory, HasApprovalWorkflow;", $content ); } File::put(base_path($modelPath), $content); $this->info("✓ Added HasApprovalWorkflow trait to {$model}"); } protected function generateApprovalController(string $model, ?string $domain) { $namespace = $domain ? "App\\Domains\\{$domain}\\Http\\Controllers" : "App\\Http\\Controllers"; $directory = $domain ? "app/Domains/{$domain}/Http/Controllers" : "app/Http/Controllers"; File::ensureDirectoryExists(base_path($directory)); $modelClass = $domain ? "App\\Domains\\{$domain}\\Models\\{$model}" : "App\\Models\\{$model}"; $stub = <<<PHP<?phpnamespace {$namespace};use {$modelClass};use Illuminate\Http\Request;use App\Http\Controllers\Controller;class {$model}ApprovalController extends Controller{ public function approve(Request \$request, {$model} \$model) { \$request->validate([ 'notes' => 'nullable|string|max:1000', ]); \$model->approve( approver: \$request->user(), notes: \$request->notes ); return redirect() ->route('{$model}.show', \$model) ->with('success', '{$model} approved successfully'); } public function reject(Request \$request, {$model} \$model) { \$request->validate([ 'notes' => 'required|string|max:1000', ]); \$model->reject( approver: \$request->user(), notes: \$request->notes ); return redirect() ->route('{$model}.show', \$model) ->with('success', '{$model} rejected'); }}PHP; $filename = "{$directory}/{$model}ApprovalController.php"; File::put(base_path($filename), $stub); $this->info("✓ Created controller: {$filename}"); } protected function generateApprovalNotification(string $model, ?string $domain) { $this->call('make:notification', [ 'name' => "{$model}ApprovalRequested", ]); $this->info("✓ Created notification: {$model}ApprovalRequested"); } protected function addApprovalRoutes(string $model) { $routeName = Str::kebab(Str::pluralStudly($model)); $routes = <<<PHP// {$model} Approval RoutesRoute::post('{$routeName}/{{$model}}/approve', [{$model}ApprovalController::class, 'approve']) ->name('{$model}.approve');Route::post('{$routeName}/{{$model}}/reject', [{$model}ApprovalController::class, 'reject']) ->name('{$model}.reject');PHP; File::append(base_path('routes/web.php'), $routes); $this->info("✓ Added approval routes to routes/web.php"); }}Step 3: Use Custom Generator
Bash
php artisan generate:approval-workflow PurchaseOrder \ --states=draft,pending,approved,rejected \ --domain=Procurement# Generates:# ✓ Base CRUD (via Thunderclap)# ✓ Migration with workflow columns# ✓ PurchaseOrderState enum# ✓ HasApprovalWorkflow trait added to model# ✓ PurchaseOrderApprovalController# ✓ PurchaseOrderApprovalRequested notification# ✓ Approval routesGenerator Hooks and Events
Listening to Thunderclap Events
PHP
<?phpnamespace App\Providers;use Illuminate\Support\ServiceProvider;use Laravolt\Thunderclap\Events\ModelGenerated;class AppServiceProvider extends ServiceProvider{ public function boot() { // Hook into model generation ModelGenerated::listen(function ($event) { $model = $event->model; $path = $event->path; // Auto-add to Git exec("git add {$path}"); // Log generation \Log::info("Generated model: {$model} at {$path}"); // Trigger IDE indexing (PHPStorm) exec("php artisan ide-helper:models {$model}"); }); }}Complete Example: Audit Log Generator
This generator creates a polymorphic audit log system for any model.
Step 1: Create Generator Command
Bash
php artisan make:command GenerateAuditLogStep 2: Implement Generator
PHP
<?phpnamespace App\Console\Commands;use Illuminate\Console\Command;use Illuminate\Support\Facades\File;class GenerateAuditLog extends Command{ protected $signature = 'generate:audit-log {model}'; protected $description = 'Add audit logging to a model'; public function handle() { $model = $this->argument('model'); // 1. Create audit_logs table if not exists if (!$this->auditTableExists()) { $this->createAuditMigration(); } // 2. Create AuditLog model if not exists if (!File::exists(app_path('Models/AuditLog.php'))) { $this->createAuditModel(); } // 3. Create Auditable trait if not exists if (!File::exists(app_path('Traits/Auditable.php'))) { $this->createAuditableTrait(); } // 4. Add trait to target model $this->addTraitToModel($model); $this->info("✅ Audit logging added to {$model}"); } protected function auditTableExists(): bool { return \Schema::hasTable('audit_logs'); } protected function createAuditMigration() { $timestamp = date('Y_m_d_His'); $filename = "database/migrations/{$timestamp}_create_audit_logs_table.php"; $stub = <<<'PHP'<?phpuse Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;return new class extends Migration{ public function up() { Schema::create('audit_logs', function (Blueprint $table) { $table->id(); $table->morphs('auditable'); $table->foreignId('user_id')->nullable()->constrained(); $table->string('event'); // created, updated, deleted $table->json('old_values')->nullable(); $table->json('new_values')->nullable(); $table->string('ip_address', 45)->nullable(); $table->string('user_agent')->nullable(); $table->timestamps(); $table->index(['auditable_type', 'auditable_id']); $table->index('event'); $table->index('created_at'); }); } public function down() { Schema::dropIfExists('audit_logs'); }};PHP; File::put(base_path($filename), $stub); $this->call('migrate'); $this->info("✓ Created audit_logs table"); } protected function createAuditModel() { $stub = <<<'PHP'<?phpnamespace App\Models;use Illuminate\Database\Eloquent\Model;class AuditLog extends Model{ protected $fillable = [ 'auditable_type', 'auditable_id', 'user_id', 'event', 'old_values', 'new_values', 'ip_address', 'user_agent', ]; protected $casts = [ 'old_values' => 'array', 'new_values' => 'array', ]; public function auditable() { return $this->morphTo(); } public function user() { return $this->belongsTo(User::class); }}PHP; File::put(app_path('Models/AuditLog.php'), $stub); $this->info("✓ Created AuditLog model"); } protected function createAuditableTrait() { File::ensureDirectoryExists(app_path('Traits')); $stub = <<<'PHP'<?phpnamespace App\Traits;use App\Models\AuditLog;trait Auditable{ protected static function bootAuditable() { static::created(function ($model) { $model->logAudit('created', null, $model->getAttributes()); }); static::updated(function ($model) { $model->logAudit('updated', $model->getOriginal(), $model->getChanges()); }); static::deleted(function ($model) { $model->logAudit('deleted', $model->getAttributes(), null); }); } public function auditLogs() { return $this->morphMany(AuditLog::class, 'auditable'); } protected function logAudit(string $event, ?array $oldValues, ?array $newValues) { AuditLog::create([ 'auditable_type' => get_class($this), 'auditable_id' => $this->id, 'user_id' => auth()->id(), 'event' => $event, 'old_values' => $oldValues, 'new_values' => $newValues, 'ip_address' => request()->ip(), 'user_agent' => request()->userAgent(), ]); }}PHP; File::put(app_path('Traits/Auditable.php'), $stub); $this->info("✓ Created Auditable trait"); } protected function addTraitToModel(string $model) { $modelPath = app_path("Models/{$model}.php"); if (!File::exists($modelPath)) { $this->error("Model not found: {$modelPath}"); return; } $content = File::get($modelPath); // Add trait use statement if (!str_contains($content, 'use App\Traits\Auditable;')) { $content = str_replace( "use Illuminate\Database\Eloquent\Model;", "use Illuminate\Database\Eloquent\Model;\nuse App\Traits\Auditable;", $content ); } // Add trait to class if (!str_contains($content, 'use Auditable;')) { $content = str_replace( "use HasFactory;", "use HasFactory, Auditable;", $content ); } File::put($modelPath, $content); $this->info("✓ Added Auditable trait to {$model}"); }}Step 3: Use Audit Log Generator
Bash
# Add audit logging to Product modelphp artisan generate:audit-log Product# Now all Product changes are automatically logged$product = Product::create(['name' => 'Widget', 'price' => 99.99]);// AuditLog created: event=created, new_values={name: Widget, price: 99.99}$product->update(['price' => 89.99]);// AuditLog created: event=updated, old_values={price: 99.99}, new_values={price: 89.99}$product->delete();// AuditLog created: event=deleted, old_values={...}// Query audit trail$product->auditLogs; // All changes$product->auditLogs()->where('event', 'updated')->get(); // Only updatesBest Practices
1. Keep Generators Focused
- One generator = one responsibility
- Compose multiple generators for complex workflows
- Avoid monolithic "generate everything" commands
2. Make Generators Idempotent
- Check if files exist before creating
- Use
--forceflag for overwrites - Provide clear feedback on what was skipped
3. Validate Input
- Check model exists before adding traits
- Validate state names, field types
- Provide helpful error messages
4. Document Generated Code
- Add docblocks explaining purpose
- Include usage examples in comments
- Link to relevant documentation
5. Test Generators
PHP
public function test_approval_workflow_generator(){ Artisan::call('generate:approval-workflow', [ 'model' => 'TestOrder', '--states' => 'pending,approved', ]); $this->assertFileExists(app_path('Models/TestOrder.php')); $this->assertFileExists(app_path('Enums/TestOrderState.php')); $this->assertFileExists(app_path('Http/Controllers/TestOrderApprovalController.php'));}Verification
Bash
# Test custom generatorphp artisan generate:approval-workflow Invoice --domain=Billing# Verify generated filesls -la app/Domains/Billing/Models/Invoice.phpls -la app/Domains/Billing/Enums/InvoiceState.phpls -la app/Domains/Billing/Http/Controllers/InvoiceApprovalController.php# Run migrationsphp artisan migrate# Test workflowphp artisan tinkerPHP
$invoice = App\Domains\Billing\Models\Invoice::create([ 'amount' => 1000, 'status' => 'pending',]);$invoice->approve(auth()->user(), 'Approved by manager');$invoice->status; // 'approved'$invoice->approved_at; // Carbon instance$invoice->approved_by; // User IDSummary
Custom Thunderclap generators enable:
- ✅ Domain-specific scaffolding (approval workflows, audit logs)
- ✅ Company-wide conventions enforcement
- ✅ Reduced boilerplate and repetition
- ✅ Consistent code structure across teams
- ✅ Faster onboarding for new developers
Next Steps:
- Identify repetitive patterns in your codebase
- Extract common logic into reusable generators
- Document generator usage in team wiki
- Share generators across projects via Composer package