Streaming AI in Laravel without fighting Livewire

php dev.to

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 Baseline Architecture That Does Not Fight Livewire

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:

  • durable chat state in Laravel and your database
  • transient stream state in the browser
  • provider transport hidden behind a service class

That sounds boring, and that is exactly why it works.

What Should Be Durable

At minimum, each message in your database should store:

  • chat_id
  • role
  • content
  • status
  • sequence
  • error_message or error_code
  • timestamps

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.

What Should Stay Transient

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.

What Livewire Should Actually Do

Use Livewire for:

  • submitting the user message
  • rendering the stable message list
  • reflecting durable status changes
  • exposing actions like stop and retry

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.

Build The Message Lifecycle Before You Build The Pretty UI

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.

  1. Persist the user message.
  2. Create an empty assistant message shell with status = streaming.
  3. Start the provider stream.
  4. Append chunks into a transient buffer in the browser.
  5. On clean completion, persist the final content and mark completed.
  6. On user stop, mark cancelled and halt the stream loop.
  7. On failure, preserve what you have, mark failed, and expose retry.

That is the real lifecycle. Everything else is UI polish.

A Practical Service Boundary

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Let The Browser Paint Tokens, Not The Entire Livewire Component

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.

The Responsibility Split

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:

  • the message tree stays structurally stable
  • token updates touch one DOM node instead of a whole component
  • final persistence still flows through Laravel cleanly

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Why This Pattern Holds Up Better

This hybrid setup lets you solve real problems cleanly:

  • a page refresh still restores durable messages from the database
  • a cancelled stream can stop visually without corrupting history
  • a failed stream can leave partial content plus an honest status
  • retry can create a fresh assistant message without DOM gymnastics

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.

Handle Stop, Retry, And Duplicate Submission Like They Will Break In Production

Because they will.

These are not edge cases once users start relying on the feature. They are standard paths.

Cancellation Should Be Cooperative And Immediate

A stop button that only hides a spinner is fake cancellation. Users notice.

When the user clicks stop:

  • update the message status to cancelled immediately
  • stop showing the streaming cursor in the browser
  • make the server stream loop notice the cancellation flag
  • persist whatever partial content you want to keep

In 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']);
}
Enter fullscreen mode Exit fullscreen mode

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.

Retries Should Create A New Assistant Turn

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:

  • first attempt failed
  • second attempt succeeded

That history is useful for support, observability, and user trust.

A practical retry rule is:

  1. keep the failed message visible
  2. disable duplicate retries while one is active
  3. create a new assistant placeholder
  4. stream into the new placeholder

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.

Idempotency Is Not Optional

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:

  • if a submission with the same key already exists and is still active, return the active state instead of starting another stream
  • if it completed, reload the result
  • if it failed, let the UI offer explicit retry

That single guard prevents a lot of messy “why did the bot answer twice?” bugs.

Scroll Behavior And Error Handling Are Where The UX Either Feels Calm Or Cheap

Bad scroll behavior destroys trust faster than most model mistakes. It makes the interface feel unstable.

Follow Only When The User Wants To Follow

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;
}
Enter fullscreen mode Exit fullscreen mode

Do not over-engineer this. You do not need a scroll physics engine. You need a sensible rule and consistent behavior.

Render Errors With Useful Honesty

AI chat fails in several distinct ways:

  • the provider rejected the request
  • the stream disconnected before completion
  • your application failed while saving or broadcasting
  • the user cancelled intentionally

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.

Keep Diagnostics On The Server

The UI should be clean. Your logs should not.

Track at least:

  • provider name and model
  • time to first token
  • total duration
  • stop reason
  • failure class
  • token or usage metadata where available

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.

The Production Version Is Smaller And Stricter Than Most Demos

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:

  • one active assistant message per turn
  • explicit message statuses
  • browser-owned transient token buffer
  • Livewire-owned durable thread structure
  • cooperative cancellation
  • retry as a new assistant turn
  • auto-scroll only when the user stays near the bottom
  • honest error states
  • idempotency for submissions

That stack is enough to make a chat UI feel solid.

What usually does not matter early:

  • token-by-token Markdown rendering
  • animated typing gimmicks
  • fancy streaming cursors
  • complex optimistic threading across multiple parallel generations

Those features are nice later. They are not the foundation.

If you are building this now, start with the simplest shape that preserves truth:

  1. persist user message
  2. create assistant shell with streaming
  3. append chunks in the browser only
  4. finalize content and status in Laravel
  5. support stop, retry, and scroll intent before adding polish

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/

Source: dev.to

arrow_back Back to Tutorials