laravel livewire claude api

Laravel Livewire Claude API: Real-Time AI Chat Without JavaScript Frameworks

Most AI chat tutorials default to React the moment “real-time” enters the requirements. That instinct is understandable — but it’s often wrong. The Laravel Livewire Claude API pairing handles server-driven reactivity cleanly, and for AI interfaces specifically, that tradeoff lands in your favour more often than you’d think.

We’re going to build a working chat interface using this stack — Laravel 11, Livewire v3, and a proper service layer. We’re going to build a working Claude-powered chat interface using Laravel 11, Livewire v3, and a proper service layer. No React. No Vue. No client-side state management. Just PHP, the Laravel Service Container, Eloquent, and a clear understanding of where the complexity actually lives.

Why Livewire Is a Legitimate Choice for AI Chat

Livewire’s model is simple: the server owns state. Each interaction triggers a small, scoped request, the server computes the new state, and the DOM gets patched. That’s it.

AI interactions slot into this pattern well. Here’s why:

  • Claude API calls are slow relative to UI events — latency measured in seconds, not milliseconds
  • You need state persistence, not raw socket throughput
  • Determinism and debuggability matter more than perceived cleverness
  • You don’t lose the “no JavaScript” constraint the moment AI enters the picture

The use case where Livewire breaks down is token-level streaming — where you’re rendering each word as Claude generates it. That’s a different architectural problem, and we’ll address it explicitly at the end. For a streaming chatbot with Claude in Laravel using Server-Sent Events, that implementation is covered in full in the streaming chatbot guide.

What We’re Building

  • A Livewire v3 chat component with clean action handling
  • A ClaudeService class with real error handling
  • Eloquent-backed conversation memory
  • Input validation using Livewire’s built-in validate() method
  • An honest assessment of the streaming tradeoff

If you want to go deeper on service boundaries, token accounting, and rate-limit middleware before building the Livewire layer, our complete Claude API integration guide for Laravel covers the full production architecture — including queue-backed calls and cost tracking.

Step 1: Conversation Persistence

Claude has no memory. Livewire does not solve that. Memory must live in your database — full stop.

Schema::create('chat_messages', function (Blueprint $table) {
    $table->id();
    $table->uuid('conversation_id');
    $table->enum('role', ['user', 'assistant', 'system']);
    $table->longText('content');
    $table->timestamps();
    $table->index('conversation_id');
});

The conversation_id index matters. Once a conversation grows past a few dozen messages, unindexed queries on a shared chat_messages table will hurt. Add it now.

Step 2: The Claude Service — With Actual Error Handling

Your Livewire component should orchestrate state. It should not make raw HTTP calls. That responsibility belongs in a dedicated service class, and that service class needs to handle failure correctly.

namespace App\Services;

use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class ClaudeService
{
    public function send(array $messages): string
    {
        try {
            $response = Http::withHeaders([
                'x-api-key'         => config('services.anthropic.key'),
                'anthropic-version' => '2023-06-01',
            ])
            ->timeout(30)
            ->retry(2, 1000, fn ($exception) => $exception instanceof RequestException)
            ->post('https://api.anthropic.com/v1/messages', [
                'model'      => 'claude-sonnet-4-6',
                'max_tokens' => 600,
                'messages'   => $messages,
            ]);

            $response->throw();

            return $response->json('content.0.text') ?? '';

        } catch (RequestException $e) {
            Log::error('Claude API error', [
                'status'  => $e->response->status(),
                'body'    => $e->response->body(),
            ]);

            throw new \RuntimeException('Claude did not respond. Please try again.');
        }
    }
}

A few deliberate decisions here. ->timeout(30) prevents the Livewire request from hanging indefinitely if Claude is slow. ->retry(2, 1000) handles transient 529 rate-limit responses without manual retry logic. $response->throw() converts non-2xx responses into exceptions rather than silently returning empty strings. Log the failure so your error tracking (Sentry, Flare, Bugsnag) captures context.

See the Anthropic Messages API documentation for the full list of status codes and error shapes you should be accounting for.

Step 3: Formatting Messages for Claude

Claude’s message format is structured for a reason. Don’t flatten everything into a single prompt — that destroys role clarity and degrades response quality. Keep the role structure intact:

private function formatForClaude(\Illuminate\Support\Collection $messages): array
{
    return $messages->map(fn ($m) => [
        'role'    => $m->role,
        'content' => [
            ['type' => 'text', 'text' => $m->content],
        ],
    ])->toArray();
}

This is now a private method on the component — not a global function floating in the file. Global functions in Livewire component files break autoloading assumptions and can’t be unit tested in isolation.

Step 4: The Livewire Component (v3 Syntax)

namespace App\Livewire;

use App\Models\ChatMessage;
use App\Services\ClaudeService;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Livewire\Attributes\Rule;
use Livewire\Component;

class ClaudeChat extends Component
{
    public string $conversationId = '';

    #[Rule('required|string|max:2000')]
    public string $message = '';

    public bool $loading = false;
    public ?string $errorMessage = null;

    protected ClaudeService $claude;

    public function boot(ClaudeService $claude): void
    {
        $this->claude = $claude;
    }

    public function mount(): void
    {
        $this->conversationId = (string) Str::uuid();
    }

    public function send(): void
    {
        $this->validate();
        $this->errorMessage = null;
        $this->loading = true;

        try {
            ChatMessage::create([
                'conversation_id' => $this->conversationId,
                'role'            => 'user',
                'content'         => trim($this->message),
            ]);

            $messages  = $this->getConversationMessages();
            $formatted = $this->formatForClaude($messages);
            $reply     = $this->claude->send($formatted);

            ChatMessage::create([
                'conversation_id' => $this->conversationId,
                'role'            => 'assistant',
                'content'         => $reply,
            ]);

            $this->message = '';

        } catch (\RuntimeException $e) {
            $this->errorMessage = $e->getMessage();
        } finally {
            $this->loading = false;
        }
    }

    private function getConversationMessages(): Collection
    {
        return ChatMessage::where('conversation_id', $this->conversationId)
            ->orderBy('id')
            ->get();
    }

    private function formatForClaude(Collection $messages): array
    {
        return $messages->map(fn ($m) => [
            'role'    => $m->role,
            'content' => [
                ['type' => 'text', 'text' => $m->content],
            ],
        ])->toArray();
    }

    public function render(): \Illuminate\View\View
    {
        return view('livewire.claude-chat', [
            'messages' => $this->getConversationMessages(),
        ]);
    }
}

[Production Pitfall] The original article had $this->loading = false at the very end of send(). If any line above it throws — the API times out, the DB write fails, anything — that line never executes. The component freezes in “Thinking…” with no recovery path for the user. The fix is finally {}. It runs regardless of outcome. This is not a theoretical edge case; it will happen under load, on flaky connections, and during Anthropic’s maintenance windows. Use finally.

Step 5: The View (Livewire v3)

blade

<div>
    @if ($errorMessage)
        <div class="text-red-600 mb-4 text-sm">{{ $errorMessage }}</div>
    @endif

    <div class="space-y-4 mb-4">
        @foreach ($messages as $msg)
            <div class="{{ $msg->role === 'user' ? 'text-right' : 'text-left' }}">
                <strong>{{ ucfirst($msg->role) }}:</strong>
                <p>{{ $msg->content }}</p>
            </div>
        @endforeach
    </div>

    <div>
        <textarea wire:model="message" placeholder="Ask something…"></textarea>

        <button
            wire:click="send"
            wire:loading.attr="disabled"
            wire:target="send"
        >
            <span wire:loading.remove wire:target="send">Send</span>
            <span wire:loading wire:target="send">Thinking…</span>
        </button>
    </div>
</div>

One important syntax correction: wire:model.defer was removed in Livewire v3. In v3, wire:model is lazy by default (deferred). Use wire:model.live if you want real-time syncing. Using wire:model.defer in a Livewire v3 project will silently fail to sync the value — a subtle, hard-to-diagnose bug. Refer to the Livewire v3 documentation on property binding if you’re migrating from v2.

wire:loading.attr="disabled" and wire:loading directives are the idiomatic v3 way to handle loading state in the view, rather than relying solely on the $loading property for disabling buttons.

What About Streaming?

What we’ve built feels real-time. Submit a message, the UI updates, the response appears without a page reload. But Claude is returning its answer as a single, complete payload.

True streaming is different — tokens arrive as they’re generated, text appears word by word, perceived latency drops dramatically even when total latency is identical.

The honest answer: Livewire is not designed for streaming. Its model is transactional. You can simulate streaming (request the full response server-side, then replay it in chunks with polling), and for most internal tools and dashboards, that’s enough.

For consumer-facing products where typing animation is a core UX feature, you need SSE or WebSockets — which means stepping outside Livewire’s scope entirely. We’ve mapped out the decision tree in detail in Livewire vs SSE vs WebSockets for AI UIs, including the reverse-proxy buffering and connection timeout edge cases that catch most teams unprepared.

[Architect’s Note] Choosing Livewire for AI chat is not a compromise — it’s a deliberate tradeoff. Livewire gives you Eloquent integration, session-aware state, testable PHP, and a single deployment target. The teams that regret this choice are the ones who chose it without understanding why they chose it. Know the tradeoffs. Own the decision.

Production Checklist

Before you ship this:

  • ANTHROPIC_API_KEY is in .env, pulled via config('services.anthropic.key') — never hardcoded
  • The chat_messages table has the conversation_id index in place
  • Laravel’s queue worker is ready for when you inevitably want to move Claude calls off the main request cycle
  • You have a rate-limiting strategy for the send action (Livewire actions are HTTP endpoints — they can be hammered)
  • Error reporting is wired up so Claude failures surface in your monitoring tool, not just in logs
Subscribe
Notify of
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Navigation
Scroll to Top