To build a Laravel AI chatbot, you need five pieces: two Eloquent models for conversations and messages, a service class that calls an LLM API through Laravel's HTTP client, a controller that streams the reply back to the browser, a queued job for anything slow, and a Blade or Livewire view for the chat window. Everything else is polish.

Most tutorials on this topic stop at a single Http::post() call and a dd() of the response. That is a demo, not a chatbot. A real one has to remember the conversation, survive rate limits, stream tokens so the user is not staring at a spinner for eight seconds, and keep API keys out of the frontend. Those four problems are where every side project stalls.

You already know how to write a controller. What you probably have not decided yet is where the AI logic should live, how much conversation history to send on each request, and what to do when the provider returns a 429. This guide walks the whole build in order: schema, service layer, streaming controller, queued jobs, and a Livewire interface. Every snippet is standard Laravel that you can paste into a fresh app and run.

Key Takeaways

What a Laravel AI chatbot actually needs

Strip away the framing and a chatbot is a loop. The user sends text, you attach relevant history, the model returns text, you store both sides and render the reply. The complexity lives in the details around that loop.

Here is the full anatomy before you write any code:

Layer

Laravel artifact

Why it matters

Persistence

Conversation and Message models plus migrations

The model is stateless. Your database is the memory.

Provider contract

A service class bound in a service provider

Swapping OpenAI for Anthropic becomes a one line container change.

Transport

Http facade with retry() and timeout()

Rate limits and cold starts are normal, not exceptional.

Delivery

Streamed response, Livewire, or Broadcasting

Perceived speed is the whole user experience.

Background work

Queued jobs and workers

Embeddings, summaries, and moderation should never block a request.

Safety

FormRequest validation and rate limiting

A public chat endpoint is a metered, abusable resource.

A Laravel team lead at a mid size SaaS shipped an internal support bot in a week and thought they were done. Then a customer pasted a 40,000 word policy document into the chat box. No input validation, no token cap, no rate limit. One request burned a meaningful chunk of the month's API budget and returned a provider error the UI rendered as a blank bubble. The fix took an afternoon: a FormRequest with a character limit, a token budget in the service class, and a throttle middleware on the route. Build those in from the start and you skip that week entirely.

Which provider to pick

Any HTTP based LLM works because you are talking to a REST endpoint, not a PHP SDK. OpenAI's chat completions API and Anthropic's messages API both accept a JSON array of role and content pairs, so the shape of your code is nearly identical either way. Read the OpenAI chat completions reference or the Anthropic messages API docs for the exact payload keys before you start, since request fields change more often than Laravel's does.

Step 1, set up the project and store your API key

Start from a fresh app or an existing one. You need no packages for the core build, which is the point.

laravel new chatbot
cd chatbot
php artisan install:broadcasting --no-interaction

Add your credentials to .env, then wire them into config/services.php so you never touch env() outside a config file. Calling env() in application code breaks the moment you run php artisan config:cache in production.

// config/services.php
return [
    'llm' => [
        'key' => env('LLM_API_KEY'),
        'base_url' => env('LLM_BASE_URL', 'https://api.openai.com/v1'),
        'model' => env('LLM_MODEL', 'gpt-4o-mini'),
        'timeout' => (int) env('LLM_TIMEOUT', 60),
    ],
];

Two rules that save real debugging time later. Keep the key server side only, never in a Blade template or a Vite variable. And set an explicit timeout, because the PHP default will hang a queue worker on a slow generation.

Step 2, design the conversation schema

The model has no memory between requests. Your tables are what make a chat feel continuous, so get this right before anything else.

php artisan make:model Conversation -mf
php artisan make:model Message -mf

Two migrations. Conversations belong to a user and carry a title plus an optional rolling summary. Messages belong to a conversation and store the role, the content, and the token count you were billed for.

// database/migrations/xxxx_create_conversations_table.php
Schema::create('conversations', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('title')->nullable();
    $table->text('summary')->nullable();
    $table->timestamps();
});

// database/migrations/xxxx_create_messages_table.php
Schema::create('messages', function (Blueprint $table) {
    $table->id();
    $table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
    $table->enum('role', ['system', 'user', 'assistant']);
    $table->longText('content');
    $table->unsignedInteger('tokens')->default(0);
    $table->timestamps();

    $table->index(['conversation_id', 'created_at']);
});

That composite index matters more than it looks. Every single turn queries the last N messages for one conversation ordered by time, so without it you are scanning the whole table on every keystroke once you have real traffic.

Now the relationships and a scope that returns history in the exact shape the API wants.

// app/Models/Conversation.php
class Conversation extends Model
{
    protected $fillable = ['user_id', 'title', 'summary'];

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

    public function recentMessages(int $limit = 20): Collection
    {
        return $this->messages()
            ->latest()
            ->limit($limit)
            ->get()
            ->reverse()
            ->values();
    }

    public function toPromptMessages(): array
    {
        $history = $this->recentMessages()
            ->map(fn (Message $m) => ['role' => $m->role, 'content' => $m->content])
            ->all();

        if ($this->summary) {
            array_unshift($history, [
                'role' => 'system',
                'content' => "Earlier context: {$this->summary}",
            ]);
        }

        return $history;
    }
}

The latest() then reverse() pattern is deliberate. You want the newest 20 rows from the database, but the API needs them oldest first. Doing it this way keeps the query on the indexed column instead of sorting the full history in PHP.

Generating this schema, the relationships, and the FormRequests by hand takes an hour of careful typing. If you would rather describe the feature and review the result, you can generate a production-ready Laravel app from a prompt and keep every file it writes.

Build your Laravel app with AI

Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.

Get started free

Step 3, build the chatbot service class

Put every provider detail in one class. Controllers, jobs, and Livewire components then talk to your interface, not to OpenAI.

// app/Services/ChatbotService.php
namespace App\Services;

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

class ChatbotService
{
    public function __construct(
        private readonly string $key,
        private readonly string $baseUrl,
        private readonly string $model,
        private readonly int $timeout,
    ) {}

    public function reply(array $messages, ?string $systemPrompt = null): array
    {
        $payload = [
            'model' => $this->model,
            'messages' => $this->withSystemPrompt($messages, $systemPrompt),
            'temperature' => 0.7,
            'max_tokens' => 1024,
        ];

        $response = Http::withToken($this->key)
            ->timeout($this->timeout)
            ->retry(3, 2000, throw: false)
            ->post("{$this->baseUrl}/chat/completions", $payload)
            ->throw();

        return [
            'content' => $response->json('choices.0.message.content', ''),
            'tokens' => $response->json('usage.total_tokens', 0),
        ];
    }

    private function withSystemPrompt(array $messages, ?string $prompt): array
    {
        $prompt ??= config('chatbot.system_prompt');

        return array_merge(
            [['role' => 'system', 'content' => $prompt]],
            array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system')),
        );
    }
}

Bind it once in a service provider so the config lookup happens in exactly one place.

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->singleton(ChatbotService::class, fn () => new ChatbotService(
        key: config('services.llm.key'),
        baseUrl: config('services.llm.base_url'),
        model: config('services.llm.model'),
        timeout: config('services.llm.timeout'),
    ));
}

The retry(3, 2000) call is doing quiet heavy lifting. LLM APIs return 429s under normal load, and Laravel's HTTP client retry behaviour handles the backoff without a custom wrapper. Pair it with Http::fake() in your Pest tests and you can assert the whole chat flow without spending a cent on API calls.

Step 4, stream the response so the chat feels instant

This is the step most Laravel chatbot tutorials skip, and it is the one users notice. A non streamed reply means six to ten seconds of dead air. Streaming makes the same request feel immediate because tokens appear as they are generated.

On Laravel 12, response()->eventStream() gives you server sent events with almost no ceremony.

// app/Http/Controllers/ChatController.php
public function stream(SendMessageRequest $request, Conversation $conversation)
{
    Gate::authorize('update', $conversation);

    $conversation->messages()->create([
        'role' => 'user',
        'content' => $request->validated('message'),
    ]);

    $history = $conversation->toPromptMessages();

    return response()->eventStream(function () use ($conversation, $history) {
        $buffer = '';

        $stream = Http::withToken(config('services.llm.key'))
            ->timeout(config('services.llm.timeout'))
            ->withOptions(['stream' => true])
            ->post(config('services.llm.base_url').'/chat/completions', [
                'model' => config('services.llm.model'),
                'messages' => $history,
                'stream' => true,
            ]);

        foreach ($this->parseSse($stream->toPsrResponse()->getBody()) as $delta) {
            $buffer .= $delta;
            yield $delta;
        }

        $conversation->messages()->create([
            'role' => 'assistant',
            'content' => $buffer,
        ]);
    });
}

On Laravel 10 or 11, use a StreamedResponse and flush manually. Same idea, a little more plumbing.

return response()->stream(function () use ($history) {
    foreach ($this->tokens($history) as $delta) {
        echo "data: ".json_encode(['delta' => $delta])."\n\n";
        ob_flush();
        flush();
    }
    echo "data: [DONE]\n\n";
}, 200, [
    'Content-Type' => 'text/event-stream',
    'Cache-Control' => 'no-cache',
    'X-Accel-Buffering' => 'no',
]);

That X-Accel-Buffering: no header is not optional on Nginx. Without it the proxy buffers your entire stream and delivers it in one chunk, which looks exactly like streaming being broken. Check Laravel's event stream documentation for the client side EventSource setup.

Note what you are storing. The assistant message is only written after the stream completes, using the accumulated buffer. Write it before and you save an empty string; forget entirely and the next turn has amnesia.

Step 5, move slow work into queued jobs

The reply itself belongs in the request cycle since the user is waiting for it. Everything else does not.

Three jobs cover most chatbots:

  • Summarize the conversation once it passes a message threshold, then store the result on conversations.summary so you can trim history without losing context.

  • Generate embeddings for documents you want the bot to search, writing vectors to a dedicated table or a vector store.

  • Title the conversation from the first exchange, which is a cheap call that has no business blocking the first reply.

// app/Jobs/SummarizeConversation.php
class SummarizeConversation implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public array $backoff = [10, 60, 180];

    public function __construct(public Conversation $conversation) {}

    public function handle(ChatbotService $chatbot): void
    {
        $transcript = $this->conversation->messages()
            ->oldest()
            ->get()
            ->map(fn ($m) => "{$m->role}: {$m->content}")
            ->implode("\n");

        $result = $chatbot->reply(
            messages: [['role' => 'user', 'content' => $transcript]],
            systemPrompt: 'Summarize this conversation in under 200 words. Keep names, decisions, and open questions.',
        );

        $this->conversation->update(['summary' => $result['content']]);
    }
}

Dispatch it conditionally after each assistant message.

if ($conversation->messages()->count() % 20 === 0) {
    SummarizeConversation::dispatch($conversation)->afterResponse();
}

Set tries and backoff on every AI job. Provider errors are transient far more often than they are permanent, and a job that fails once and dies will silently degrade your bot's memory.

Step 6, wire up the chat interface

You have three sane options in Laravel, and the right one depends on what you already run.

Approach

Best when

Trade-off

Livewire component

You want minimal JavaScript and already use Livewire

Streaming needs wire:stream or a hybrid SSE endpoint

Blade plus Alpine and EventSource

You want true token streaming with no build step

You write the client parsing yourself

Inertia with React or Vue

The app is already an SPA

More moving parts for a single feature

The Livewire version is the shortest path to something working.

// app/Livewire/Chat.php
class Chat extends Component
{
    public Conversation $conversation;
    public string $draft = '';

    public function send(ChatbotService $chatbot): void
    {
        $this->validate(['draft' => 'required|string|max:4000']);

        $this->conversation->messages()->create([
            'role' => 'user',
            'content' => $this->draft,
        ]);

        $prompt = $this->draft;
        $this->draft = '';

        $result = $chatbot->reply($this->conversation->toPromptMessages());

        $this->conversation->messages()->create([
            'role' => 'assistant',
            'content' => $result['content'],
            'tokens' => $result['tokens'],
        ]);
    }

    public function render()
    {
        return view('livewire.chat', [
            'messages' => $this->conversation->recentMessages(50),
        ]);
    }
}

Two guardrails before this goes anywhere public. Validate length in a FormRequest or the component itself, and rate limit the route per authenticated user.

// routes/web.php
Route::middleware(['auth', 'throttle:chat'])->group(function () {
    Route::post('/conversations/{conversation}/stream', [ChatController::class, 'stream']);
});

// app/Providers/AppServiceProvider.php
RateLimiter::for('chat', fn (Request $request) => Limit::perMinute(20)->by($request->user()->id));

A solo developer building a documentation assistant learned this the direct way. The endpoint was public so the marketing site could embed it, with no throttle and no auth. Within two days a scraper was hammering it in a loop and the API bill for that week exceeded the hosting cost for the quarter. Auth plus throttle:20,1 ended it in a single commit. Treat a chat endpoint like a paid resource, because that is exactly what it is.

Testing and deploying your Laravel chatbot

The reason to route everything through Http and a service class is that both are trivially fakeable. Your test suite never needs a live key.

it('stores both sides of the exchange', function () {
    Http::fake([
        '*/chat/completions' => Http::response([
            'choices' => [['message' => ['content' => 'Migrations run automatically.']]],
            'usage' => ['total_tokens' => 42],
        ]),
    ]);

    $conversation = Conversation::factory()->create();

    app(ChatbotService::class)->reply([
        ['role' => 'user', 'content' => 'How do deploys work?'],
    ]);

    Http::assertSent(fn ($request) => $request->hasHeader('Authorization'));
});

Also test the failure path. Fake a 429 and confirm your retry fires, then fake a 500 and confirm the user sees a readable error instead of a stack trace.

For deployment, three things break AI chatbots that work fine locally:

  1. Queue workers are not running. Summaries and embeddings silently never happen. Use Supervisor or Horizon and monitor the failed jobs table.

  2. Proxy buffering kills the stream. Set X-Accel-Buffering: no and confirm your host does not buffer SSE at the edge.

  3. PHP timeouts fire mid generation. Raise max_execution_time for the streaming route and give the queue worker a longer --timeout than your longest expected call.

If you would rather skip the scaffolding entirely, Try StackMivo Now to generate the models, migrations, controllers, and Pest tests for a feature like this, then read and edit every file as standard Laravel.

Where to go next with your chatbot

Once the loop works, the interesting upgrades are all additive.

  • Retrieval augmented generation. Embed your docs, store the vectors, and inject the top matches into the system prompt so the bot answers from your content instead of guessing.

  • Tool calling. Let the model request a function, run it in a Laravel action, and feed the result back. This is how a chatbot goes from answering questions to actually checking an order status.

  • Per user cost tracking. You already store tokens on every message. Sum it by user and you have usage based billing without new infrastructure.

  • Moderation. Run user input through a moderation endpoint in a queued job before it reaches your main prompt.

Each one reuses the same service class and the same tables. That is the payoff for structuring the boring parts properly first.

Wrapping up

Building a Laravel AI chatbot comes down to five decisions made in the right order. Store conversations and messages in real tables with an index on the columns you query every turn. Put the provider contract in one injectable service class so a model swap is a config change. Stream the response, because perceived latency is the difference between a tool people use and a tool they abandon. Push summaries, embeddings, and titling into queued jobs with sensible retries. Then lock the endpoint behind auth, validation, and a rate limiter before anyone else can reach it.

None of this needs a package or a framework you do not already have. Eloquent, the HTTP client, queues, and either Livewire or Blade cover the entire build. Start with the schema, get one non streamed reply working end to end, then add streaming and background jobs once the loop is proven.

The developers shipping useful AI features right now are not the ones with the cleverest prompts. They are the ones who treated the chatbot as ordinary Laravel plumbing and got the boring layers right.

Build your Laravel app with AI

Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.

Get started free