Implementing Event-Driven Architecture for Async PHP SaaS AI Applications
This article shows how to use Laravel’s event‑driven architecture—events, listeners, and queue jobs—to decouple slow AI calls in a SaaS product, delivering instant HTTP responses, background processing, real‑time WebSocket updates, robust retry/failure handling, and fast, isolated testing.
When a user submits a complex prompt to an AI chat application, waiting 15 seconds for a response creates a poor experience. Treating the AI call as a normal HTTP request blocks the web server, load balancer, and browser, and can trigger time‑outs because large‑model calls may take 2–30 seconds.
Why Event‑Driven Architecture (EDA) solves the problem
EDA replaces synchronous waiting with message‑based communication. In Laravel an Event notifies interested Listeners , which can dispatch a Queue Job that runs independently of the original HTTP request. This decouples the slow AI operation from the user‑facing request.
Core components
Event : a notification that something happened, e.g. PromptSubmitted or AiResponseGenerated.
Listener : code that reacts to a specific event; one event may have many listeners.
Queue Job : a background task that performs the heavy AI call without blocking the request.
Parallel execution flows
Synchronous flow (fast response): the HTTP request writes the prompt to the database, fires PromptSubmitted, and immediately returns a 201 response so the UI does not wait for the AI.
Asynchronous flow (background processing): the PromptSubmitted event triggers a listener ( DispatchAiProviderJob) that dispatches CallAiProviderJob. The job calls the AI provider (2–30 s), then fires AiResponseGenerated. Three listeners handle the generated response: store it in the database, broadcast it via WebSocket, and record token usage for billing.
Code implementation
Step 1 – Define the event :
namespace App\Domain\Chat\Events;
use App\Domain\Chat\Models\Conversation;
use App\Domain\Chat\Models\Message;
class PromptSubmitted {
public function __construct(public readonly Conversation $conversation, public readonly Message $message) {}
}Step 2 – Listener that dispatches the queue job :
namespace App\Domain\Chat\Listeners;
use App\Domain\Chat\Events\PromptSubmitted;
use App\Domain\Chat\Jobs\CallAiProviderJob;
class DispatchAiProviderJob {
public function handle(PromptSubmitted $event): void {
CallAiProviderJob::dispatch($event->conversation, $event->message);
}
}Step 3 – Queue job that calls the AI provider (implements ShouldQueue and defines retry/back‑off):
namespace App\Domain\Chat\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use App\Domain\Chat\Models\Conversation;
use App\Domain\Chat\Models\Message;
use App\Domain\Chat\Events\AiResponseGenerated;
class CallAiProviderJob implements ShouldQueue {
public int $tries = 3;
public int $backoff = 5;
public function __construct(public readonly Conversation $conversation, public readonly Message $message) {}
public function handle(AiProviderRouterService $router): void {
$provider = $router->resolveFor($this->conversation->tenant);
$response = $provider->complete(conversation: $this->conversation, prompt: $this->message);
event(new AiResponseGenerated($this->conversation, $response));
}
}Step 4 – Multiple listeners for the AI response event (store, broadcast, billing):
// Save to DB
class SaveAiResponseToDatabase {
public function handle(AiResponseGenerated $event): void {
$event->conversation->messages()->create([
'role' => 'assistant',
'content' => $event->response->content,
]);
}
}
// Broadcast via WebSocket
class BroadcastAiResponseToFrontend {
public function handle(AiResponseGenerated $event): void {
broadcast(new AiResponseReady($event->conversation->id, $event->response->content))->toOthers();
}
}
// Record token usage for billing
class RecordTokenUsageForBilling {
public function handle(AiResponseGenerated $event): void {
RecordUsageAction::run($event->conversation->tenant, $event->response->tokenUsage);
}
}Failure handling
Laravel’s queue system provides automatic retries. The job distinguishes unretryable errors (e.g., content‑policy violations) from transient ones (e.g., time‑outs). Unretryable errors call $this->fail($e), while time‑outs are re‑thrown to let the queue retry. After all retries are exhausted, a AiResponseFailed event notifies the user.
Testing without real AI calls
Using Event::fake() and Bus::fake() prevents actual listeners and jobs from running. Tests can assert that the correct events and jobs are dispatched and that they carry the expected data (prompt content, token usage) without contacting an external AI service.
Common pitfalls
Attaching too many listeners to a single event makes the flow hard to trace; consolidate related logic.
Ignoring idempotency in retryable jobs can create duplicate records; use updateOrCreate instead of create where appropriate.
Failing to notify the user after a job gives up leads to a silent dead‑end.
Using events for strictly ordered processes; if later steps depend on earlier results, keep the sequence in a single action rather than separate events.
Directory layout (example)
app/
Domain/
Chat/
Events/
PromptSubmitted.php
AiResponseGenerated.php
AiResponseFailed.php
Listeners/
DispatchAiProviderJob.php
SaveAiResponseToDatabase.php
BroadcastAiResponseToFrontend.php
RecordTokenUsageForBilling.php
Jobs/
CallAiProviderJob.phpConclusion
If you can adopt only one architecture from the series, choose event‑driven architecture. It keeps the user interface responsive while heavy AI processing runs in the background, and it integrates naturally with Laravel’s testing tools and queue‑based retry mechanisms.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Open Source Tech Hub
Sharing cutting-edge internet technologies and practical AI resources.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
