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 Hooks

Key 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.php for paths and namespaces

Custom Stub Templates

Step 1: Publish Default Stubs

Bash
php artisan vendor:publish --tag=thunderclap-stubs

This 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.stub

Step 2: Customize Model Stub

PHP
// resources/stubs/thunderclap/model.stub
<?php
namespace {{ namespace }};
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use App\Traits\Auditable; // Custom trait
class {{ 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.php
return [
'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 docblock

Creating 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 GenerateApprovalWorkflow

Step 2: Implement Generator Logic

PHP
<?php
namespace 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
<?php
use 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
<?php
namespace {$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
<?php
namespace {$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 Routes
Route::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 routes

Generator Hooks and Events

Listening to Thunderclap Events

PHP
<?php
namespace 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 GenerateAuditLog

Step 2: Implement Generator

PHP
<?php
namespace 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'
<?php
use 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'
<?php
namespace 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'
<?php
namespace 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 model
php 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 updates

Best 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 --force flag 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 generator
php artisan generate:approval-workflow Invoice --domain=Billing
# Verify generated files
ls -la app/Domains/Billing/Models/Invoice.php
ls -la app/Domains/Billing/Enums/InvoiceState.php
ls -la app/Domains/Billing/Http/Controllers/InvoiceApprovalController.php
# Run migrations
php artisan migrate
# Test workflow
php artisan tinker
PHP
$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 ID

Summary

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:

  1. Identify repetitive patterns in your codebase
  2. Extract common logic into reusable generators
  3. Document generator usage in team wiki
  4. Share generators across projects via Composer package
Previous
API integration