Skip to content
Bladewell
Guides

Laravel AI chat with Livewire and streaming replies

A Livewire AI chat with the Laravel AI SDK: replies streamed as they're written, Markdown shown safely, files attached, and each paid reply claimed once.

php artisan bladewell:add chat button

Uses Chat and Button. New to Bladewell? Install it first.

Three parts, each doing one job

Bladewell's chat draws the conversation: your prompts in bubbles, replies the full width, a caret while one is still being written, Markdown formatted safely, files as cards, and new messages read out to screen readers. The Laravel AI SDK talks to the model, whichever provider you use: OpenAI, Anthropic, Gemini and others. A Livewire component joins them, and streams each reply into the page as it's written.

This guide was checked against the AI SDK's 1.x source and documentation (laravel/ai 1.3). It needs PHP 8.3 and Laravel 12 or 13, like Bladewell itself.

Install

Add the AI SDK and its config, then the components, and put your provider's key in .env. The SDK's own default provider is OpenAI; the agent below names Anthropic, so with another provider, change that line.

Terminal
composer require laravel/ai
php artisan vendor:publish --tag=ai-config
php artisan bladewell:add chat button

# .env: the key for your provider, for example
ANTHROPIC_API_KEY=…

Store the conversation

Keep the messages in your own tables, so each prompt can be claimed for its one reply (more on that below) and carry a file. The AI SDK can also store conversations itself, with its RemembersConversations trait, but then you can't claim a reply before paying for it.

database/migrations/…_create_chat_tables.php
// database/migrations/…_create_chat_tables.php, in up()
Schema::create('conversations', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->timestamps();
});

Schema::create('messages', function (Blueprint $table) {
    $table->id();
    $table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
    // true: the person's prompt; false: the assistant's reply.
    $table->boolean('mine');
    $table->text('body');
    // A reply's HTML, made from its Markdown once, when it's saved, not on every render.
    $table->text('body_html')->nullable();
    // The prompt still waits for its one reply. respond() claims it with reply_claimed_at; a claim left by a request
    // that died (a timeout) can be taken again after a few minutes.
    $table->boolean('awaiting_reply')->default(false);
    $table->timestamp('reply_claimed_at')->nullable();
    $table->string('attachment_path')->nullable();
    $table->string('attachment_name')->nullable();
    $table->string('attachment_mime')->nullable();
    $table->timestamps();
    // Each conversation's messages, newest first, without a scan.
    $table->index(['conversation_id', 'id']);
});

Two small models, one for each table:

app/Models/Message.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Message extends Model
{
    protected $fillable = ['mine', 'body', 'body_html', 'awaiting_reply', 'reply_claimed_at', 'attachment_path', 'attachment_name', 'attachment_mime'];

    protected function casts(): array
    {
        return ['mine' => 'boolean', 'awaiting_reply' => 'boolean', 'reply_claimed_at' => 'datetime'];
    }

    public function conversation(): BelongsTo
    {
        return $this->belongsTo(Conversation::class);
    }
}

A conversation has many messages:

app/Models/Conversation.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Conversation extends Model
{
    public function messages(): HasMany
    {
        return $this->hasMany(Message::class);
    }
}

A conversation belongs to one person. The id in the address can be changed to anyone else's, so a policy decides who may read and write in it, and the component asks it on every request:

app/Policies/ConversationPolicy.php
namespace App\Policies;

use App\Models\Conversation;
use App\Models\User;

class ConversationPolicy
{
    // Only the person whose conversation it is may read it, write in it or get replies.
    public function reply(User $user, Conversation $conversation): bool
    {
        return $conversation->user_id === $user->id;
    }
}

The assistant

An agent is a class with its instructions. php artisan make:agent Assistant makes one. #[Provider] picks the provider whose key you set, and #[Model(…)] can pick its model. #[Timeout] matters too: the SDK stops a reply after 60 seconds unless told otherwise.

app/Ai/Agents/Assistant.php
namespace App\Ai\Agents;

use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\Timeout;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Stringable;

// The provider whose key is in .env; without this, config/ai.php's default is OpenAI. Timeout: the SDK stops a reply
// after 60 seconds unless told otherwise, and a long one can take more.
#[Provider(Lab::Anthropic)]
#[Timeout(120)]
class Assistant implements Agent
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return 'You are Acme Assistant. Answer clearly and briefly. Format with Markdown.';
    }
}

Send, then stream the reply

Livewire can only stream into an element that's already on the page. So sending takes two requests:

  • send() saves the prompt and its file, and draws an empty reply marked streaming. Then it asks the browser to call respond().
  • respond() claims the reply, passes the conversation so far to the agent, and streams the text into that reply, piece by piece, as the model writes it.

Each reply is a paid model call, and every Livewire action can be called, and every public property set, from the browser. So each action checks the policy, sending is rate-limited, and a reply is claimed in one database update before the model is called: two requests for the same reply can't both pay for it. If the call fails, the claim goes back, and Try again asks once more. If the request dies instead, through a timeout or a crash, the claim is taken again after a few minutes, so the prompt is never stuck. ignore_user_abort() keeps a reply that has started going even if the tab is closed, so it's saved, not lost.

app/Livewire/Chat.php
namespace App\Livewire;

use App\Ai\Agents\Assistant;
use App\Models\Conversation;
use App\View\Widget\ChatMarkdown;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Laravel\Ai\Files\Document;
use Laravel\Ai\Files\Image;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Streaming\Events\Error;
use Laravel\Ai\Streaming\Events\TextDelta;
use Livewire\Component;
use Livewire\WithFileUploads;
use RuntimeException;
use Throwable;

class Chat extends Component
{
    use WithFileUploads;

    // A claim older than this belongs to a request that died (a timeout, a crash): it may be taken again.
    private const CLAIM_MINUTES = 5;

    public Conversation $conversation;

    public string $prompt = '';

    // A file to send with the next prompt: an image or a PDF.
    public $attachment = null;

    // Only draws the page. Every public property can be set from the browser, so the server decides on its own
    // whether a reply is due: see respond().
    public bool $answering = false;

    public function mount(Conversation $conversation): void
    {
        Gate::authorize('reply', $conversation);
        $this->conversation = $conversation;
    }

    public function send(): void
    {
        // Every action is a request of its own: check who's asking each time.
        Gate::authorize('reply', $this->conversation);
        $this->validate([
            'prompt' => ['required', 'string', 'max:2000'],
            'attachment' => ['nullable', 'file', 'mimes:jpg,jpeg,png,webp,pdf', 'max:10240'],
        ]);
        // Each prompt is a paid model call: 20 a minute per person, not per tab. Consider a daily cap too.
        if (! RateLimiter::attempt('chat:'.auth()->id(), 20, fn () => true)) {
            throw ValidationException::withMessages(['prompt' => 'You\'re sending quickly. Try again in a minute.']);
        }

        $message = $this->conversation->messages()->create([
            'mine' => true,
            'body' => $this->prompt,
            'awaiting_reply' => true,
            // Stored now, privately: respond() runs as a request of its own, after this upload is gone.
            'attachment_path' => $this->attachment?->store('chat', 'local'),
            'attachment_name' => $this->attachment?->getClientOriginalName(),
            'attachment_mime' => $this->attachment?->getMimeType(),
        ]);

        $this->reset('prompt', 'attachment');
        // Livewire streams only into an element already on the page: this render draws the reply's message, then
        // the browser asks for the reply.
        $this->answering = true;
        $this->js("\$wire.respond({$message->id})");
    }

    public function respond(int $messageId): void
    {
        Gate::authorize('reply', $this->conversation);
        // A reply that has started is paid for: finish and save it even if the tab closes or reloads meanwhile.
        // Leave room above the agent's 120-second timeout, so the catch below gets to run.
        ignore_user_abort(true);
        set_time_limit(150);

        // This prompt, in this conversation, only while it waits and nobody else is answering it: claimed in one
        // UPDATE. Of two requests at once only one changes the row, so only one calls (and pays for) the model.
        $claimed = $this->conversation->messages()->whereKey($messageId)->where('mine', true)->where('awaiting_reply', true)
            ->where(fn ($query) => $query->whereNull('reply_claimed_at')->orWhere('reply_claimed_at', '<', now()->subMinutes(self::CLAIM_MINUTES)))
            ->update(['reply_claimed_at' => now()]) === 1;
        if (! $claimed) {
            $this->answering = false;

            return;
        }

        $question = $this->conversation->messages()->findOrFail($messageId);
        // What came before it, from the database, oldest first: not newer messages, and nothing the browser sent.
        $history = $this->conversation->messages()->where('id', '<', $messageId)->latest('id')->limit(20)->get()->reverse()
            ->map(fn ($message) => new Message($message->mine ? 'user' : 'assistant', $message->body))
            ->values()->all();
        $attachments = match (true) {
            $question->attachment_path === null => [],
            str_starts_with($question->attachment_mime, 'image/') => [Image::fromStorage($question->attachment_path, 'local')],
            default => [Document::fromStorage($question->attachment_path, 'local')],
        };

        $answer = '';
        try {
            foreach ((new Assistant)->withMessages($history)->stream($question->body, $attachments) as $event) {
                // A provider's failure part-way can come as an event rather than an exception.
                if ($event instanceof Error) {
                    throw new RuntimeException($event->message);
                }
                if ($event instanceof TextDelta) {
                    $answer .= $event->delta;
                    // Livewire puts streamed text in as HTML: escape each piece. The whole reply is formatted once
                    // it's saved.
                    $this->stream(to: 'answer', content: e($event->delta));
                }
            }
        } catch (Throwable $e) {
            // Nothing was saved, so let the claim go: Try again asks for this reply once more.
            $this->conversation->messages()->whereKey($messageId)->update(['reply_claimed_at' => null]);
            $this->answering = false;
            report($e);
            $this->addError('prompt', 'The reply didn\'t come through.');

            return;
        }

        $this->conversation->messages()->create([
            'mine' => false,
            'body' => $answer,
            // Markdown made safe once, here, instead of on every render: what a model writes can carry HTML.
            'body_html' => ChatMarkdown::toHtml($answer),
        ]);
        $question->update(['awaiting_reply' => false]);
        $this->answering = false;
    }

    public function tryAgain(): void
    {
        Gate::authorize('reply', $this->conversation);
        // Found here, not taken from the browser: the latest prompt still without a reply, and not being answered.
        $stuck = $this->stuckPrompt();
        if ($stuck !== null) {
            $this->resetErrorBag('prompt');
            $this->answering = true;
            $this->js("\$wire.respond({$stuck})");
        }
    }

    private function stuckPrompt(): ?int
    {
        return $this->conversation->messages()->where('mine', true)->where('awaiting_reply', true)
            ->where(fn ($query) => $query->whereNull('reply_claimed_at')->orWhere('reply_claimed_at', '<', now()->subMinutes(self::CLAIM_MINUTES)))
            ->latest('id')->value('id');
    }

    public function render()
    {
        return view('livewire.chat', [
            // From the database on every render: the browser can't add messages of its own.
            'messages' => $this->conversation->messages()->latest('id')->limit(50)->get()->reverse(),
            // A prompt left without its reply (the call failed, or the request died): offer to try again.
            'stuck' => $this->answering ? null : $this->stuckPrompt(),
        ]);
    }
}

The view

Your prompts are shown as typed, with text. Each reply's Markdown is turned into HTML once, when it's saved, with the chat's own ChatMarkdown, which formats lists, code and links but drops any HTML the model wrote: what a model writes can be steered by a page or file it read. Doing it once, not on every render, keeps a long conversation quick. While a reply streams, its text is escaped piece by piece. A file goes through wire:model in the composer's actions slot.

resources/views/livewire/chat.blade.php
<div>
    <x-widget.chat label="Chat with Acme Assistant" class="h-[36rem]">
        @foreach ($messages as $message)
            {{-- Prompts as typed. Replies as the HTML ChatMarkdown made when each was saved: formatted, with any HTML
                 the model wrote dropped. --}}
            <x-widget.chat.message wire:key="message-{{ $message->id }}" :mine="$message->mine" :name="$message->mine ? null : 'Acme Assistant'" :initials="$message->mine ? null : 'AI'" :time="$message->created_at" :text="$message->mine ? $message->body : null">
                {{-- Unescaped on purpose: ChatMarkdown built this HTML from escaped text, with no HTML of the reply's own. --}}
                @unless ($message->mine)
                    {!! $message->body_html !!}
                @endunless
                @if ($message->attachment_path)
                    <x-slot:attachments>
                        <x-widget.chat.attachment :href="route('chat.attachments.show', $message)" :name="$message->attachment_name" />
                    </x-slot:attachments>
                @endif
            </x-widget.chat.message>
        @endforeach

        @if ($answering)
            {{-- On the page before respond() runs, so the reply has somewhere to stream to. --}}
            <x-widget.chat.message name="Acme Assistant" initials="AI" streaming>
                <span wire:stream="answer"></span>
            </x-widget.chat.message>
        @endif

        @if ($stuck)
            <x-widget.button wire:click="tryAgain" variant="secondary" size="sm" class="self-start">Try again</x-widget.button>
        @endif

        <x-slot:composer>
            <x-widget.chat.composer wire:submit="send" wire:model="prompt" placeholder="Ask anything…" maxlength="2000">
                <x-slot:actions>
                    {{-- With Livewire, the file goes through wire:model; the composer's own paperclip posts a plain form. --}}
                    <label class="text-foreground/70 hover:bg-field hover:text-foreground has-focus-visible:ring-primary relative grid size-9 shrink-0 cursor-pointer place-items-center rounded-xl has-focus-visible:ring-2">
                        <x-widget.icon name="paperclip" class="size-5" />
                        <span class="sr-only">{{ $attachment ? 'Attached: '.$attachment->getClientOriginalName() : 'Attach an image or PDF' }}</span>
                        <input type="file" wire:model="attachment" accept="image/png,image/jpeg,image/webp,.pdf" class="sr-only" @if ($errors->has('attachment')) aria-invalid="true" aria-describedby="attachment-error" @endif>
                    </label>
                    @if ($attachment)
                        <span class="text-foreground/70 max-w-40 truncate text-sm">{{ $attachment->getClientOriginalName() }}</span>
                    @endif
                    {{-- The composer shows the prompt's error; a wrong or too-large file's is the file's own. --}}
                    @if ($errors->has('attachment'))
                        <span id="attachment-error" class="text-error text-sm">{{ $errors->first('attachment') }}</span>
                    @endif
                </x-slot:actions>
            </x-widget.chat.composer>
        </x-slot:composer>
    </x-widget.chat>
</div>

Routes, and the files people send

The page is the component itself. Files are stored on the private local disk under a name of their own, and only ever sent back as downloads, after the same policy check.

routes/web.php
// routes/web.php
use App\Livewire\Chat;
use App\Models\Message;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;

Route::middleware('auth')->group(function () {
    // The page: the component checks the conversation is this person's.
    Route::get('/chat/{conversation}', Chat::class)->name('chat.show');

    // A file sent with a prompt, always as a download: an SVG or HTML file opened on your site could run its scripts.
    Route::get('/chat/messages/{message}/attachment', function (Message $message) {
        Gate::authorize('reply', $message->conversation);
        abort_if($message->attachment_path === null, 404);

        return Storage::disk('local')->download($message->attachment_path, $message->attachment_name);
    })->name('chat.attachments.show');
});

Before you ship it

  • Cost. The rate limit and the claim stop accidental double calls; add a daily cap per person if replies are expensive. The context is the last 20 messages: raise it with care, as every message in it is paid for again on each reply.
  • Time. respond() allows itself 150 seconds, above the agent's 120. Your web server or proxy may stop it sooner, so check its timeout too. If a reply arrives all at once instead of word by word, a proxy in front of the app is holding the response back until it ends.
  • Older messages. The view shows the latest 50. For long conversations, load earlier ones as the person scrolls up, with the chat's history-url, a page at a time.
  • Livewire. The chat works inside Livewire 3 and 4, wire:stream included. See the chat's page for its other options.