Documentation

Event-driven AI with Laravel

Receive AI webhooks in Laravel controllers, process with queued jobs, and call back to ModelRiver: leveraging Laravel's queue and event system.

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-app
cd my-ai-app

Environment variables

Bash
# .env
MODELRIVER_API_KEY=mr_live_YOUR_API_KEY
MODELRIVER_WEBHOOK_SECRET=your_webhook_secret

Configuration

PHP
1// config/services.php
2return [
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.php
2<?php
3 
4namespace App\Http\Middleware;
5 
6use Closure;
7use Illuminate\Http\Request;
8 
9class VerifyModelRiverSignature
10{
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.php
2<?php
3 
4namespace App\Http\Controllers;
5 
6use App\Jobs\ProcessAiWebhook;
7use Illuminate\Http\Request;
8use Illuminate\Http\JsonResponse;
9 
10class ModelRiverWebhookController extends Controller
11{
12 public function handle(Request $request): JsonResponse
13 {
14 $payload = $request->all();
15 $type = $payload['type'] ?? '';
16 $callbackUrl = $payload['callback_url'] ?? null;
17 
18 // Handle event-driven workflow
19 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 webhook
31 logger()->info('Standard webhook received', ['type' => $type]);
32 return response()->json(['received' => true]);
33 }
34}

Routes

PHP
1// routes/api.php
2use App\Http\Controllers\ModelRiverWebhookController;
3 
4Route::post('/webhooks/modelriver', [ModelRiverWebhookController::class, 'handle'])
5 ->middleware('verify.modelriver');

Queued job

PHP
1// app/Jobs/ProcessAiWebhook.php
2<?php
3 
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 ShouldQueue
15{
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(): void
29 {
30 try {
31 $enrichedData = $this->aiResponse['data'] ?? [];
32 
33 // Your custom business logic
34 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 service
48 $enrichedData['reviewed'] = true;
49 $enrichedData['reviewed_at'] = now()->toISOString();
50 }
51 
52 // Call back to ModelRiver
53 $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 callback
71 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.php
2<?php
3 
4namespace App\Services;
5 
6use Illuminate\Support\Facades\Http;
7 
8class ModelRiverService
9{
10 public function triggerAsync(string $workflow, string $prompt, array $metadata = []): array
11 {
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 controller
28public 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

  1. Use queued jobs: Never block the webhook response with heavy processing.
  2. Set $tries and $backoff: Handle transient failures with retries.
  3. Use Laravel's HTTP client: Built-in retry, timeout, and error handling.
  4. Register middleware: Verify signatures before the controller is invoked.
  5. Use Laravel events: Emit events for extensibility (WebhookReceived, CallbackSent).

Next steps