Streaming tokens is the easy demo. Shipping a chat UI that feels native inside a Laravel product is the hard part.
Most teams get the first 20 percent working fast: call a model, stream text, print it into a box. Then the UX starts breaking in ways users notice immediately. The scroll jumps while they are reading. Stop does not really stop. A failed request leaves a half-answer that looks finished. Retry duplicates messages. Livewire keeps re-rendering the whole thread for every tiny chunk and the interface starts feeling sticky.
If you want Laravel AI streaming to feel production-ready, the core rule is simple: streaming is a state-management problem first, and a rendering problem second. Treat partial output as temporary UI state, keep durable message state explicit, and let Livewire coordinate structure instead of repainting the world on every token.
This tutorial walks through a practical architecture that handles the parts that actually matter: partial tokens, cancellation, retries, scroll behavior, optimistic UI, and failure states. The goal is not a flashy demo widget. The goal is a chat experience that feels like it belongs in a real SaaS product.
The first mistake is letting the provider stream drive your UI model directly. If your frontend is just “whatever tokens arrived so far,” you have no clean answer for cancellation, reconnects, or partial persistence.
A better mental model is to split the system into three layers:
That sounds boring, and that is exactly why it works.
At minimum, each message in your database should store:
chat_id
role
content
status
sequence
error_message
or error_code
The important field is status
. Do not reduce assistant output to “message exists or does not exist.” You want explicit lifecycle states such as:
queued
streaming
completed
cancelled
failed
That gives your UI real semantics. A streaming
message can show a stop button and cursor. A failed
message can show retry. A cancelled
message can remain visible without pretending the answer finished cleanly.
The browser should own the temporary token buffer for the active assistant message. That buffer is not durable truth. It is presentation state.
This distinction matters because users do not care whether token 147 reached the DOM. They care that the final message state is predictable. If the stream dies halfway through, the UI should know whether that partial text is recoverable, cancelled, or failed. A raw stream alone cannot tell you that.
Use Livewire for:
Do not use Livewire for ultra-high-frequency token painting if that means re-rendering the component on every chunk. Livewire is excellent at server-driven structure. It is not the best place to diff and repaint a whole thread 20 times per second.
The official Livewire docs and Laravel broadcasting docs give you the primitives. The real design choice is responsibility: Livewire owns the structure, a small client-side layer owns the active stream buffer, and your AI client owns provider-specific transport.
This is the step teams skip because it feels like backend ceremony. It is also the step that prevents weeks of ugly edge-case cleanup later.
When a user sends a prompt, the sequence should be deliberate.
status = streaming
.completed
.cancelled
and halt the stream loop.failed
, and expose retry.That is the real lifecycle. Everything else is UI polish.
Wrap your model provider behind a small interface. Even if you are only targeting one provider today, you will want this abstraction the moment you add fallback models, custom logging, or a non-streaming retry path.
<?php
namespace App\AI;
use App\Models\Chat;
use App\Models\Message;
use Generator;
interface StreamsResponses
{
/**
* @return Generator<int, StreamChunk>
*/
public function stream(Chat $chat, Message $userMessage): Generator;
}
A chunk object can stay tiny:
<?php
namespace App\AI;
final class StreamChunk
{
public function __construct(
public readonly string $type,
public readonly string $text = '',
public readonly array $meta = [],
) {}
public static function token(string $text): self
{
return new self(type: 'token', text: $text);
}
public static function done(array $meta = []): self
{
return new self(type: 'done', meta: $meta);
}
}
The Laravel service that orchestrates a single assistant turn can then focus on state transitions instead of provider mechanics.
<?php
namespace App\Actions\Chat;
use App\AI\StreamsResponses;
use App\Events\ChatStreamChunked;
use App\Events\ChatStreamFinished;
use App\Models\Chat;
use App\Models\Message;
use Throwable;
final class StreamAssistantReply
{
public function __construct(private StreamsResponses $client) {}
public function handle(Chat $chat, Message $userMessage): Message
{
$assistant = $chat->messages()->create([
'role' => 'assistant',
'content' => '',
'status' => 'streaming',
'sequence' => $chat->messages()->max('sequence') + 1,
]);
$buffer = '';
try {
foreach ($this->client->stream($chat, $userMessage) as $chunk) {
if ($assistant->fresh()->status === 'cancelled') {
break;
}
if ($chunk->type === 'token') {
$buffer .= $chunk->text;
broadcast(new ChatStreamChunked(
chatId: $chat->id,
messageId: $assistant->id,
text: $chunk->text,
));
}
}
$assistant->update([
'content' => $buffer,
'status' => $assistant->fresh()->status === 'cancelled'
? 'cancelled'
: 'completed',
]);
broadcast(new ChatStreamFinished(
chatId: $chat->id,
messageId: $assistant->id,
status: $assistant->status,
));
return $assistant->fresh();
} catch (Throwable $e) {
$assistant->update([
'content' => $buffer,
'status' => 'failed',
'error_message' => str($e->getMessage())->limit(300),
]);
broadcast(new ChatStreamFinished(
chatId: $chat->id,
messageId: $assistant->id,
status: 'failed',
));
throw $e;
}
}
}
There are two details here that matter more than the rest.
First, the database stores the final message and final status, not every token. Second, the loop checks for cancellation between chunks. If your provider supports a true abort signal, use it. If not, a cooperative cancellation check still gives you sane behavior.
This is the part that usually wrecks UX.
If every token update becomes a full Livewire re-render, your app starts doing expensive work for trivial changes. That creates flicker, scroll instability, and wasted network chatter. It also makes the component harder to reason about because durable state and transient state are tangled together.
The fix is not to abandon Livewire. The fix is to give the browser a tiny local stream store.
Use Livewire to render a message list with stable containers. Then use Alpine or a small vanilla JS store to append streamed text only into the active message node.
That gives you three benefits immediately:
A simple Blade shape might look like this:
<div x-data="chatStream(@js($chat->id))" class="flex h-full flex-col">
<div x-ref="scroller" class="flex-1 overflow-y-auto">
@foreach ($messages as $message)
<article wire:key="message-{{ $message->id }}" class="mb-4">
<div class="text-xs text-slate-500">{{ $message->role }}</div>
<div class="prose max-w-none">
@if ($message->status === 'streaming')
<div data-stream-id="{{ $message->id }}">{{ $message->content }}</div>
@else
<div>{!! nl2br(e($message->content)) !!}</div>
@endif
</div>
@if ($message->status === 'failed')
<button wire:click="retry({{ $message->id }})">Retry</button>
@endif
</article>
@endforeach
</div>
</div>
Then let a tiny client-side store handle incremental updates.
<script>
function chatStream(chatId) {
return {
buffers: {},
followStream: true,
init() {
const scroller = this.$refs.scroller;
scroller.addEventListener('scroll', () => {
const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
this.followStream = distance < 120;
});
window.Echo.private(`chat.${chatId}`)
.listen('.chat.stream.chunked', (event) => {
this.append(event.messageId, event.text);
})
.listen('.chat.stream.finished', (event) => {
this.finish(event.messageId, event.status);
});
},
append(messageId, text) {
if (!this.buffers[messageId]) {
this.buffers[messageId] = '';
}
this.buffers[messageId] += text;
const node = document.querySelector(`[data-stream-id='${messageId}']`);
if (node) {
node.textContent = this.buffers[messageId];
}
if (this.followStream) {
this.$nextTick(() => {
this.$refs.scroller.scrollTop = this.$refs.scroller.scrollHeight;
});
}
},
finish(messageId) {
delete this.buffers[messageId];
}
}
}
</script>
This is not fancy. That is the point. You want the smallest possible client-side layer that can own the active token buffer without dragging the rest of the UI into every incremental update.
This hybrid setup lets you solve real problems cleanly:
That is what “AI feels native in Laravel” actually means. It means the chat behaves like the rest of your product, not like a lab experiment glued into a Blade file.
Because they will.
These are not edge cases once users start relying on the feature. They are standard paths.
A stop button that only hides a spinner is fake cancellation. Users notice.
When the user clicks stop:
cancelled
immediatelyIn many products, preserving partial content is the better choice. It gives the user something to reference and makes the stop action feel honest instead of destructive.
A simple Livewire action can be enough:
public function stop(int $messageId): void
{
Message::query()
->whereKey($messageId)
->where('status', 'streaming')
->update(['status' => 'cancelled']);
}
If your provider SDK supports request abortion, wire that in too. But even without transport-level abort, cooperative cancellation still improves UX dramatically because the message state turns truthful immediately.
Do not mutate a failed assistant message in place. That makes conversation history ambiguous and complicates debugging.
A retry should usually reuse the same user message context while generating a fresh assistant message shell. That gives you a clean before-and-after trail:
That history is useful for support, observability, and user trust.
A practical retry rule is:
If you want a cleaner timeline, you can visually group retries in the UI. But do not rewrite history at the database level just to make the thread look prettier.
Users double-click. Mobile connections stutter. A request can time out client-side while still running server-side. If you do not add idempotency, you will end up with duplicate assistant turns for the same prompt.
The safest move is to generate a per-submission idempotency key on the client and persist it with the user message or turn record.
Then enforce a rule like this:
That single guard prevents a lot of messy “why did the bot answer twice?” bugs.
Bad scroll behavior destroys trust faster than most model mistakes. It makes the interface feel unstable.
The rule is simple: auto-scroll only if the user is already near the bottom. If they scroll upward to read something, stop following the stream and show a small “jump to latest” affordance instead.
This is better than unconditional auto-scroll because it respects intent. The user is telling you they want context, not motion.
A threshold-based check is enough in most apps:
function isNearBottom(container, threshold = 120) {
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
return distance < threshold;
}
Do not over-engineer this. You do not need a scroll physics engine. You need a sensible rule and consistent behavior.
AI chat fails in several distinct ways:
Those should not all surface as “Something went wrong.” That message is content-free.
Instead, make the UI specific enough to guide the next action:
Response stopped before completion. Retry from the last prompt.
Model provider rejected the request. Try again or switch models.
Reply could not be saved. Refresh the thread before retrying.
Generation stopped by you.
The user does not need your exception trace. They do need a clear explanation of what state the message is in and what action is available next.
The UI should be clean. Your logs should not.
Track at least:
If you want to improve your AI feature later, these metrics matter more than vague intuition. They help you answer practical questions like whether a model is too slow for your UX budget, whether one provider fails more often during peak times, or whether a long-running generation path needs a fallback.
The official Laravel logging and queue monitoring patterns are enough to start. You do not need a huge observability platform on day one. You do need structured events and enough metadata to reconstruct a broken session.
A lot of AI demos look impressive because they ignore the hard parts. Real Laravel products need the opposite mindset.
The production-ready version is usually stricter, not bigger:
That stack is enough to make a chat UI feel solid.
What usually does not matter early:
Those features are nice later. They are not the foundation.
If you are building this now, start with the simplest shape that preserves truth:
streaming
That order matters. It keeps your UI calm and your code understandable.
The practical decision rule is this: if a streamed token would force you to rewrite durable state on every update, your architecture is too coupled. Keep the stream transient, keep message states explicit, and let Livewire do what it does best: coordinating stable server-driven UI.
That is how you ship Laravel AI streaming without wrecking Livewire UX.
Read the full post on QCode: https://qcode.in/how-to-stream-ai-responses-in-laravel-without-wrecking-livewire-ux/