Advanced patterns
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
<?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 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
<?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 routesGenerator 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 GenerateAuditLogStep 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 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 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 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