Broadcasting LLM token streams through Laravel Echo feels elegant right up until the moment it silently dies halfway through a response. No error. No terminal event. Just a client sitting there, waiting for tokens that will never arrive.
We hit this in production during a multi-user document generation feature. The Pusher connection degraded during a particularly long Anthropic response. The queue job had no idea the client had disconnected, and the user stared at a spinner for three minutes before refreshing. The partial response was gone. No retry surface. No recovery path. The incident report had three action items and none of them were obvious beforehand.
That experience shaped the *Laravel LLM streaming fail-safe* architecture this article covers. Every pattern below is oriented toward the specific failure modes that broadcasting-based LLM streams introduce, and in several cases those failure modes are different from what you encounter with SSE.
One framing note before we start. Laravel Echo names a client-side JavaScript library for subscribing to broadcast channels. It is not an SSE wrapper. The architecture here is: a queued job calls the LLM API, iterates the stream, and broadcasts each token as a private channel event. Echo subscribes on the client. This pattern earns its weight when you need multiple subscribers on the same stream (team collaboration, agent monitoring, admin oversight), or when you are already running Reverb or Pusher in your stack. covers private channel authorization in full, but the event structure below is what drives the client-side recovery logic.
<?php
namespace App\Events;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
class LlmTokenReceived implements ShouldBroadcast
{
public function __construct(
public readonly string $streamId,
public readonly string $token,
public readonly int $sequence,
public readonly string $status, // 'streaming' | 'complete' | 'truncated' | 'error' | 'dead'
public readonly ?string $finishReason = null,
) {}
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel("stream.{$this->streamId}");
}
public function broadcastAs(): string
{
return 'token.received';
}
public function broadcastWith(): array
{
return [
'stream_id' => $this->streamId,
'token' => $this->token,
'sequence' => $this->sequence,
'status' => $this->status,
'finish_reason' => $this->finishReason,
];
}
}
The status field carries the terminal state explicitly. complete means the LLM finished naturally (end_turn). truncated means it hit a token limit. error and dead mean infrastructure or retry failure. The client does not guess. The server tells it.
The database layer makes recovery possible at all:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('llm_streams', function (Blueprint $table) {
$table->id();
$table->string('stream_id')->unique();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('prompt');
$table->enum('status', ['pending', 'streaming', 'complete', 'truncated', 'error', 'dead'])
->default('pending');
$table->longText('partial_content')->nullable();
$table->longText('final_content')->nullable();
$table->unsignedInteger('last_sequence')->default(0);
$table->string('finish_reason')->nullable();
$table->text('error_message')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('last_checkpoint_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('failed_at')->nullable();
$table->timestamps();
$table->index(['status', 'last_checkpoint_at']); // orphan detection
$table->index(['user_id', 'status']);
});
}
};
Server-Side Fail-Safes
The queue job carries most of the server-side protection. Three responsibilities: stream tokens and broadcast them with sequence numbers, write periodic checkpoints so recovery is possible, and guarantee a terminal broadcast event regardless of how the job ends.
The , the retry endpoint becomes an unauthenticated job dispatch surface. Sanctum’s API token middleware is the right guard here, not session auth, because the retry call typically originates from JavaScript, not a form submission.
The Service Layer Is Not This Job’s Problem
The job above handles the transport layer. It does not handle token budget management, provider fallback, cost attribution, or prompt construction. Those belong one layer up.
If you are building this into a larger system, the belongs at the service layer. A truncated JSON response is harder to recover from than truncated prose. Catching a max_tokens truncation in a partial JSON structure before it reaches your client is far cheaper than handling the parse error downstream.
Monitoring What Actually Matters
Before going live, instrument three metrics from your llm_streams table:
Stream completion rate. status = 'complete' divided by total streams initiated, measured hourly. Below 95% in production warrants investigation. Below 90% is an incident.
Orphan rate. Streams reaped by the Artisan command as a percentage of total streams. A spike here usually correlates with deployment restarts or queue worker OOM events. Check your Horizon metrics or system logs alongside it.
Gap frequency. Log the gaps array from client-side onComplete callbacks via a lightweight analytics endpoint. Persistent gaps from specific geographic regions point to Pusher or Reverb delivery problems, not LLM API issues. The distinction matters: one is your infrastructure, the other is your vendor’s.
Connecting these to Laravel’s AI middleware for token tracking gives you cost visibility alongside reliability metrics. That combination is what you need to have an honest conversation about whether Echo-based streaming is worth its operational complexity for your specific use case.
SOCIAL SHARE CARD GENERATOR