Laravel Daily's

Streaming AI Responses in Laravel: Real-Time Chat with the AI SDK

hero image

AI chat feels slow when the browser waits for the complete response. Streaming fixes that gap. Your Laravel application sends each generated text fragment as soon as it arrives.

The Laravel AI SDK provides this flow through the stream() method. It supports Server-Sent Events (SSE), Livewire 4, the Vercel AI protocol, and integrations with frontend agent protocols such as AG-UI.

This tutorial builds a small chat endpoint. It then adapts the endpoint for Livewire and JavaScript clients.

Start with the Laravel AI SDK

Install the SDK with Composer:

composer require laravel/ai

php artisan vendor:publish \
    --provider="Laravel\Ai\AiServiceProvider"

php artisan migrate

Add your provider key to .env:

OPENAI_API_KEY=your-api-key

The SDK supports OpenAI, Anthropic, Gemini, Mistral, Groq, Ollama, OpenRouter, and other providers. You can configure the default provider and model in config/ai.php.

Create an agent:

php artisan make:agent ChatAgent

A minimal agent can use the Agent contract and Promptable trait:

<?php

namespace App\Ai\Agents;

use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;

class ChatAgent implements Agent
{
    use Promptable;

    public function instructions(): string
    {
        return 'You are a concise assistant for a Laravel application.';
    }
}

An agent keeps prompting logic in one place. You can add conversation history, tools, structured output, or provider attributes later.

Illustration of Laravel, PHP, SSE, and a browser connected in a streaming pipeline

How stream() works

The stream() method returns a StreamableAgentResponse. You can return that response directly from a Laravel route:

use App\Ai\Agents\ChatAgent;
use Illuminate\Support\Facades\Route;

Route::get('/coach', function () {
    return (new ChatAgent)->stream(
        'Explain Laravel middleware in three short points.'
    );
});

Laravel sends the result as an SSE response. The client receives events incrementally instead of waiting for the model to finish.

SSE uses a long-lived HTTP response. Each message is sent as an event, usually with a data: line followed by a blank line. It works well for one-way server-to-browser communication, which matches the shape of an AI response.

The SDK also lets you run post-processing after the stream ends:

use Laravel\Ai\Responses\StreamedAgentResponse;

return (new ChatAgent)
    ->stream($prompt)
    ->then(function (StreamedAgentResponse $response) {
        // Persist $response->text or record usage.
    });

This is useful for storing transcripts, tracking usage, or dispatching application events.

Build a working chat endpoint

Create a POST route in routes/api.php:

use App\Ai\Agents\ChatAgent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/chat/stream', function (Request $request) {
    $data = $request->validate([
        'message' => ['required', 'string', 'max:4000'],
    ]);

    return (new ChatAgent)
        ->forUser($request->user())
        ->stream($data['message']);
})->middleware('auth:sanctum');

forUser() is optional. Use it when the agent implements conversation persistence or needs user-specific context.

The endpoint now accepts JSON and returns an SSE stream:

curl -N https://example.com/api/chat/stream \
    -H "Accept: text/event-stream" \
    -H "Authorization: Bearer YOUR_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"message":"What makes Laravel productive for PHP teams?"}'

The -N option disables curl buffering. Without it, curl may wait before displaying the received chunks.

Consume the stream with browser JavaScript

EventSource only supports GET requests. Chat endpoints usually use POST, so use fetch() and a ReadableStream:

const response = await fetch('/api/chat/stream', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Accept': 'text/event-stream',
    },
    body: JSON.stringify({ message: prompt }),
});

const reader = response.body.getReader();
const decoder = new TextDecoder();

let answer = '';

while (true) {
    const { value, done } = await reader.read();

    if (done) break;

    answer += decoder.decode(value, { stream: true });
    document.querySelector('#answer').textContent = answer;
}

This basic client displays raw SSE data. A production client should parse event boundaries and handle errors explicitly.

Stream tokens into Livewire 4

Livewire 4 provides wire:stream for updating part of a page before the request completes. This is useful for chat interfaces because the component can forward each TextDelta event to the browser.

<?php

namespace App\Livewire;

use App\Ai\Agents\ChatAgent;
use Laravel\Ai\Streaming\Events\TextDelta;
use Livewire\Component;

class Chat extends Component
{
    public string $prompt = '';

    public function ask(): void
    {
        $answer = '';

        foreach ((new ChatAgent)->stream($this->prompt) as $event) {
            if (! $event instanceof TextDelta) {
                continue;
            }

            $answer .= $event->delta;

            $this->stream(
                to: 'answer',
                content: $answer,
                replace: true,
            );
        }
    }

    public function render()
    {
        return view('livewire.chat');
    }
}

Bind the stream target in the Blade view:

<div>
    <form wire:submit="ask">
        <input wire:model="prompt" placeholder="Ask a question...">
        <button type="submit">Send</button>
    </form>

    <article wire:stream.replace="answer">
        Waiting for a response...
    </article>
</div>

TextDelta::$delta contains the next text fragment. The example accumulates the answer and replaces the target on every update. This approach avoids duplicated text when each event contains only a partial chunk.

You can also append content directly:

$this->stream(
    to: 'answer',
    content: $event->delta,
);

Use appending when the target starts empty and every update is a new fragment. Use replacement when you need to render accumulated Markdown or maintain a complete answer.

Livewire’s wire:stream documentation notes that this feature is not currently compatible with Laravel Octane. Test the deployment model before enabling Octane for a Livewire streaming page.

Illustration of Livewire 4 streaming incremental AI content into a Laravel chat component

Use the Vercel AI protocol with JavaScript clients

The Laravel AI SDK can emit the Vercel AI SDK data stream protocol:

Route::post('/chat/vercel', function (Request $request) {
    $data = $request->validate([
        'message' => ['required', 'string', 'max:4000'],
    ]);

    return (new ChatAgent)
        ->stream($data['message'])
        ->usingVercelDataProtocol();
});

This response remains SSE, but its data: payloads follow the format expected by Vercel AI SDK UI clients. The SDK also sends the x-vercel-ai-ui-message-stream: v1 header.

The protocol supports text blocks, tool inputs, tool outputs, finish events, and a final [DONE] marker. A JavaScript client can use the standard transport:

import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';

const { messages, sendMessage } = useChat({
    transport: new DefaultChatTransport({
        api: '/api/chat/vercel',
    }),
});

The exact client package can vary by AI SDK version. The important part is the transport contract. Your Laravel route must call usingVercelDataProtocol(), and the client must expect a data stream rather than a plain text stream.

Read the Vercel stream protocol documentation when implementing a custom client. It documents message start, text start, text delta, text end, tool, finish, and termination parts.

AG-UI protocol support

AG-UI is a separate, framework-agnostic protocol for connecting agents to user interfaces. It uses structured, event-based streams.

The Laravel AI SDK does not make a Vercel data stream an AG-UI stream automatically. Treat them as two different protocols.

You can support AG-UI by adding a translation layer around the Laravel stream. The main mappings are straightforward:

  • TextDelta becomes an AG-UI text message content event.
  • The first text event creates a message with a messageId.
  • Later deltas reuse that ID.
  • Stream completion becomes a text message end and run finished event.
  • Tool calls map to AG-UI tool call start, arguments, end, and result events.

AG-UI expects lifecycle events such as RunStarted and RunFinished. Its text messages follow a start-content-end pattern. The TextMessageContent event carries a delta that clients concatenate in order.

For a production AG-UI endpoint, serialize these events as JSON over SSE and follow the current AG-UI event specification. Keep this adapter separate from your regular Vercel endpoint. Each client then receives the protocol it understands.

Illustration of Laravel interoperating with Vercel AI, AG-UI, SSE, and JavaScript clients

Server configuration gotchas

Application code can be correct while the server buffers every token. The result looks like a normal blocking response.

For Nginx, disable proxy buffering on the streaming location:

location /api/chat/ {
    proxy_pass http://php_app;

    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_cache off;

    proxy_set_header Connection "";
    proxy_read_timeout 300s;
}

If you create a custom response()->stream() response, add the buffering header:

return response()->stream($callback, 200, [
    'Content-Type' => 'text/event-stream',
    'Cache-Control' => 'no-cache',
    'X-Accel-Buffering' => 'no',
]);

X-Accel-Buffering: no tells Nginx not to buffer the response. Set it at the application or reverse-proxy layer, depending on your architecture.

Also check CDN settings, load balancer idle timeouts, PHP-FPM timeouts, and platform response limits. A proxy can buffer or terminate a stream even when Nginx is configured correctly.

Use a keep-alive event if your provider pauses for a long time. This prevents intermediaries from treating the connection as idle.

Make streaming part of your REST API

A streaming chat endpoint is still a REST endpoint. It accepts an HTTP request, authenticates the caller, validates input, and returns a documented response format.

For larger applications, move the route logic into a controller:

final class ChatStreamController
{
    public function __invoke(ChatRequest $request)
    {
        return (new ChatAgent)
            ->forUser($request->user())
            ->stream($request->string('message'));
    }
}

Register it in routes/api.php:

Route::post('/chat/stream', ChatStreamController::class)
    ->middleware('auth:sanctum');

Document the endpoint as text/event-stream, not application/json. Include authentication, validation limits, cancellation behavior, and the event format in your API documentation.

This is a practical way to build rest api with php while keeping the user experience responsive. Laravel supplies the routing, authentication, validation, and AI integration. Your frontend receives useful output as soon as the model generates it.

Closing thoughts

Streaming does not require a separate real-time infrastructure layer for a single chat session. Laravel’s AI SDK handles the provider stream, SSE response, and protocol conversion.

Use direct stream() responses for simple clients. Use $this->stream() with Livewire 4 when the page already belongs to a Livewire component. Use usingVercelDataProtocol() for Vercel AI clients, and add an explicit event adapter for AG-UI.

The result is a focused stack of PHP developer tools around a capable php web framework. Each layer keeps its responsibility clear, so your team can ship real-time AI features without rebuilding the transport from scratch.

Previous
Building an AI-Powered Laravel Support Assistant with OpenAI and Laravel Boost
Next
Building Fast, Modern SPAs with Laravel, Vue, and Inertia