Inertia.js was built around a simple contract: a request comes in, the server returns a component name and a fresh set of props, and the client swaps them in. That contract breaks down the moment you need to stream an LLM response token by token. A full prop replacement can’t represent “text that’s still arriving,” and polling for partial state is slow and wasteful. If you’re building Laravel Inertia AI streaming into a Vue or React app, you need a second channel running alongside Inertia’s normal request cycle, not a replacement for it. Server-Sent Events is that channel: a single long-lived HTTP connection carrying token deltas straight into local component state, with Inertia left alone to do what it already does well.
This runbook covers that pattern end to end: an SSE controller talking to the Anthropic API, a Vue 3 component buffering the stream, and a way to safely render structured UI payloads the model streams back, not just raw text.
Where SSE Fits in an Inertia App
Before wiring anything up, it’s worth being clear about what SSE is replacing and what it isn’t. Inertia still owns page navigation and your normal CRUD props. SSE owns exactly one thing: the open connection that carries token deltas from Laravel to the browser while a completion is in flight. Once the stream ends, you’re back to Inertia’s normal world, syncing the finished message back to the server on your own terms.
The diagram below shows the three hops: the Vue client opens a fetch stream against a Laravel controller, the controller opens its own stream against Anthropic, and tokens flow back down the same path they came in on.
This sits within the broader question of real-time AI UX in Laravel: once you’ve picked a transport, everything downstream, state management, reconnection handling, generative UI, has to be built around that choice rather than bolted on afterward. Choosing SSE here isn’t the only option, either. If you need bidirectional communication, the client pushing state back mid-stream rather than just receiving, WebSockets are the better fit. We’ve laid out the full decision matrix in our breakdown of Livewire, SSE, and WebSockets as AI streaming transports, and the short version holds here too: for unidirectional server-to-client token delivery, SSE gives you the lowest infrastructure cost for the smallest amount of new surface area.
[Word to the Wise] Don’t reach for a WebSocket server just because streaming feels like a “real-time” problem. Most AI chat UIs are server-to-client only. Standing up Reverb for that is solving a problem you don’t have yet.
There’s a second reason this split works well specifically with Inertia: Inertia already assumes the server is the source of truth for page state, and SSE doesn’t fight that assumption, it just defers to it. The stream is a temporary, client-only buffer. Nothing about the streamed text touches Inertia’s page props until the response is finished and you deliberately persist it, typically with a normal useForm submission or an Inertia partial reload once commitStreamToHistory fires. That boundary is worth keeping strict. The moment you start pushing intermediate tokens into Inertia’s shared page state, you reintroduce the exact full-prop-replacement overhead this pattern exists to avoid.
Server-Side Implementation: SSE Controller and the Anthropic PHP SDK
The controller’s job is narrow: validate the incoming prompt, open a streamed HTTP response, and re-emit each token from Anthropic’s stream as its own SSE event. We inject the Anthropic client through Laravel’s Service Container rather than instantiating it inline, which keeps the API key out of the controller and makes the client swappable in tests. The stream event types and exception hierarchy used below come straight from Anthropic’s official PHP SDK documentation, not a community wrapper, worth checking directly if you’re pinning a different SDK version than the one this was written against.
[Architect’s Note] This article calls the Anthropic PHP SDK directly instead of going through
laravel/ai. That’s a deliberate exception, not the default.laravel/ai‘s streaming abstraction is built around returning a complete response to a normal Inertia prop cycle, it doesn’t give you a raw event stream you can re-shape and multiplex with your own custom event types (as this article does for structured UI payloads later on). If your use case is “stream Claude’s text and nothing else,” uselaravel/aiand skip this section entirely. Reach for the raw SDK only when you need to control the wire format of the SSE events yourself.
namespace App\Http\Controllers;
use Anthropic\Client;
use Anthropic\Core\Exceptions\APIConnectionException;
use Anthropic\Core\Exceptions\APIStatusException;
use Anthropic\Core\Exceptions\RateLimitException;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AIStreamingController extends Controller
{
public function __construct(
protected Client $anthropic
) {}
public function stream(Request $request): StreamedResponse
{
$validated = $request->validate([
'prompt' => ['required', 'string', 'max:2000'],
'conversation_id' => ['nullable', 'string'],
]);
// Release the session lock before opening a long-lived connection,
// otherwise every other request from this user queues behind it.
if ($request->hasSession()) {
$request->session()->save();
}
return response()->stream(function () use ($validated) {
while (ob_get_level() > 0) {
ob_end_clean();
}
try {
$stream = $this->anthropic->messages->createStream(
maxTokens: 2048,
messages: [
['role' => 'user', 'content' => $validated['prompt']],
],
model: 'claude-sonnet-5',
);
foreach ($stream as $event) {
if (connection_aborted()) {
break;
}
if ($event->type === 'content_block_delta' && isset($event->delta->text)) {
$this->emit('token', ['content' => $event->delta->text]);
}
}
if (!connection_aborted()) {
$this->emit('done', []);
}
} catch (RateLimitException $e) {
// The SDK already retries 429s twice with backoff before this
// is ever thrown. If we're here, the caller needs to actually wait.
$this->emit('error', [
'code' => 'rate_limited',
'message' => 'The model is at capacity. Try again shortly.',
]);
} catch (APIConnectionException $e) {
$this->emit('error', [
'code' => 'connection_failed',
'message' => 'Could not reach the model provider.',
]);
} catch (APIStatusException $e) {
$this->emit('error', [
'code' => 'provider_error',
'message' => 'The model provider returned an error.',
]);
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache, no-transform',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no',
]);
}
protected function emit(string $type, array $payload): void
{
if (connection_aborted()) {
return;
}
echo 'data: ' . json_encode(array_merge(['type' => $type], $payload)) . "\n\n";
flush();
}
}
[Production Pitfall] Setting
X-Accel-Buffering: nois not optional if you’re behind Nginx or Forge. Without it, Nginx buffers the response into 4KB chunks before releasing it to the browser, so instead of smooth token-by-token output you get bursts of text arriving in clumps every few seconds. It’s the single most common reason “my SSE stream works locally but not in production.”
The SDK’s own retry logic already handles transient failures (connection errors, 429s, 5xx responses) with exponential backoff before ever raising an exception, twice by default. Catching RateLimitException separately here isn’t redundant work, it’s the difference between the client silently hanging for a few seconds during that internal retry and the client getting a specific, actionable error if the backoff genuinely exhausts. If you’re already tracking spend across providers, tie this into your existing AI middleware for token tracking and rate limiting rather than building a second, parallel rate-limit system just for this endpoint.
[Efficiency Gain] Each open SSE connection ties up a PHP-FPM worker for the full duration of the stream, the same way a long-running queue worker holds a process. Budget your
pm.max_childrenaccordingly, or move this endpoint to Octane if concurrent streams climb past a handful.
Testing the Streaming Endpoint Without Hitting the Real API
Don’t skip test coverage on this controller just because it’s stream-shaped. The SDK ships a fake client built for exactly this, so you can assert on the request without opening a real connection or spending real tokens:
use Anthropic\Testing\ClientFake;
use Anthropic\Responses\Messages\CreateResponse;
it('validates the prompt before calling the model', function () {
$fake = new ClientFake([
CreateResponse::fake(['content' => [['type' => 'text', 'text' => 'stubbed reply']]]),
]);
$this->app->instance(Client::class, $fake);
$this->postJson('/streaming-ux/stream', ['prompt' => ''])
->assertStatus(422);
});
Bind the fake through the Service Container in your test setup rather than mocking the HTTP layer directly. It keeps the test tied to the SDK’s actual method signatures, so a future SDK upgrade that renames a parameter fails your test suite instead of failing silently in production.
Client-Side State Orchestration in Vue 3
On the client, the goal is to buffer incoming tokens in local reactive state and leave Inertia’s page state untouched until the stream finishes. We use the Fetch API’s stream reader rather than EventSource, because EventSource can’t send a POST body or custom headers, and we need both for the prompt and the CSRF token.
<script setup>
import { ref, onUnmounted } from 'vue';
import { useForm } from '@inertiajs/vue3';
const props = defineProps({
initialHistory: {
type: Array,
default: () => [],
},
});
const promptForm = useForm({ prompt: '' });
const isStreaming = ref(false);
const activeStreamContent = ref('');
const localHistory = ref([...props.initialHistory]);
const streamError = ref(null);
let abortController = null;
const startStream = async () => {
if (!promptForm.prompt || isStreaming.value) return;
const userMessage = promptForm.prompt;
localHistory.value.push({ role: 'user', content: userMessage });
promptForm.prompt = '';
isStreaming.value = true;
activeStreamContent.value = '';
streamError.value = null;
abortController = new AbortController();
try {
const response = await fetch('/streaming-ux/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
body: JSON.stringify({ prompt: userMessage }),
signal: abortController.signal,
});
if (!response.ok || !response.body) {
throw new Error(`HTTP error: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.replace(/\r\n/g, '\n').split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data: ')) continue;
try {
const data = JSON.parse(trimmed.replace(/^data:\s*/, ''));
if (data.type === 'token') {
activeStreamContent.value += data.content;
} else if (data.type === 'component') {
commitComponentToHistory(data);
} else if (data.type === 'done') {
commitStreamToHistory();
} else if (data.type === 'error') {
streamError.value = data.message;
resetStreamState();
}
} catch (parseError) {
console.warn('Malformed SSE payload, skipping frame:', trimmed);
}
}
}
// The connection can close without an explicit "done" frame if the
// server drops mid-flush. Commit whatever we already buffered.
if (isStreaming.value && activeStreamContent.value) {
commitStreamToHistory();
}
} catch (err) {
if (err.name !== 'AbortError') {
streamError.value = 'Connection lost. Your partial response was saved.';
}
resetStreamState();
}
};
const commitStreamToHistory = () => {
if (activeStreamContent.value) {
localHistory.value.push({ role: 'assistant', content: activeStreamContent.value });
}
resetStreamState();
};
const commitComponentToHistory = (data) => {
localHistory.value.push({ role: 'assistant', component: data.name, props: data.props });
};
const resetStreamState = () => {
isStreaming.value = false;
activeStreamContent.value = '';
abortController = null;
};
onUnmounted(() => {
abortController?.abort();
});
</script>
[Edge Case Alert] The
buffer.replace(/\r\n/g, '\n')line matters more than it looks. Some proxies and load balancers normalize line endings inconsistently between hops, and SSE’s\n\nframe delimiter breaks silently if you’re splitting on the wrong sequence. You’ll see this as messages that arrive complete locally but truncate randomly in staging behind a different proxy layer.
Notice the fallback commit after the read loop: if the connection drops mid-stream without an explicit done frame (a proxy timeout, a dropped mobile connection), we still commit whatever text arrived rather than discarding it. This is exactly the failure mode covered in more depth in our guide to building fail-safes for incomplete LLM responses in Laravel Echo: a partial answer displayed honestly beats a blank screen or a silent retry that duplicates the user’s spend.
Streaming Generative UI Payloads
Raw text covers a chat window. It doesn’t cover a model that wants to hand back a structured table or an action card. For that, the controller emits a second event type, component, alongside token, carrying a component name and its props as JSON. The client resolves that name against an explicit allowlist before rendering anything.
<!-- resources/js/Components/AI/GenerativeRenderer.vue -->
<script setup>
import { computed } from 'vue';
import MetricsTable from './Generative/MetricsTable.vue';
import ActionCard from './Generative/ActionCard.vue';
const props = defineProps({
name: { type: String, required: true },
componentProps: { type: Object, default: () => ({}) },
});
const registry = {
MetricsTable,
ActionCard,
};
const resolved = computed(() => registry[props.name] ?? null);
</script>
<template>
<component :is="resolved" v-bind="componentProps" v-if="resolved" />
<div v-else class="text-xs font-mono text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-3">
Unregistered component: {{ name }}
</div>
</template>
[Production Pitfall] Never let a streamed payload reach
v-htmlor a dynamic function constructor. The registry above is the entire security model here: the model can only ever trigger components you’ve explicitly written, reviewed, and pre-compiled. If thenameisn’t in the map, it renders nothing executable, just a visible fallback.
[Edge Case Alert] A
componentevent and atokenevent can legitimately interleave in the same stream if the model narrates before or after emitting structured output. Commit them to history as separate entries in order, don’t try to merge a component into the middle of a text buffer.
Routing and Laravel 13 Configuration
Register the endpoint in routes/web.php with rate limiting applied inline:
use App\Http\Controllers\AIStreamingController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth', 'throttle:30,1'])->group(function () {
Route::post('/streaming-ux/stream', [AIStreamingController::class, 'stream'])
->name('ai.stream');
});
Confirm your global middleware stack in bootstrap/app.php isn’t buffering or terminating long-lived connections. Laravel 13 has no app/Http/Kernel.php to check anymore, so this is the only place that stack gets configured; see Laravel’s own documentation on the application structure if you’re coming from a pre-11 codebase and the bootstrap file layout is unfamiliar.
Reconnection is the piece most implementations skip entirely. The fetch reader above detects a dropped connection through its own catch block, but it doesn’t automatically retry the request, and for a stateful chat message that’s usually the right call: silently re-sending the same prompt can produce a duplicate charge against your API spend if the first request actually completed server-side before the network dropped. Surface the failure to the user with the partial content still visible, as this implementation does through streamError, and let them explicitly resend if they want to. If you need automatic reconnection with delivery guarantees baked in rather than handled by hand, that’s a strong signal you’ve outgrown raw SSE and should look at Laravel Reverb’s token-by-token WebSocket delivery instead, which tracks connection state as a first-class concern rather than something you reconstruct from a fetch reader.
Evaluating the Architecture
| Metric | SSE + Inertia Local State | Inertia Partial Reloads | WebSockets (Laravel Reverb) |
|---|---|---|---|
| Network overhead | Low, single HTTP connection | High, repeated round trips | Very low, persistent socket |
| Server concurrency cost | Moderate, needs non-blocking PHP | High, requests are short-lived | Very high, event-loop server |
| Implementation complexity | Low, native browser APIs | Low, framework native | Moderate, needs a socket server |
| Directionality | Server to client only | Client-request driven | Bidirectional |
| State persistence | Vue reactivity, gone on reload | Laravel session or cache | Externalized in socket state |
Wrapping Up
None of these three transports is universally correct. SSE wins here specifically because AI text generation is unidirectional and this app doesn’t already need a socket server for anything else, if it did, the marginal cost of routing streaming through Reverb instead would probably be lower than running two real-time systems side by side. What matters more than the transport choice itself is treating the stream as genuinely unreliable: buffer defensively, commit partial output rather than discarding it, and never let structured payloads execute without going through an explicit allowlist first. Get those three things right and the specific transport underneath becomes a much lower-stakes decision.
Frequently Asked Questions
How does SSE streaming with Inertia.js differ from a normal Inertia visit?
A normal Inertia visit expects one complete JSON response containing the page component and its props. SSE instead opens a persistent text/event-stream connection so the server can push incremental chunks. The client buffers those chunks in local Vue state and only syncs back to Inertia’s own state once the stream finishes, Inertia’s page cycle is never involved mid-stream.
Why use SSE instead of WebSockets for AI streaming in Laravel?
AI text completion is one-directional: the server pushes tokens, the client doesn’t need to push anything back mid-generation. SSE runs over plain HTTP with no dedicated socket server to operate, while WebSockets via Laravel Reverb earn their complexity when you need bidirectional communication or you’re already running Reverb for something else.
How do I stop Nginx from buffering the stream?
Set X-Accel-Buffering: no on the StreamedResponse headers, and flush output explicitly in the controller loop with ob_end_clean() and flush(). Without both, Nginx holds the response in 4KB chunks and tokens arrive in delayed bursts instead of a smooth stream.
How do I safely render structured components the model streams back?
Never pass streamed content into v-html or a dynamic function constructor. Emit a distinct component event type carrying a name and a props object, then resolve that name against an explicit client-side registry of pre-compiled components. Unknown names render a visible fallback instead of executing anything.
What happens if the connection drops mid-stream?
The controller’s catch block emits a structured error event with a specific code before the stream ends. On the client, the fetch reader detects the closed connection, halts the loading state, and commits whatever partial text was already buffered rather than discarding it, the same recovery pattern covered in the fail-safes guide linked above.
A software architect with 15+ years of experience in the PHP and Laravel ecosystem. Dewald created Origin Main to provide the engineering rigour required to integrate AI into professional, high-concurrency production systems. He writes for developers who care less about "getting it to work" and more about "getting it to last".

