Larachat: Build a Real-Time AI Chat App with Laravel and React

7月5日 Published inAI Tools

Larachat is a technical demonstration of real-time chat capabilities. It provides developers with a blueprint for building interfaces that feature streaming AI responses, persistent message history, and user authentication. Essentially a minimalist ChatGPT clone, Larachat focuses on being clean, functional, and entirely open-source.

What Larachat Does

  • Real-Time Streaming: Utilizes Server-Sent Events (SSE) to stream AI responses as they are generated.
  • Persistent History: Automatically saves message logs. Optional authentication ensures that chat sessions are securely tied to specific user accounts.
  • Dynamic Title Generation: Automatically labels conversations using the useEventStream hook.
  • Modern UI: Built with Tailwind CSS v4 and shadcn/ui, providing a fully responsive interface that works seamlessly on mobile devices.
  • Theming: Includes a toggle for light and dark modes while also respecting system-wide preferences.

System Requirements

  • PHP 8.2 or newer: Ensure the following extensions are enabled: curl, dom, fileinfo, filter, hash, mbstring, openssl, pcre, pdo, session, tokenizer, and xml.
  • Node.js 22 or newer: Required for React 19 compatibility.
  • Composer 2.x: For PHP dependency management.
  • Database: SQLite is the default configuration, though MySQL or PostgreSQL are also supported.
  • Git: To clone the repository.

Optional but Recommended

  • OpenAI API Key: Required for live AI interactions. If omitted, the application will provide mock responses for testing purposes.
  • Local Server: PHP's built-in development server or Laravel Valet is recommended for local development.

Framework Versions Used

  • Laravel 12.0 (Latest)
  • React 19 (Latest)
  • Tailwind CSS v4 (Beta)
  • Inertia.js 2.0

This stack utilizes cutting-edge releases. If you encounter issues during setup, verify that your local environment matches these specific versions.

Quick Start

Begin by cloning the repository and installing the necessary dependencies.

composer install
npm install

Configure your environment settings.

cp .env.example .env
php artisan key:generate

Add your OpenAI API key to the .env file.

OPENAI_API_KEY=your-api-key-here

Run the database migrations and launch the development environment.

php artisan migrate
composer dev

The composer dev command runs several processes concurrently. If you prefer to manage them individually, you can open separate terminal windows:

  • Terminal 1: php artisan serve
  • Terminal 2: php artisan queue:listen
  • Terminal 3: npm run dev

Common Fixes

  • Incompatible Node.js Version: If you see version errors, install Node 22 or higher. We recommend using nvm: nvm install 22 && nvm use 22.
  • Missing OpenAI Class: If the app cannot find the OpenAI class, run composer install again and ensure your .env key is correctly formatted.
  • Database Connection Errors: SQLite requires a physical file at database/database.sqlite. You can create it manually with touch database/database.sqlite before running migrations.
  • Vite/Tailwind v4 Build Failures: Try clearing the npm cache (npm cache clean --force), deleting the node_modules folder, and reinstalling (rm -rf node_modules && npm install). Ensure you are using Node 22+.
  • Streaming CSRF Mismatch: While the layout includes the necessary meta tags, you may need to clear your browser cache or local domain cookies if session errors persist during streaming.

Using the useStream Hook

Streaming responses are managed on the frontend by the @laravel/stream-react package via the useStream hook. This allows for straightforward integration of real-time data into React components.

import { useStream } from '@laravel/stream-react';
import { useState } from 'react';

function Chat() {
    const [messages, setMessages] = useState([]);
    const { data, send, isStreaming } = useStream('/chat/stream');

    const handleSubmit = (e) => {
        e.preventDefault();
        const query = e.target.query.value;

        const newMessage = { type: 'prompt', content: query };
        setMessages([...messages, newMessage]);

        send({ messages: [...messages, newMessage] });

        e.target.reset();
    };

    return (
        <div>
            {messages.map((msg, i) => (
                <div key={i}>{msg.content}</div>
            ))}

            {data && <div>{data}</div>}

            <form onSubmit={handleSubmit}>
                <input name="query" disabled={isStreaming} />
                <button type="submit">Send</button>
            </form>
        </div>
    );
}

The hook establishes a connection to a Laravel endpoint. The send function transmits JSON data, isStreaming provides a boolean for loading states, and data accumulates the incoming stream.

The Backend Stream Endpoint

On the server side, the controller manages the stream by flushing the output buffer as chunks are received from the OpenAI API.

public function stream(Request $request)
{
    return response()->stream(function () use ($request) {
        $messages = $request->input('messages', []);

        $stream = OpenAI::chat()->createStreamed([
            'model' => 'gpt-4',
            'messages' => $messages,
        ]);

        foreach ($stream as $response) {
            $chunk = $response->choices[0]->delta->content;
            if ($chunk !== null) {
                echo $chunk;
                ob_flush();
                flush();
            }
        }
    }, 200, [
        'Content-Type' => 'text/event-stream',
        'Cache-Control' => 'no-cache',
        'X-Accel-Buffering' => 'no',
    ]);
}

Using the useEventStream Hook

In this demo, useEventStream handles real-time title generation. When a new chat begins, it is initially marked as "Untitled." The system then prompts OpenAI for a concise title and streams it back to update the UI.

import { useEventStream } from '@laravel/stream-react';

function TitleGenerator({ chatId, onTitleUpdate, onComplete }) {
    const { message } = useEventStream(`/chat/${chatId}/title-stream`, {
        eventName: "title-update",
        endSignal: "</stream>",
        onMessage: (event) => {
            try {
                const parsed = JSON.parse(event.data);
                if (parsed.title) {
                    onTitleUpdate(parsed.title);
                }
            } catch (error) {
                console.error('Error parsing title:', error);
            }
        },
        onComplete: () => {
            onComplete();
        },
        onError: (error) => {
            console.error('EventStream error:', error);
            onComplete();
        },
    });

    return null;
}

Note the use of eventName to filter specific events. Multiple components can subscribe to this same stream; for instance, one component can update the main header while another refreshes the navigation sidebar.

Backend EventStream for Titles

use Illuminate\Http\StreamedEvent;

public function titleStream(Chat $chat)
{
    $this->authorize('view', $chat);

    return response()->eventStream(function () use ($chat) {
        if ($chat->title && $chat->title !== 'Untitled') {
            yield new StreamedEvent(
                event: 'title-update',
                data: json_encode(['title' => $chat->title])
            );
            return;
        }

        $firstMessage = $chat->messages()->where('type', 'prompt')->first();

        $response = OpenAI::chat()->create([
            'model' => 'gpt-4o-mini',
            'messages' => [
                [
                    'role' => 'system',
                    'content' => 'Generate a short, descriptive title (max 50 chars) for a chat that starts with this message. Return only the title, no quotes or extra formatting.'
                ],
                ['role' => 'user', 'content' => $firstMessage->content]
            ],
            'max_tokens' => 20,
            'temperature' => 0.7,
        ]);

        $title = trim($response->choices[0]->message->content);
        $chat->update(['title' => $title]);

        yield new StreamedEvent(
            event: 'title-update',
            data: json_encode(['title' => $title])
        );
    }, endStreamWith: new StreamedEvent(event: 'title-update', data: '</stream>'));
}

How It Fits Together

When a user sends their first message, the AI's reply streams back through useStream. Once that process finishes, the title generator triggers. The server requests a title from OpenAI, and the EventStream pushes the new title to the UI. The sidebar and header update instantly without requiring a page reload.

Advanced Features in the Demo

  • Full Authentication: Persistent chat history is reserved for registered users.
  • Dynamic Routing: Supports unique stream URLs for both guests and authenticated users.
  • Message Persistence: Complete AI responses are logged to the database for future sessions.
  • Error Handling: Includes graceful fallbacks for API timeouts or connectivity issues.

Handling CSRF Protection in Streaming Endpoints

While Inertia forms manage CSRF tokens automatically, streaming endpoints operate outside of Inertia's standard request lifecycle. Because useStream performs a direct POST to an API endpoint, you must handle the CSRF token manually.

Ensure the CSRF meta tag is in your root layout:

<meta name="csrf-token" content="{{ csrf_token() }}">

The useStream hook will attempt to read this automatically, or you can pass it explicitly during initialization:

const { send } = useStream('/chat/stream', {
    csrfToken: document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
});

This configuration allows you to maintain standard Inertia-driven pages while integrating high-performance real-time streaming in the same application.