laraveleasyai maintained by easybdit
📺 Video Tutorials
Why LaravelAI?
Building AI features in Laravel normally means separate SDKs, different formats, and custom error handling for every provider. LaravelAI eliminates all of that.
// Same code. Any provider. Just change the name.
$response = AI::provider('ollama')->chat($messages); // Self-hosted, free
$response = AI::provider('openai')->chat($messages); // ChatGPT
$response = AI::provider('anthropic')->chat($messages); // Claude
$response = AI::provider('deepseek')->chat($messages); // DeepSeek
Built on Laravel's driver pattern — same architecture as Mail, Cache, and Queue.
What you get, all in one package
- 8 providers, one interface — Ollama (free, self-hosted), OpenAI, Anthropic, DeepSeek, Groq, Gemini, Together AI, and any custom OpenAI-compatible endpoint. Switch providers by changing one string, never your calling code.
- A ready-made chat UI —
/ai-chatis live the moment you install it. No frontend to build. - RAG built in — ingestion, search, and a pluggable vector store (
pgvectorincluded), no separate vector-database service to stand up first. - Tool-calling and structured output, working the same way across every provider above — including Anthropic, which has no native JSON mode, handled transparently via a forced tool call under the hood.
- Response caching and automatic retry with backoff, both opt-in, one
.envflag each. - Conversation export to PDF, Word, and PowerPoint, built in.
- PHP 8.1+, Laravel 10–13 — and free to try right now: point
AI_PROVIDER=ollamaat a local model, no API key needed.
Three real scenarios where that adds up:
1. You want to try AI in your app without spending anything or waiting on an API key.
composer require easybdit/laraveleasyai
php artisan laravelai:install # pick "Ollama" — free, self-hosted, no key needed
echo ai('What is Laravel?'); // running against a model on your own machine
2. You need a working chat screen, not just an API client.
php artisan laravelai:install # publishes /ai-chat, migrates, done
Visit /ai-chat — a full ChatGPT-style window (streaming, history, file uploads, exports) is already there. No React/Vue build step, no writing a chat frontend from scratch.
3. You want the model to answer from your own documents, not just its training data.
use EasyAI\LaravelAI\Facades\AI;
AI::rag()->ingest('Refunds are accepted within 30 days of purchase.', 'policies');
$answer = AI::rag()->ask('What is the refund window?');
// "Refunds are accepted within 30 days of purchase."
No separate vector-database service to stand up first — the built-in scan backend works out of the box on your existing SQL database; swap in pgvector later only if you outgrow it.
📦 Installation
📘 Prefer a single narrative walkthrough of every feature with real examples, install to done? See the Setup Guide. This README is the full reference.
🎓 Never used an AI package before? Start with the Tutorial instead — it walks you through building one real, complete feature (a support chatbot with a knowledge base, tool calling, image generation, and cost tracking) step by step, taught the way a senior dev would pair with you on it. The Setup Guide above is for once you already know the package and just need a specific snippet.
Step 1: Install via Composer
composer require easybdit/laraveleasyai
Step 2: Run the guided installer — publishes config/assets, runs migrations, and walks you through picking a provider (Ollama, OpenAI, Anthropic, DeepSeek, or Gemini), all in one interactive command:
php artisan laravelai:install
It asks before overwriting anything that already exists, and never clobbers an .env value you've already set — safe to run again later if you just want to add a second provider's key.
Step 3: Visit /ai-chat in your browser ✅
php artisan vendor:publish --tag=ai-config
php artisan vendor:publish --tag=ai-chat-assets
php artisan migrate
Then add to .env yourself:
AI_PROVIDER=ollama
AI_OLLAMA_URL=http://127.0.0.1:11434
AI_OLLAMA_MODEL=qwen2:1.5b
Requirements
| Requirement | Version |
|---|---|
| PHP | 8.1+ |
| Laravel | 10, 11, 12, 13 |
🚀 Quick Start
use EasyAI\LaravelAI\Facades\AI;
$response = AI::chat([['role' => 'user', 'content' => 'What is Laravel?']]);
echo $response->content;
One-Liner Helper
$answer = ai('What is Laravel?');
Test in Tinker
php artisan tinker
>>> AI::provider('ollama')->health()
=> true
>>> ai('Say hello in 3 words')
=> "Hello there, friend!"
💬 Built-in Chat UI
New in v1.3.0 — A full ChatGPT-like chat app included. Zero setup required.
What you get out of the box
| Feature | Description |
|---|---|
| 💬 Chat UI | ChatGPT-like sidebar with session history |
| ⚡ Streaming | Real-time typing effect |
| 📝 Markdown | Full rendering with syntax-highlighted code |
| 📋 Copy buttons | Per message and per code block |
| 🔄 Provider switcher | Switch Ollama / OpenAI / Claude / DeepSeek live |
| 💾 DB persistence | History survives page refresh |
| 🏷️ Auto-title | First message becomes session title |
| 📁 Projects | RAG-powered knowledge bases (v1.4.0) |
| 📦 Offline assets | No CDN dependency |
| 🔒 Security suite | Rate limiting, access control, captcha, GDPR gate (v2.0.0) |
| 📎 Attachments | Vision + document uploads mid-chat (v2.0.0) |
| 🧩 Floating widget | <x-laravelai::widget /> — embeddable anywhere (v2.0.0) |
| 📊 Analytics | Usage dashboard, zero external tracking (v2.0.0) |
| 📱 Responsive | Off-canvas sidebar drawer, full-screen modals, and touch-sized controls below 768px |
| 🧠 Live thinking | Collapsible "Thinking…" block for reasoning models (Ollama qwen3, Anthropic extended thinking, Gemini 2.5) instead of dead air |
Customize the view
php artisan vendor:publish --tag=ai-chat-views
# → resources/views/vendor/laravelai/chat.blade.php
Routes registered automatically
| Method | URL | Description |
|---|---|---|
| GET | /ai-chat |
Chat UI |
| GET | /ai-chat/analytics |
Usage dashboard |
| POST | /ai-chat/api/sessions |
Create session |
| DELETE | /ai-chat/api/sessions/{id} |
Delete session |
| POST | /ai-chat/api/stream |
SSE streaming (POST — see note below) |
| POST | /ai-chat/api/provider |
Switch provider |
| GET | /ai-chat/api/captcha |
Fetch a math captcha challenge |
| POST | /ai-chat/api/messages/{id}/feedback |
👍/👎 a message |
| POST | /ai-chat/api/attachments |
Upload a chat attachment (image/document) |
| GET | /ai-chat/api/attachments/{id} |
Stream an attachment back |
| DELETE | /ai-chat/api/attachments/{id} |
Delete an attachment |
| GET | /ai-chat/api/projects |
List projects |
| POST | /ai-chat/api/projects |
Create project |
| DELETE | /ai-chat/api/projects/{id} |
Delete project |
| GET | /ai-chat/api/projects/{id}/files |
List project files |
| POST | /ai-chat/api/projects/{id}/files |
Upload & ingest file |
| POST | /ai-chat/api/projects/{id}/files/{fid}/reprocess |
Re-chunk a file in place |
| DELETE | /ai-chat/api/projects/{id}/files/{fid} |
Delete file |
| POST | /ai-chat/api/rag/test |
Inspect matched chunks for a query |
/ai-chat/api/streammoved from GET to POST in v2.0.0 — a 4000-character message no longer risks the URL length limit, and the built-in view reads the response viafetch()+ a manual SSE parser (notEventSource) so blocked/rate-limited JSON error responses are actually readable.
v2.1.1 —
/ai-chat/api/streamnow survives replies that run long (a reasoning model thinking + writing a full answer can easily take a few minutes). Before this fix, a slow reply could hit your server'smax_execution_timeand vanish after the user had already watched it finish streaming — nothing showed as an error, it just wasn't there on the next page load. See Troubleshooting → A reply appears while streaming, then disappears after reload for the real example and the fix.
🗂️ Projects & Knowledge Bases
New in v1.4.0 — Self-hosted Claude-like Projects. Create knowledge bases, upload documents, and get RAG-powered answers scoped per project.
How it works
Create Project → Upload Files → Chat Inside Project → RAG answers from your docs
- Click + next to Projects in the sidebar
- Upload
.txt,.md, or.pdffiles — auto-ingested into RAG on upload - Click the project to start a new RAG-powered chat session
- Every message retrieves relevant context from that project's documents only
- Normal chats outside projects are completely unaffected
What you see in the UI
- 📁 Projects section in sidebar with file count badge
- 🧠 RAG ON badge in chat header when inside a project session
- 📎 Manage Files button — upload, view ingestion status, delete files
- 🟢 Status per file:
pending→ingested→failed - Project context active indicator in the input footer
PDF support (optional)
composer require smalot/pdfparser
RAG Scoping API
$results = AI::rag()->source('project_5')->search('your query');
$answer = AI::rag()->source('project_5')->ask('your question');
AI::rag()->flush('project_5');
🧠 RAG (Built-in)
No external vector database required — uses your existing SQL database.
Setup
Free/self-hosted via Ollama (the default):
ollama pull nomic-embed-text
php artisan migrate
AI_RAG_PROVIDER=ollama
AI_RAG_EMBED_MODEL=nomic-embed-text
Or point RAG's embedding step at OpenAI or Gemini instead — no Ollama dependency at all, useful if your app already only talks to a paid provider:
AI_RAG_PROVIDER=openai
AI_RAG_EMBED_MODEL=text-embedding-3-small
AI_RAG_PROVIDER=gemini
AI_RAG_EMBED_MODEL=gemini-embedding-001
AI_RAG_PROVIDER just selects which driver's ->embed() RAG calls internally — same config key regardless of provider. Not every provider has a real embeddings API to switch to: Anthropic doesn't offer one at all (they point to a third-party, Voyage AI, entirely outside this package's scope), and among the OpenAI-compatible drivers, Together AI has a genuine one but Groq and DeepSeek don't — pick from Ollama, OpenAI, Gemini, or Together.
Usage
// Store
AI::rag()->ingest('Laravel is a PHP framework using MVC.', 'docs');
// Ask
$answer = AI::rag()->ask('What is Laravel?');
// Search
$results = AI::rag()->search('MVC pattern');
// [['content' => '...', 'source' => 'docs', 'score' => 0.91]]
// Scoped
$results = AI::rag()->source('project_5')->search('your query');
// Flush
AI::rag()->flush();
AI::rag()->flush('project_5');
Artisan
php artisan ai:rag:ingest storage/docs/manual.txt --source=manual
php artisan ai:rag:ingest storage/docs/ --flush
Accurate counting, test queries, reprocessing
New in v2.0.0
// Full-corpus keyword scan for "how many X" questions — top-K semantic
// search alone under-counts when relevant chunks rank outside K.
AI::rag()->countMatches('invoice', 'project_5');
POST /ai-chat/api/rag/test { "query": "refund policy", "source": "project_5" }
POST /ai-chat/api/projects/5/files/12/reprocess # re-chunk in place, no re-upload
Auto-index your own models ("Ask This Site")
Keep RAG in sync with any Eloquent model on save/delete — opt-in, empty by default:
// config/ai.php
'rag' => [
'auto_index' => [
'posts' => [
'model' => \App\Models\Post::class,
'source' => 'site_posts',
'text' => fn ($post) => $post->title . "\n\n" . strip_tags($post->body),
'when' => fn ($post) => $post->is_published, // optional
],
],
],
Chat sessions with no project attached automatically pull context from every auto-indexed source once at least one entry is configured.
RAG Configuration
.env Key |
Default | Description |
|---|---|---|
AI_RAG_PROVIDER |
ollama |
Embedding provider |
AI_RAG_EMBED_MODEL |
nomic-embed-text |
Embedding model |
AI_RAG_CHUNK_SIZE |
2000 |
Max chars per chunk |
AI_RAG_TOP_K |
3 |
Chunks retrieved per query |
AI_RAG_TABLE |
ai_documents |
Database table |
AI_RAG_MAX_SCAN_ROWS |
50000 |
Safety cap on rows scanned per search — raise it if your knowledge base is genuinely bigger and you'd rather wait than get a capped answer |
Outgrown the built-in scan? Bring your own vector store
The built-in RAG search is genuinely fine up to tens of thousands of chunks (an in-PHP cosine scan, memory-bounded — see AI_RAG_MAX_SCAN_ROWS above), but it's not a substitute for a real vector database at large scale. Bind your own VectorStoreInterface implementation and RAGManager delegates ingestion/search/flush to it entirely — every existing install (nothing bound) keeps the exact same built-in behavior.
interface VectorStoreInterface
{
public function upsert(string $content, string $source, array $embedding): void;
public function search(array $queryEmbedding, ?string $source, int $topK): array;
public function delete(?string $source): void;
public function count(?string $source): int;
}
A working Postgres + pgvector implementation ships out of the box — no new Composer dependency, just raw SQL against the extension:
$this->app->bind(
\EasyAI\LaravelAI\RAG\Contracts\VectorStoreInterface::class,
\EasyAI\LaravelAI\RAG\VectorStores\PgVectorStore::class
);
One-time setup (CREATE EXTENSION vector; + the table DDL) is documented directly in PgVectorStore's class docblock. Want Pinecone, Weaviate, Milvus, or anything else instead? Implement the same four methods.
One scope boundary worth knowing: the "Ask This Site" auto-indexer's search (
searchAutoIndexed()) isn't delegated — its multi-source prefix match doesn't fit this contract's single-sourcesearch(), so it always uses the built-in table regardless of what's bound.
🔒 Security & Trust
New in v2.0.0 — everything below is config/env-driven (
config/ai.php→chat); there's no settings database or admin panel, by design — your app already has its own way to manage config.
| Setting | .env key |
Default |
|---|---|---|
| Rate limit (per identity) | AI_CHAT_RATE_LIMIT_MAX / _WINDOW |
20 / 60s |
| Rate limit (per IP, hard cap) | AI_CHAT_RATE_LIMIT_IP_MAX |
60 |
| Access restriction | AI_CHAT_ACCESS_RESTRICTION |
everyone (also: auth, role, gate) |
| IP blocklist | AI_CHAT_IP_BLOCKLIST |
— (comma-separated) |
| Word filter | AI_CHAT_WORD_FILTER_ENABLED / _WORDS |
off |
| Prompt-injection detection | AI_CHAT_PROMPT_INJECTION_DETECT |
off |
| No-storage mode | AI_CHAT_DISABLE_STORAGE |
off |
| Math captcha (first message) | AI_CHAT_CAPTCHA_ENABLED |
off |
| Abuse-alert email | AI_CHAT_ABUSE_ALERT_ENABLED / _EMAIL |
off |
| GDPR consent gate | AI_CHAT_GDPR_GATE_ENABLED |
off |
| Lock system prompt | AI_CHAT_LOCK_SYSTEM_PROMPT |
off |
| Max message length | AI_CHAT_MAX_MESSAGE_LENGTH |
4000 |
"Role" restriction looks for $user->hasAnyRole([...]) (configurable method name — works with Spatie permission out of the box) or a roles collection/array on the user model. "Gate" restriction checks a named Laravel Gate you define yourself (AI_CHAT_ACCESS_GATE, defaults to use-ai-chat).
Internal exception detail (e.g. "No API key configured for OpenAI") is only shown to callers who pass the same gate — everyone else gets a generic "temporarily unavailable" message. Set AI_CHAT_SHOW_INTERNAL_ERRORS=true to always show real errors (e.g. in a staging environment).
⚙️ Provider Settings UI & Auth Guard
Change providers/API keys from /ai-chat/settings instead of editing .env and redeploying. .env stays the source of truth for everything you never touch in the UI — this is a purely additive override layer, not a replacement.
Fail-closed by default — nobody can reach it until you grant access, and there's no "everyone" mode for editing API keys, regardless of how the main chat's own access restriction is configured. Grant the first admin from the CLI:
php artisan laravelai:make-admin your@email.com
Log in as that user and /ai-chat/settings just works — a "👤 Admin Access" panel right there lets that admin add or remove other admins by email, no code changes or redeploys needed for every admin after the first. php artisan laravelai:install also offers to do this for you as part of the guided setup.
Why it might say "No user found": this command grants access to an existing account, it doesn't create one — and a fresh Laravel install has no users yet. Create one first, then re-run the command:
php artisan tinker
\App\Models\User::create([
'name' => 'Your Name',
'email' => 'your@email.com',
'password' => bcrypt('choose-a-real-password'),
]);
Or, if you'd rather register through a real UI, scaffold one first (e.g. composer require laravel/breeze --dev && php artisan breeze:install), sign up at /register, then run laravelai:make-admin against that email.
Already have your own roles/permissions system? Define the Gate yourself anywhere in your app (AppServiceProvider::boot() is the usual place) and it takes over completely — this package's own default only ever applies when nothing else has claimed the ability:
Gate::define('manage-ai-settings', fn ($user) => $user->hasRole('admin'));
Secrets are masked in the UI (last 4 characters only) and never overwritten unless you actually type a new value; blanking a field deletes the override and falls back to .env again.
Storage: API keys are encrypted (Laravel's Crypt, your app's APP_KEY) before ever touching the database — never stored in plaintext. Every provider driver's health() (used by the page's "Test connection" button) is also audited to confirm none of them can leak a credential through an exception message.
Auth-gating the chat itself is a separate, independent knob — public by default:
AI_CHAT_MIDDLEWARE=auth # require login for the whole /ai-chat area; leave unset for a public page
📊 Usage & Cost Tracking
A "Usage & Costs" tab lives right next to "Providers" on the same Settings page — off by default, flip it on from .env or with the checkbox right there on the tab itself (saved through the same SettingsOverlay as everything else):
AI_USAGE_LOGGING_ENABLED=true
Upgrading from an older version? This adds one new table — run php artisan migrate first. The tab itself still renders even before you have (with a short "run migrate" hint instead of blank stats), so nothing breaks if you flip the checkbox on ahead of it.
Once on, every chat()/generateImage() call this package's drivers make — from the bundled chat UI and from your own PHP code calling AI::provider(...) directly — appends a row to ai_usage_logs: provider, model, chat vs. image, token/image counts, and an estimated USD cost. The tab shows total spend (all-time and this month), a breakdown by provider, and the most recent calls.
Cost estimation reuses the same config('ai.pricing') rates as getEstimatedCost() — empty by default, so cost shows as "—" until you fill in a rate for the exact model you're using. Image generation adds a second rate shape under each provider's image key:
'pricing' => [
'openai' => [
'image' => ['dall-e-3' => 0.04], // flat USD per image
],
'together' => [
'image' => ['black-forest-labs/FLUX.1-schnell' => ['per_mp' => 0.0027]], // USD per megapixel
],
],
Same fail-safe posture as everything else on this page: no rate configured just means that row's cost shows as unconfigured, never a guessed number, and a logging problem (migration not yet run, DB briefly down) is swallowed rather than breaking the AI call it rode in on.
💬 Chat UX & Personalization
New in v2.0.0
Stop & regenerate, message timestamps, per-message and whole-conversation export, read-aloud (browser SpeechSynthesis), voice input (browser SpeechRecognition — Chrome/Edge/Safari), fullscreen mode, 👍/👎 feedback, and client-side session search all ship in the default resources/views/chat.blade.php — no configuration needed, some gated by AI_CHAT_VOICE_INPUT_ENABLED / AI_CHAT_TTS_ENABLED / AI_CHAT_EXPORT_ENABLED.
Welcome message, suggested-question chips, a custom AI avatar, and accent/bubble colors are all config('ai.chat.ui') / env-driven:
AI_CHAT_WELCOME_ENABLED=true
AI_CHAT_WELCOME_MESSAGE="Hello! How can I help you today?"
AI_CHAT_SUGGESTED_QUESTIONS="What can you help with?|Summarize a document|Write me an email"
AI_CHAT_AVATAR_URL=https://example.com/bot-avatar.png
AI_CHAT_COLOR_ACCENT=#6366f1
Bot profiles
Named presets — provider, title, system prompt — loadable per session:
// config/ai.php
'bot_profiles' => [
'support' => [
'label' => 'Support Bot',
'provider' => 'openai',
'system_prompt' => 'You are a friendly support agent for Acme Inc.',
],
],
POST /ai-chat/api/sessions { "profile": "support" }
Floating widget (embeddable anywhere)
A self-contained launcher + chat panel — talks to the same session/stream API, ships its own scoped CSS and JS, and works on any Blade view without loading the full /ai-chat app:
<x-laravelai::widget position="bottom-right" label="Chat with us" profile="support" />
Export as PDF, Word, Excel, or PowerPoint
The export button next to the chat header is a dropdown — plain text works with zero setup (client-side), the other four are optional server-side dependencies, install only the ones you want:
composer require dompdf/dompdf # PDF
composer require phpoffice/phpword # Word (.docx)
composer require phpoffice/phpspreadsheet # Excel (.xlsx) — one row per message
composer require phpoffice/phppresentation # PowerPoint (.pptx) — one slide per message
Not installed yet? The download attempt shows exactly which command to run instead of failing silently. PowerPoint is the odd one out of the four — slides don't paginate long text the way a document does, so it suits short conversations best; PDF or Word read better for a long transcript.
📎 Attachments & Vision
New in v2.0.0 —
AI_CHAT_ATTACHMENTS_ENABLED=true
Upload images and documents (.txt/.md/.pdf) mid-chat. Documents are text-extracted and appended as context for every provider; images become real vision input for OpenAI, Anthropic, and Gemini (via a universal multipart message format translated per-provider), with a "this provider can't view images" fallback note for everyone else.
📊 Analytics & Webhooks
Visit /ai-chat/analytics for a zero-external-tracking dashboard — total conversations/messages, messages today, active chats (7d), most-used provider, a 7-day bar chart, and feedback stats — computed entirely from your own ai_chat_sessions/ai_chat_messages tables.
AI_CHAT_WEBHOOK_URL=https://your-endpoint.example.com/hook
AI_CHAT_WEBHOOK_SECRET=optional-hmac-secret
Fires a best-effort POST after every AI response with session_id, user_message, ai_response, provider, timestamp — and an X-LaravelAI-Signature: sha256=... header when a secret is set. Compatible with Zapier, Make, n8n, or any HTTP endpoint.
🛍️ Commerce Assistants
Three AI endpoints for a storefront — Ask Your Store (admin analytics), product Q&A/finder, and order status — built entirely on three PHP interfaces. This package creates zero e-commerce tables and assumes no particular catalog/order shape: it can't collide with WooCommerce, a custom Eloquent catalog, a remote API, or any other package, because it never queries your commerce data directly. You bind a small resolver class that does, in your own app.
Every endpoint is a clear 501 Not Implemented until you bind its resolver — nothing "half-works" against a guessed schema.
How it works, in three steps
1. Implement one interface for whichever assistant you want (each is independent — use one, two, or all three):
// app/Services/MyProductResolver.php
use EasyAI\LaravelAI\Commerce\Contracts\ProductResolver;
class MyProductResolver implements ProductResolver
{
public function search(array $criteria): array
{
// $criteria: ['keyword' => 'red dress', 'min_price' => 20, 'max_price' => 50, 'limit' => 4]
return Product::query()
->when($criteria['keyword'] ?? null, fn ($q, $kw) => $q->where('name', 'like', "%{$kw}%"))
->when($criteria['max_price'] ?? null, fn ($q, $p) => $q->where('price', '<=', $p))
->limit($criteria['limit'] ?? 4)
->get()
->map(fn ($p) => ['id' => $p->id, 'name' => $p->name, 'price' => $p->price, 'currency' => 'USD'])
->all();
}
public function find(int|string $productId): ?array
{
$p = Product::find($productId);
return $p ? ['id' => $p->id, 'name' => $p->name, 'price' => $p->price] : null;
}
}
2. Bind it in your AppServiceProvider:
public function register(): void
{
$this->app->bind(
\EasyAI\LaravelAI\Commerce\Contracts\ProductResolver::class,
\App\Services\MyProductResolver::class
);
}
3. Call the endpoint from your storefront's chat widget or your own frontend:
curl -X POST /ai-chat/api/commerce/products/ask \
-H "Content-Type: application/json" \
-d '{"question": "red dress under $50"}'
# → {"reply": "Here are a few options!", "products": [{"id": 1, "name": "Red Summer Dress", "price": 42.0}]}
The three assistants
| Assistant | Endpoint | Contract to implement | Notes |
|---|---|---|---|
| 🛒 Product Q&A / finder | POST /ai-chat/api/commerce/products/ask |
ProductResolver |
Public by default — it's just product search |
| 📦 Order status | POST /ai-chat/api/commerce/orders/ask |
OrderResolver |
Guest path requires an order number and matching email — the resolver, not the package, verifies real ownership |
| 📊 Ask Your Store | POST /ai-chat/api/commerce/store-assistant |
StoreAnalyticsResolver |
Fail-closed — refused for everyone until you Gate::define('view-store-assistant', ...), same pattern as the Settings UI |
Order status example — the guest verification path:
curl -X POST /ai-chat/api/commerce/orders/ask \
-d '{"question": "Where is my order?", "order_number": "1001", "email": "jane@example.com"}'
# → {"reply": "Order #1001 has shipped! It contains 1x Red Dress."}
# A wrong email never reveals whether the order even exists:
# → {"reply": "I couldn't find an order matching those details."}
Ask Your Store example — once you've defined the Gate:
// app/Providers/AppServiceProvider.php
Gate::define('view-store-assistant', fn ($user) => $user->isAdmin());
curl -X POST /ai-chat/api/commerce/store-assistant \
-d '{"question": "How much revenue this month, and what is running low on stock?"}'
# → {"answer": "Revenue this period was $12,340 across 210 orders. 3 items are low on stock: ..."}
# .env — optional overrides
AI_COMMERCE_PROVIDER= # blank = use AI_PROVIDER
AI_COMMERCE_GATE=view-store-assistant
AI_COMMERCE_PRODUCT_LIMIT=4
🤖 Providers
Ollama — Self-Hosted & Free
AI_PROVIDER=ollama
AI_OLLAMA_URL=http://127.0.0.1:11434
AI_OLLAMA_MODEL=qwen2:1.5b
AI_OLLAMA_TIMEOUT=120
Note for small models (qwen2, qwen2.5): If you get 400 errors with RAG context, set
num_ctxto match your model's context window:ollama show qwen2:1.5b --modelfile > /tmp/modelfile echo "PARAMETER num_ctx 2048" >> /tmp/modelfile ollama create qwen2-fixed -f /tmp/modelfileThen use
AI_OLLAMA_MODEL=qwen2-fixedin.env.
Reasoning models (qwen3 and similar) "think" before answering — often adding 10–30+ seconds of latency for a simple question, with nothing visible while it happens. The built-in chat UI shows this live as a collapsible "🧠 Thinking… Ns" block instead of dead air, auto-collapsing to "Thought for Ns" once the real answer starts — same idea as Claude.ai's/ChatGPT's extended-thinking UI. Supported on Ollama, Anthropic, and Gemini (see their sections below); OpenAI's o-series reasoning models don't expose visible reasoning through the Chat Completions API this package uses, so there's nothing to show for OpenAI. If you'd rather skip the wait entirely on Ollama:
AI_OLLAMA_THINK=false
// or per-call
AI::provider('ollama')->think(false)->chat($messages);
OpenAI (ChatGPT)
AI_OPENAI_KEY=sk-your-api-key
AI_OPENAI_MODEL=gpt-4o-mini
Image generation (DALL·E / GPT image models):
$url = AI::provider('openai')->generateImage('a red fox in snow');
Defaults to dall-e-3, returning a hosted URL (valid 60 minutes) — same generateImage(string): string contract as Together's FLUX support below, so either drops straight into  markdown or an <img src>. Set AI_OPENAI_IMAGE_MODEL=gpt-image-1 (or gpt-image-1-mini/gpt-image-1.5) for OpenAI's newer models — they return only base64, no URL option at all, so this returns a data:image/png;base64,... string instead: still one usable string, just meaningfully larger if you store the resulting chat message. This is a PHP-API capability only for now — the chat UI's /image command is still wired to Together specifically, not provider-selectable yet.
Speech-to-text and text-to-speech:
$text = AI::provider('openai')->transcribe(storage_path('app/recording.mp3'));
$audio = AI::provider('openai')->textToSpeech('Hello there!'); // raw mp3 bytes
Storage::disk('local')->put('reply.mp3', $audio);
transcribe() defaults to whisper-1; textToSpeech() defaults to voice alloy on model tts-1 — override either per-call (->textToSpeech($text, ['voice' => 'nova', 'model' => 'gpt-4o-mini-tts'])) or via AI_OPENAI_TRANSCRIBE_MODEL/AI_OPENAI_TTS_MODEL/AI_OPENAI_TTS_VOICE. Both are inherited by Groq (confirmed genuinely OpenAI-compatible — Groq's own docs show the identical endpoint shape, with whisper-large-v3-turbo as a fast transcription model) and, for textToSpeech() only, Together (Together has no transcription endpoint of its own). DeepSeek exposes no audio API at all.
Anthropic (Claude)
AI_ANTHROPIC_KEY=sk-ant-your-api-key
AI_ANTHROPIC_MODEL=claude-sonnet-4-20250514
Extended thinking is off by default (it costs extra tokens and time). Turn it on and the chat UI shows the same live "🧠 Thinking…" block as Ollama:
AI_ANTHROPIC_THINK=true
AI_ANTHROPIC_THINK_BUDGET=10000
DeepSeek
AI_DEEPSEEK_KEY=sk-your-api-key
AI_DEEPSEEK_MODEL=deepseek-chat
Groq
Famously fast inference, OpenAI-compatible API — same driver family as DeepSeek/Together, gets tool-calling and everything else for free.
AI_GROQ_KEY=gsk_your-api-key
AI_GROQ_MODEL=llama-3.3-70b-versatile
Google Gemini
AI_GEMINI_KEY=your-api-key
AI_GEMINI_MODEL=gemini-2.0-flash
2.5-series models support the same live thinking display:
AI_GEMINI_THINK=true
Together AI
AI_TOGETHER_KEY=your-api-key
AI_TOGETHER_MODEL=meta-llama/Llama-3.3-70B-Instruct-Turbo
# Optional: FLUX image generation via "/image a red fox in snow" in chat
AI_TOGETHER_IMAGE_ENABLED=true
Custom (any OpenAI-compatible endpoint)
LM Studio, vLLM, OpenRouter, an in-house gateway — add as many named entries as you like in config/ai.php:
'custom_providers' => [
'lmstudio' => [
'label' => 'LM Studio (local)',
'url' => 'http://127.0.0.1:1234/v1',
'api_key' => null,
'model' => 'local-model',
'timeout' => 60,
],
],
AI::custom('lmstudio')->chat($messages);
// or, in the chat UI provider selector: "custom:lmstudio"
🧰 Agent Module — Tool / Function Calling
Give any provider the ability to call your own PHP functions mid-conversation — look something up, run a calculation, hit an API — and keep going until it has a real answer. Works identically across OpenAI, Anthropic, Gemini, and Ollama (plus DeepSeek/Together/any custom OpenAI-compatible endpoint, which inherit it from the OpenAI driver): one Tool shape, translated into each provider's own wire format under the hood.
A tool in three lines
use EasyAI\LaravelAI\Agent\Tool;
$weather = Tool::make(
name: 'get_weather',
description: 'Get the current weather for a city.',
parameters: ['type' => 'object', 'properties' => ['city' => ['type' => 'string']], 'required' => ['city']],
handler: fn (array $args) => ['temp_c' => 22, 'condition' => 'Sunny'], // call a real API here
);
Run it
$response = AI::provider('openai')->tools([$weather])->run([
['role' => 'user', 'content' => 'What is the weather in Paris?'],
]);
echo $response->content;
// "It's currently 22°C and sunny in Paris."
run() (not chat()) drives the whole loop: sends your message, and — as long as the model keeps asking to call a tool — executes the matching handler and feeds the result back, up to 5 round-trips by default (AI::provider('openai')->tools([$weather])->run($messages, maxSteps: 10) to raise it). A handler that throws doesn't crash the run; the model just sees {"error": "..."} and can try something else or explain the failure.
Multiple tools, and inspecting what happened
$response = AI::provider('anthropic')->tools([$weather, $calculator])->run($messages);
echo $response->content; // the final answer
$response->hasToolCalls(); // false on the final turn (that's how run() knows to stop)
🔍 Built-in web search tool
The one tool most agents actually need first — give the model real, current information instead of only what was in its training data.
use EasyAI\LaravelAI\Agent\Tools\WebSearchTool;
$response = AI::provider('openai')
->tools([WebSearchTool::make()])
->run([['role' => 'user', 'content' => 'What happened in the news today about Laravel?']]);
Backed by a pluggable WebSearchProvider contract — two work out of the box, both with genuinely free tiers, pick whichever you already have a key for:
AI_WEB_SEARCH_PROVIDER=tavily # or "brave"
AI_TAVILY_API_KEY=tvly-... # https://tavily.com — 1,000 free searches/month
# AI_BRAVE_API_KEY=... # https://brave.com/search/api — also has a free tier
No key configured and nothing bound? The tool degrades to a "no results / not configured" message rather than failing the whole run — an agent missing one capability should say so, not crash. Want your own backend entirely (a self-hosted search index, a different vendor)? Bind it like any other resolver in this package:
$this->app->bind(
\EasyAI\LaravelAI\Agent\Contracts\WebSearchProvider::class,
\App\Services\MySearchProvider::class // implements search(string $query, int $limit): array
);
What each provider actually supports
| Provider | Tool calling | Notes |
|---|---|---|
| OpenAI | ✅ | Also covers DeepSeek, Together AI, Groq, and any custom OpenAI-compatible endpoint |
| Anthropic (Claude) | ✅ | Supports parallel tool calls (multiple in one turn) |
| Google Gemini | ✅ | |
| Ollama | ✅ | Model-dependent — needs a tool-calling-capable model (e.g. qwen3, llama3.1); older/smaller models may ignore the tools list entirely |
Scope note: run() is non-streaming by design — an agent loop's intermediate tool-call decisions aren't meaningfully "typed out" character by character the way a final answer is, so this uses the same request/response chat() path under the hood, not stream(). Investigated properly for v2.7.0 and deliberately not built yet: every driver's streaming handler currently only parses text/thinking deltas, never tool-call deltas — doing this right means teaching all four drivers to reassemble incremental tool-call fragments live (OpenAI's indexed argument chunks, Anthropic's input_json_delta blocks, etc.), a real body of work rather than a small addition. Staying on the roadmap as its own task rather than shipping a version that mishandles a tool call arriving mid-stream.
Using tools in the built-in chat UI
The agent module works from your own code regardless of any of this, but /ai-chat (the built-in chat window) can use it too — opt-in, currently wired up for the built-in web search tool:
AI_CHAT_TOOLS_ENABLED=true
AI_CHAT_ENABLED_TOOLS=web_search
The chat window shows a collapsible "🔧 Used N tools" line (same visual pattern as the reasoning-model "Thinking…" indicator) when a reply used one. Honest tradeoff from the scope note above: a tool-enabled reply doesn't stream token-by-token the way a normal reply does — it arrives as one complete message once the agent loop resolves, not a live typing effect. Still no slower than a normal request, just not character-by-character.
✨ Features
Fluent Builder API
$response = AI::provider('ollama')
->model('qwen2:1.5b')
->temperature(0.9)
->maxTokens(500)
->systemPrompt('You are a helpful Laravel expert.')
->chat([['role' => 'user', 'content' => 'Explain middleware']]);
Streaming
AI::provider('ollama')->stream(
[['role' => 'user', 'content' => 'Write a poem']],
function (string $chunk) { echo $chunk; }
);
Health Check + Fallback
foreach (['ollama', 'deepseek', 'openai'] as $provider) {
try {
if (!AI::provider($provider)->health()) continue;
return AI::provider($provider)->chat($messages)->content;
} catch (\Throwable $e) {
Log::warning("{$provider} failed: {$e->getMessage()}");
}
}
Response Caching
Opt-in — identical requests (same provider, model, messages, temperature, max tokens, and system prompt) hit your app's cache instead of the AI API. Never applies to stream() or a tools()-bearing call (caching either would defeat the point of one or skip a tool's real side effects).
AI_CACHE_ENABLED=true
AI_CACHE_TTL=3600 # seconds
AI_CACHE_STORE= # blank = your app's default cache store
Automatic Retry with Backoff
Opt-in, off by default — a connection failure or a 429/5xx response automatically retries before this package's own exception is raised. A 400/401/404/etc. is never retried (it'll never succeed a second time, so retrying just adds latency). Never applies to stream(), regardless of this setting — a stream that's already sent partial output to the caller can't be safely retried without duplicating it.
AI_RETRY_TIMES=2 # total attempts, not "retries on top of the first" — 2 means try once more after a failure
AI_RETRY_SLEEP=1000 # milliseconds between attempts
// or per-call, same "total attempts" meaning
AI::provider('openai')->retries(3, 500)->chat($messages);
Token Estimation
$tokens = AI::estimateTokens('Hello world');
$tokens = AI::estimateTokens($messagesArray);
Cost Estimation
getEstimatedCost() is null by default — deliberately. AI pricing changes often enough, and varies enough per model, that shipping a baked-in price table here would eventually misreport real spend without any way to know it had gone stale. You supply the rate yourself (USD per 1,000 tokens, from your provider's own current pricing page):
// config/ai.php
'pricing' => [
'openai' => [
'gpt-4o-mini' => ['input' => 0.15, 'output' => 0.60],
],
],
$response = AI::provider('openai')->model('gpt-4o-mini')->chat($messages);
$response->getEstimatedCost(); // 0.0007 (float, USD) — or null if that exact provider/model pair has no configured rate
Want that cost persisted and totaled up somewhere, instead of read off one response at a time? See Usage & Cost Tracking — same pricing config, plus a dashboard.
Structured Output
->format() gets the model to return actual data instead of prose — every provider, one call site. 'json' asks for "valid JSON, no shape enforced"; a JSON Schema array additionally constrains the exact fields.
$schema = [
'type' => 'object',
'properties' => ['city' => ['type' => 'string'], 'temp_c' => ['type' => 'number']],
'required' => ['city', 'temp_c'],
];
$response = AI::provider('openai')->format($schema)->chat([
['role' => 'user', 'content' => 'What is the weather in Paris? Respond with city and temp_c.'],
]);
$response->getStructuredData(); // ['city' => 'Paris', 'temp_c' => 22]
$response->hasStructuredData(); // true
Works the same way on OpenAI, Gemini, Ollama (plus DeepSeek/Groq/Together/Custom, inherited from the OpenAI driver), and on Anthropic too — which has no native JSON mode, so this package builds it there as a forced tool call under the hood, transparently. getStructuredData() is null for a normal response (never invented from content that merely looks like JSON), and getContent() still has the raw text either way if a response fails to decode. One real caveat, honestly documented rather than silently broken: on Anthropic specifically, ->format() isn't supported together with ->stream() — it throws rather than returning empty content, since the forced-tool-call mechanism that provider needs isn't decodable from a token stream.
Embeddings
$vectors = AI::provider('openai')->model('text-embedding-3-small')->embed('Hello world');
$vectors[0]; // [0.0123, -0.0456, ...] — always an array of vectors, one per input
$vectors = AI::provider('openai')->embed(['first text', 'second text']); // batch
Real native support on Ollama, OpenAI, Gemini, and Together AI (Together inherits it from the OpenAI driver, same as everything else it shares — DeepSeek and Groq don't expose an embeddings endpoint at all, so calling ->embed() on either surfaces that provider's own error rather than this package faking a result). This is also exactly what powers RAG's AI_RAG_PROVIDER setting — same method, same drivers.
Ollama Advanced Features
AI::provider('ollama')->keepAlive('10m')->chat($messages);
AI::provider('ollama')->options(['num_ctx' => 2048])->chat($messages);
AI::provider('ollama')->pullModel('llama3.1:8b');
AI::provider('ollama')->runningModels();
AI::provider('ollama')->deleteModel('old-model');
Error Handling
use EasyAI\LaravelAI\Exceptions\ConnectionException;
use EasyAI\LaravelAI\Exceptions\ProviderException;
try {
$response = AI::provider('openai')->chat($messages);
} catch (ConnectionException $e) {
Log::error("Connection failed: " . $e->getMessage());
} catch (ProviderException $e) {
Log::error("Provider [{$e->getProvider()}]: " . $e->getMessage());
}
📖 API Reference
Facade Methods
| Method | Returns | Description |
|---|---|---|
AI::chat(array $messages) |
AIResponse |
Chat with default provider |
AI::provider(string $name) |
AIProvider |
Switch provider — accepts "custom:slug" too |
AI::custom(string $key) |
CustomDriver |
Resolve a named custom OpenAI-compatible provider |
AI::estimateTokens(string|array) |
int |
Estimate token count |
AI::rag() |
RAGManager |
Access RAG system |
Provider Methods (Chainable)
| Method | Description |
|---|---|
->model($name) |
Set the model |
->temperature($float) |
Creativity (0–2) |
->maxTokens($int) |
Max response tokens |
->systemPrompt($text) |
Set instructions |
->timeout($seconds) |
Request timeout |
->chat(array $messages) |
Send and get response |
->stream(array $messages, callable) |
Stream token by token |
->health() |
Check provider reachable |
->models() |
List available models |
RAG Methods
| Method | Description |
|---|---|
->ingest($text, $source) |
Store as embeddings |
->search($query) |
Similarity search |
->ask($question) |
RAG-powered Q&A |
->source($name) |
Scope to one source |
->flush($source?) |
Delete documents |
->countMatches($term, $source?) |
Full-corpus keyword count for "how many X" questions |
->searchAutoIndexed($query) |
Search everything AutoIndexer has indexed |
Ollama-Only Methods
| Method | Description |
|---|---|
->format('json') |
Force JSON output |
->embed($text) |
Generate embedding |
->keepAlive($duration) |
Keep in memory |
->options($array) |
Raw Ollama options (e.g. num_ctx) |
->think($bool) |
Toggle reasoning mode on models that support it (qwen3, etc.) |
->pullModel($name) |
Download model |
->showModel($name) |
Model details |
->deleteModel($name) |
Remove model |
->copyModel($src, $dst) |
Copy model |
->runningModels() |
List loaded models |
AIResponse Object
| Property | Type | Description |
|---|---|---|
$response->content |
string |
AI reply text |
$response->model |
string |
Model used |
$response->promptTokens |
int |
Input tokens |
$response->replyTokens |
int |
Output tokens |
$response->totalTokens |
int |
Total tokens |
$response->provider |
string |
Provider name |
$response->getRaw() |
array |
Raw API response |
(string) $response |
string |
Cast to string |
Helper Function
ai('Your question')
ai('Your question', 'openai')
ai('Your question', 'anthropic', 'claude-haiku-...')
⚙️ Configuration
// config/ai.php
return [
'default' => env('AI_PROVIDER', 'ollama'),
'providers' => [
'ollama' => ['driver' => 'ollama', 'url' => env('AI_OLLAMA_URL'), 'model' => env('AI_OLLAMA_MODEL', 'qwen2:1.5b'), 'timeout' => env('AI_OLLAMA_TIMEOUT', 120)],
'openai' => ['driver' => 'openai', 'api_key' => env('AI_OPENAI_KEY'), 'model' => env('AI_OPENAI_MODEL', 'gpt-4o-mini'), 'timeout' => 60],
'anthropic' => ['driver' => 'anthropic', 'api_key' => env('AI_ANTHROPIC_KEY'), 'model' => env('AI_ANTHROPIC_MODEL'), 'timeout' => 60],
'deepseek' => ['driver' => 'deepseek', 'api_key' => env('AI_DEEPSEEK_KEY'), 'model' => env('AI_DEEPSEEK_MODEL', 'deepseek-chat'), 'timeout' => 60],
],
'rag' => [
'embed_provider' => env('AI_RAG_PROVIDER', 'ollama'),
'embed_model' => env('AI_RAG_EMBED_MODEL', 'nomic-embed-text'),
'chat_provider' => env('AI_RAG_CHAT_PROVIDER', null),
'chunk_size' => (int) env('AI_RAG_CHUNK_SIZE', 2000),
'top_k' => (int) env('AI_RAG_TOP_K', 3),
'table' => env('AI_RAG_TABLE', 'ai_documents'),
'system_prompt' => env('AI_RAG_SYSTEM_PROMPT', 'Answer using ONLY the context below. If unsure, say so.'),
],
];
Complete .env Reference
# Provider
AI_PROVIDER=ollama
# Ollama (self-hosted, free)
AI_OLLAMA_URL=http://127.0.0.1:11434
AI_OLLAMA_MODEL=qwen2:1.5b
AI_OLLAMA_TIMEOUT=120
# AI_OLLAMA_THINK=false # skip reasoning-model "thinking" latency (qwen3, etc.)
# OpenAI
AI_OPENAI_KEY=sk-proj-xxxx
AI_OPENAI_MODEL=gpt-4o-mini
# Anthropic (Claude)
AI_ANTHROPIC_KEY=sk-ant-xxxx
AI_ANTHROPIC_MODEL=claude-sonnet-4-20250514
# DeepSeek
AI_DEEPSEEK_KEY=sk-xxxx
AI_DEEPSEEK_MODEL=deepseek-chat
# Groq
AI_GROQ_KEY=gsk_xxxx
AI_GROQ_MODEL=llama-3.3-70b-versatile
# Gemini
AI_GEMINI_KEY=your-api-key
AI_GEMINI_MODEL=gemini-2.0-flash
# Together AI
AI_TOGETHER_KEY=your-api-key
AI_TOGETHER_MODEL=meta-llama/Llama-3.3-70B-Instruct-Turbo
AI_TOGETHER_IMAGE_ENABLED=false
# RAG
AI_RAG_PROVIDER=ollama
AI_RAG_EMBED_MODEL=nomic-embed-text
AI_RAG_CHUNK_SIZE=500
AI_RAG_TOP_K=1
AI_RAG_TABLE=ai_documents
# RAG for small models — reduce chunk size and limit context
# AI_OLLAMA_NUM_CTX=2048
# Security & Trust (v2.0.0) — see the Security & Trust section above for the full list
AI_CHAT_RATE_LIMIT_ENABLED=true
AI_CHAT_RATE_LIMIT_MAX=20
AI_CHAT_RATE_LIMIT_WINDOW=60
AI_CHAT_ACCESS_RESTRICTION=everyone
AI_CHAT_DISABLE_STORAGE=false
AI_CHAT_CAPTCHA_ENABLED=false
AI_CHAT_GDPR_GATE_ENABLED=false
AI_CHAT_MAX_MESSAGE_LENGTH=4000
# Chat UX & personalization (v2.0.0)
AI_CHAT_WELCOME_ENABLED=false
AI_CHAT_VOICE_INPUT_ENABLED=true
AI_CHAT_TTS_ENABLED=true
AI_CHAT_EXPORT_ENABLED=true
AI_CHAT_ATTACHMENTS_ENABLED=false
# Webhook (v2.0.0)
AI_CHAT_WEBHOOK_URL=
AI_CHAT_WEBHOOK_SECRET=
# Commerce Assistants (v2.4.0)
AI_COMMERCE_PROVIDER=
AI_COMMERCE_GATE=view-store-assistant
AI_COMMERCE_PRODUCT_LIMIT=4
# Agent module — tool/function calling + built-in web search (v2.5.0)
AI_AGENT_MAX_STEPS=5
AI_WEB_SEARCH_PROVIDER=tavily
AI_WEB_SEARCH_LIMIT=5
AI_TAVILY_API_KEY=
AI_BRAVE_API_KEY=
# Scalability (v2.6.0) — queueing is opt-in, off by default
AI_RAG_QUEUE_INGESTION=false
AI_CHAT_WEBHOOK_QUEUE=false
AI_CHAT_MAX_LOADED_MESSAGES=500
# Response caching (v2.7.0) — opt-in, off by default
AI_CACHE_ENABLED=false
AI_CACHE_TTL=3600
AI_CACHE_STORE=
# Tool-calling in the built-in chat UI (v2.7.0) — opt-in, off by default
AI_CHAT_TOOLS_ENABLED=false
AI_CHAT_ENABLED_TOOLS=web_search
🧪 Testing
vendor/bin/phpunit
vendor/bin/phpunit --filter=test_ollama_chat
Uses Http::fake() — no real API calls needed. CI runs the full suite against SQLite, MySQL, and Postgres (real GitHub Actions service containers for the latter two) — driver-specific SQL, like the raw ALTER in one of the migrations, only gets genuinely exercised on the database engines it's actually written for.
🩹 Troubleshooting
Real problems, found and fixed on real installs — kept here with the actual example so the fix makes sense.
A reply appears while streaming, then disappears after reload
Symptom: you ask a question, watch the AI's answer type itself out fully in the chat window — then reload the page (or come back later) and the reply is gone. Only your message is still there.
Real example this was found from — a chat asking a local Ollama reasoning model (qwen3:8b) to explain "how to setup laravel 12 on windows xampp". The full answer streamed to the screen normally. The server's log told the real story:
[2026-08-13 23:42:01] local.ERROR: Maximum execution time of 300 seconds exceeded
...timestamped exactly 300 seconds after the question was sent. A reasoning model's reply (thinking + a long, detailed answer) can genuinely take that long, and PHP's max_execution_time (300s is XAMPP's typical default) doesn't care that the browser is happily still receiving data — once the clock runs out, PHP kills the script with an error that cannot be caught in code, wherever it happens to be running. That's always somewhere inside the AI request, never in the step right after it that saves the reply to your database. The user sees a complete answer; the database never gets it.
Fixed in v2.1.1 — /ai-chat/api/stream now lifts PHP's execution-time limit for itself (set_time_limit(0)) since it's a long-lived streaming response by design, not a normal page request, plus a backup save that still catches the reply even if the process dies for some unrelated reason. Nothing to configure — this is automatic as of v2.1.1.
One thing still outside the package's control: if you're behind Nginx or Apache as a reverse proxy (not just XAMPP/php artisan serve directly), the proxy itself can also time out a slow request before PHP does, since removing PHP's own limit doesn't touch the web server's. If replies still cut off after upgrading, raise the proxy's read timeout too:
# nginx — inside your site's location block
fastcgi_read_timeout 600s;
proxy_read_timeout 600s;
# Apache — httpd.conf or a vhost config
ProxyTimeout 600
/ai-chat routes return HTML instead of JSON, or the chat window never loads
Usually means a catch-all route in your own routes/web.php is registered before the package's routes and swallowing everything, /ai-chat/* included:
// ❌ This shadows every package route, including its JSON APIs
Route::get('/{any}', fn () => view('app'))->where('any', '.*');
// ✅ Use fallback() instead — it only matches when nothing else did
Route::fallback(fn () => view('app'));
This is common in SPA setups (Vue/React front ends living inside a Laravel app) where a wildcard route serves the SPA shell for client-side routing.
The chat sidebar says "Guest session" even though I'm logged in
Symptom: the sidebar's identity line (bottom of the sidebar) shows a yellow dot and "Guest session," chat history isn't tied to your account, and /ai-chat/settings refuses you with a 403 even as an admin — all while you're genuinely signed in elsewhere in the same app.
Cause: /ai-chat is a normal server-rendered page. LaravelAI figures out who's chatting by calling $request->user() — Laravel's standard session-based check. That's correct for classic apps, but it resolves to nobody for a real and common architecture: a Vue/React SPA whose API auth is a Bearer token kept in the SPA's own JS state (localStorage, a Pinia/Redux store, Sanctum/Passport personal access tokens) rather than a Laravel session. A plain browser navigation to /ai-chat never attaches that token — nothing about a full-page <a href> visit runs your SPA's axios interceptor — so the request is genuinely anonymous as far as Laravel is concerned, no matter how logged-in you are in the SPA itself. Confirmed live on exactly this setup: every /ai-chat session showed user_id = NULL the whole time the user was signed in.
Two ways to fix it, pick whichever fits your app:
Option A — bridge your SPA's token into a real session (closest to "just make it work exactly like the rest of my app"). Add one endpoint your SPA calls (with its existing Bearer token) right before it links to /ai-chat:
// routes/api.php, inside your existing auth:sanctum group
Route::post('/session-bridge', function (Request $request) {
Auth::guard('web')->login($request->user());
$request->session()->regenerate();
return response()->json(['ok' => true]);
});
// before navigating to /ai-chat
await api.post('/session-bridge')
window.location.href = '/ai-chat'
Option B — plug in your own identity check, no session bridge needed. config('ai.chat.identity_resolver') is called with the current Request and returns a user id (or null for guest) — check whatever your app actually uses:
// config/ai.php
'chat' => [
'identity_resolver' => fn ($request) => $request->user('sanctum')?->id,
],
Either way, the sidebar's identity line will pick up the resolved identity automatically. Option A also fixes $request->user() everywhere else on /ai-chat, not just chat ownership — the safer default if you're not sure which you need. Note that the Settings page's manage-ai-settings Gate specifically still checks $request->user() directly (see Provider Settings UI & Auth Guard), not identity_resolver — a token-auth SPA visitor needs a real bridged session (Option A) to reach /ai-chat/settings, not just a resolved identity for chat ownership.
🗺️ Roadmap
| Version | Feature | Status |
|---|---|---|
| v1.0 | Ollama, OpenAI, Anthropic, DeepSeek | ✅ Released |
| v1.1 | Laravel 12 & 13 support | ✅ Released |
| v1.2 | Built-in RAG system + Ollama advanced | ✅ Released |
| v1.3 | Built-in Chat UI | ✅ Released |
| v1.4 | Projects + RAG scoping (self-hosted Claude Projects) | ✅ Released |
| v2.0 | Security & Trust Suite (rate limiting, access control, captcha, GDPR gate...) | ✅ Released |
| v2.0 | Vision / Image input (OpenAI, Anthropic, Gemini) | ✅ Released |
| v2.0 | Google Gemini + Together AI + custom OpenAI-compatible drivers | ✅ Released |
| v2.0 | Chat attachments (images + documents) | ✅ Released |
| v2.0 | Analytics dashboard + webhooks | ✅ Released |
| v2.0 | Bot profiles + embeddable floating widget | ✅ Released |
| v2.0 | Image generation (Together AI / FLUX, via /image) |
✅ Released |
| v2.1 | Live "thinking" visibility for reasoning models (Ollama, Anthropic, Gemini) | ✅ Released |
| v2.1 | Provider Settings UI + auth guard for the chat area | ✅ Released |
| v2.1 | Claude/ChatGPT-style message layout + responsive mobile UI | ✅ Released |
| v2.1 | Multi-format export — PDF, Word, Excel, PowerPoint | ✅ Released |
| v2.1 | Settings encryption at rest for provider API keys | ✅ Released |
| v2.1.1 | Fix — assistant replies no longer lost on long-running streams | ✅ Released |
| v2.1.2 | Fix — reapplied guest-cookie persistence fix + broken sidebar link | ✅ Released |
| v2.2 | Pluggable identity resolution (identity_resolver) for token-auth SPAs |
✅ Released |
| v2.2 | Fix — Projects had no ownership scoping at all | ✅ Released |
| v2.3 | RAG search scans in bounded batches instead of loading the entire corpus | ✅ Released |
| v2.4 | Commerce assistants — schema-agnostic Product Q&A, Order Status, Ask Your Store | ✅ Released |
| v2.5 | Agent module — function/tool calling (OpenAI, Anthropic, Gemini, Ollama) | ✅ Released |
| v2.5 | Built-in web search tool (Tavily / Brave, pluggable) | ✅ Released |
| v2.6 | php artisan laravelai:install — guided one-command setup |
✅ Released |
| v2.6 | Opt-in queued RAG ingestion + webhook delivery | ✅ Released |
| v2.6 | Fix — attachment/project files leaked on disk after deletion | ✅ Released |
| v2.6 | Bounded conversation-history loading for very long chats | ✅ Released |
| v2.7 | Groq driver | ✅ Released |
| v2.7 | Response caching (opt-in) | ✅ Released |
| v2.7 | Pluggable vector-store backend for RAG (VectorStoreInterface + a working pgvector implementation) |
✅ Released |
| v2.7 | Tool-calling in the built-in chat UI | ✅ Released |
| v2.7 | CI matrix — full suite against real MySQL and Postgres, not just SQLite | ✅ Released |
| v2.8 | Structured output (->format($schema)) across every provider |
✅ Released |
| v2.8 | Cross-provider embeddings — ->embed() on OpenAI, Gemini, Together, not just Ollama |
✅ Released |
| v2.8 | Automatic retry with backoff for transient failures (opt-in) | ✅ Released |
| v2.8 | CI matrix — real coverage for PHP 8.1/8.2 and Laravel 10/13, not just 8.3/8.4 on Laravel 12 | ✅ Released |
| v2.8 | OpenAI image generation (->generateImage(), DALL·E and GPT image models) |
✅ Released |
| v2.8 | Gemini image generation | 🔜 Planned — Google's own docs now describe a new "Interactions API" for this, replacing the classic generateContent shape every other Gemini feature here uses; not implemented yet rather than guessed at without being able to verify its raw schema as confidently as everything else in this release |
| v2.8 | Audio — ->transcribe() / ->textToSpeech() (OpenAI, inherited by Groq/Together where each genuinely supports it) |
✅ Released |
| v2.8 | Cost estimation (getEstimatedCost(), config-driven — no built-in prices, by design) |
✅ Released |
| v2.8 | PHP requirement corrected to the true minimum (^8.1) |
✅ Released |
| v2.9 | Scalable Settings-page admin access — laravelai:make-admin, an "Admin Access" UI panel, no hand-written Gate required |
✅ Released |
| v2.9 | Fix — ai:rag:ingest was fully built and documented but never actually registered as a runnable command |
✅ Released |
| v2.9.1 | Fix — chat_sessions/chat_messages/chat_attachments renamed to ai_chat_* for namespace-collision safety, verified live against real MySQL data |
✅ Released |
| v2.10 | Together AI's image_enabled/model/size/steps fields exposed in the Settings UI, not just .env |
✅ Released |
| v2.10.1 | Fix — Together's default image model (FLUX.1-schnell-Free) 400s on every account; the promotional endpoint isn't live on Together's Serverless API yet |
✅ Released |
| v2.11 | Persisted usage & cost tracking (ai_usage_logs) — a "Usage & Costs" tab on the Settings page, covering every provider's chat and image calls |
✅ Released |
| v2.12 | Streaming the agent module's final answer after the tool-call loop resolves | 🔜 Planned |
❤️ Support
- ⭐ Star this repo on GitHub
- 🐛 Report bugs via Issues
- 🔀 Submit a PR — contributions welcome
- 📢 Share with your developer friends
👤 Credits
Md Murad Hosen — Full-Stack Laravel Vue Developer and DevOps Engineer from Chittagong, Bangladesh 🇧🇩
📄 License
MIT License — free to use in personal and commercial projects. See LICENSE for details.