Overview
Laravel's powerful queue system, middleware pipeline, and HTTP client make it ideal for processing ModelRiver event-driven AI webhooks. Dispatch background jobs for heavy processing while responding instantly to the webhook.
What you'll build:
- A webhook controller with signature verification middleware
- Queued jobs for async processing and callback
- HTTP client integration for ModelRiver callbacks
- Event/listener pattern for extensibility
Quick start
Install dependencies
Bash
composer create-project laravel/laravel my-ai-appcd my-ai-appEnvironment variables
Bash
# .envMODELRIVER_API_KEY=mr_live_YOUR_API_KEYMODELRIVER_WEBHOOK_SECRET=your_webhook_secretConfiguration
PHP
1// config/services.php2return [3 // ...4 'modelriver' => [5 'api_key' => env('MODELRIVER_API_KEY'),6 'webhook_secret' => env('MODELRIVER_WEBHOOK_SECRET'),7 'base_url' => 'https://api.modelriver.com',8 ],9];Signature verification middleware
PHP
1// app/Http/Middleware/VerifyModelRiverSignature.php2<?php3 4namespace App\Http\Middleware;5 6use Closure;7use Illuminate\Http\Request;8 9class VerifyModelRiverSignature10{11 public function handle(Request $request, Closure $next)12 {13 $signature = $request->header('mr-signature', '');14 $secret = config('services.modelriver.webhook_secret');15 16 $expected = hash_hmac('sha256', $request->getContent(), $secret);17 18 if (!hash_equals($expected, $signature)) {19 return response()->json(['error' => 'Invalid signature'], 401);20 }21 22 return $next($request);23 }24}Register the middleware:
PHP
1// bootstrap/app.php (Laravel 11+)2->withMiddleware(function (Middleware $middleware) {3 $middleware->alias([4 'verify.modelriver' => \App\Http\Middleware\VerifyModelRiverSignature::class,5 ]);6})Webhook controller
PHP
1// app/Http/Controllers/ModelRiverWebhookController.php2<?php3 4namespace App\Http\Controllers;5 6use App\Jobs\ProcessAiWebhook;7use Illuminate\Http\Request;8use Illuminate\Http\JsonResponse;9 10class ModelRiverWebhookController extends Controller11{12 public function handle(Request $request): JsonResponse13 {14 $payload = $request->all();15 $type = $payload['type'] ?? '';16 $callbackUrl = $payload['callback_url'] ?? null;17 18 // Handle event-driven workflow19 if ($type === 'task.ai_generated' && $callbackUrl) {20 ProcessAiWebhook::dispatch(21 event: $payload['event'] ?? '',22 aiResponse: $payload['ai_response'] ?? [],23 callbackUrl: $callbackUrl,24 customerData: $payload['customer_data'] ?? [],25 );26 27 return response()->json(['received' => true]);28 }29 30 // Standard webhook31 logger()->info('Standard webhook received', ['type' => $type]);32 return response()->json(['received' => true]);33 }34}Routes
PHP
1// routes/api.php2use App\Http\Controllers\ModelRiverWebhookController;3 4Route::post('/webhooks/modelriver', [ModelRiverWebhookController::class, 'handle'])5 ->middleware('verify.modelriver');Queued job
PHP
1// app/Jobs/ProcessAiWebhook.php2<?php3 4namespace App\Jobs;5 6use Illuminate\Bus\Queueable;7use Illuminate\Contracts\Queue\ShouldQueue;8use Illuminate\Foundation\Bus\Dispatchable;9use Illuminate\Queue\InteractsWithQueue;10use Illuminate\Queue\SerializesModels;11use Illuminate\Support\Facades\Http;12use Illuminate\Support\Facades\Log;13 14class ProcessAiWebhook implements ShouldQueue15{16 use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;17 18 public int $tries = 3;19 public int $backoff = 10;20 21 public function __construct(22 public string $event,23 public array $aiResponse,24 public string $callbackUrl,25 public array $customerData,26 ) {}27 28 public function handle(): void29 {30 try {31 $enrichedData = $this->aiResponse['data'] ?? [];32 33 // Your custom business logic34 if ($this->event === 'content_ready') {35 $content = \App\Models\Content::create([36 'title' => $enrichedData['title'] ?? '',37 'body' => $enrichedData['description'] ?? '',38 'category' => $this->customerData['category'] ?? 'general',39 ]);40 41 $enrichedData['id'] = $content->id;42 $enrichedData['slug'] = $content->slug;43 $enrichedData['saved_at'] = now()->toISOString();44 }45 46 if ($this->event === 'review_complete') {47 // Call external service48 $enrichedData['reviewed'] = true;49 $enrichedData['reviewed_at'] = now()->toISOString();50 }51 52 // Call back to ModelRiver53 $response = Http::withHeaders([54 'Authorization' => 'Bearer ' . config('services.modelriver.api_key'),55 ])->timeout(10)->post($this->callbackUrl, [56 'data' => $enrichedData,57 'task_id' => "laravel_{$this->event}_" . now()->timestamp,58 'metadata' => [59 'processed_by' => 'laravel',60 'processed_at' => now()->toISOString(),61 ],62 ]);63 64 $response->throw();65 Log::info("✅ Callback sent for event: {$this->event}");66 67 } catch (\Exception $e) {68 Log::error("❌ Callback failed: {$e->getMessage()}");69 70 // Send error callback71 Http::withHeaders([72 'Authorization' => 'Bearer ' . config('services.modelriver.api_key'),73 ])->timeout(10)->post($this->callbackUrl, [74 'error' => 'processing_failed',75 'message' => $e->getMessage(),76 ]);77 78 throw $e;79 }80 }81}Triggering async requests
PHP
1// app/Services/ModelRiverService.php2<?php3 4namespace App\Services;5 6use Illuminate\Support\Facades\Http;7 8class ModelRiverService9{10 public function triggerAsync(string $workflow, string $prompt, array $metadata = []): array11 {12 $response = Http::withHeaders([13 'Authorization' => 'Bearer ' . config('services.modelriver.api_key'),14 ])->post(config('services.modelriver.base_url') . '/v1/ai/async', [15 'workflow' => $workflow,16 'messages' => [17 ['role' => 'user', 'content' => $prompt],18 ],19 'metadata' => $metadata,20 ]);21 22 $response->throw();23 return $response->json();24 }25}26 27// Usage in a controller28public function generate(Request $request, ModelRiverService $modelriver)29{30 $result = $modelriver->triggerAsync(31 workflow: 'content_generator',32 prompt: $request->input('prompt'),33 metadata: ['user_id' => auth()->id()],34 );35 36 return response()->json([37 'channel_id' => $result['channel_id'],38 'ws_token' => $result['ws_token'],39 'websocket_channel' => $result['websocket_channel'],40 ]);41}Best practices
- Use queued jobs: Never block the webhook response with heavy processing.
- Set
$triesand$backoff: Handle transient failures with retries. - Use Laravel's HTTP client: Built-in retry, timeout, and error handling.
- Register middleware: Verify signatures before the controller is invoked.
- Use Laravel events: Emit events for extensibility (
WebhookReceived,CallbackSent).
Next steps
- Rails event-driven guide: Ruby alternative
- Webhooks reference: Retry policies and delivery monitoring
- Event-driven AI overview: Architecture and flow