laravel/ai
Unified Laravel API for AI providers like OpenAI, Anthropic, and Gemini. Build intelligent agents with tools and structured output, generate images, synthesize and transcribe audio, and create vector embeddings, all through one consistent, expressive interface.
Install and configure in three steps
composer require laravel/ai
php artisan vendor:publish --tag=ai-config # lands as config/ai.php
Put your provider credentials in .env (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) and let config/ai.php map them to named connections. Because the package is still 0.x, pin the version in composer.json rather than floating on ^0.11 — default models change between minors (Gemini's default text model has already been bumped twice, DeepSeek's model name was replaced).
Where to look first
laravel.com/docs/ai-sdk — the README is a stub and deliberately punts to the site. Treat the docs as the source of truth; the repo's own skills/ai-sdk-development/SKILL.md says it explicitly: never guess at APIs.SKILL.md is the best single file in the repo for orientation — it contains the decision workflow (which entry point for which task) and copy-pasteable examples.changelog/v0.x.md before each upgrade. This package ships frequently and behaviour changes (e.g. stream errors began throwing in v0.11 instead of silently ending).Your first use case: a support-reply agent
Don't start with images or embeddings. Start with one agent class, because the Agent + Promptable pair is the primitive everything else composes around.
namespace App\Ai\Agents;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
class SupportResponder implements Agent
{
use Promptable;
public function instructions(): string
{
return 'You are a concise support agent for a Laravel SaaS. Never invent pricing.';
}
}
// app/Http/Controllers/SupportReplyController.php
$response = (new SupportResponder)->prompt($ticket->body);
return $response->text;
That's the whole loop. The next hour should be spent on two things: (a) a test using the fake helpers so your suite never hits a real API, and (b) moving the provider/model out of prompt() calls and into config/ai.php. Everything else in the package follows the same Something::of(...) → ->generate() shape, so the mental model transfers immediately.
Agents as domain objects, not prompt strings. Resist building a PromptService with a giant switch. One class per job (SupportResponder, TranscriptSummarizer, InvoiceExtractor) gives you a natural place for instructions(), tool registration, and middleware. Resolve through the container so constructor dependencies work:
$agent = SalesCoach::make(user: $user); // container-resolved
Keep one-off prompts out of the class tree entirely with the anonymous helper:
use function Laravel\Ai\{agent};
$response = agent(instructions: 'You are a helpful assistant.')->prompt('Hello');
Override provider/model/timeout per call, not per class. The same agent should be able to run cheap in a queued job and expensive on a user-facing request:
$response = (new SalesCoach)->prompt(
$transcript,
provider: Lab::Anthropic,
model: 'claude-haiku-4-5-20251001',
timeout: 120,
);
This is also your escape hatch when a provider has an outage or you need to A/B cost against quality.
Two conversation strategies — pick deliberately. For history you control (already stored in your own tables, or you need to filter/redact), implement Conversational and return Message objects from messages(). For history the package should own, add RemembersConversations:
$response = (new SalesCoach)->forUser($user)->prompt('Hello!');
$conversationId = $response->conversationId;
$response = (new SalesCoach)->continue($conversationId, as: $user)->prompt('Tell me more.');
The trait route ships with its own tables and now stores participants polymorphically — good enough for most "chat with our docs" features. Use the manual interface when conversations are a first-class part of your schema and you need to join against them.
Structured output beats regex. Anything you intend to persist or branch on should come back as a schema, not prose:
class InvoiceExtractor implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): string { return 'Extract invoice fields exactly.'; }
public function schema(JsonSchema $schema): array
{
return [
'total' => $schema->number()->required(),
'due_date'=> $schema->string()->required(),
];
}
}
Then bind the array straight into a DTO or Model::create(). Note the provider asymmetries: Anthropic historically rejected certain JSON Schema constraints, and OpenAI-compatible gateways sometimes wrap output in markdown fences (now stripped for you). Validate the result with a Laravel validator anyway — a schema is a strong hint to the model, not a guarantee from it.
Streaming is a route concern, not a controller concern. Return the stream response directly from a route and let the framework do SSE:
Route::post('/coach', fn (Request $request) =>
(new SalesCoach)->stream($request->input('message'))
);
If your front end is Livewire/Inertia rather than raw fetch, use the broadcasting support instead of hand-rolling SSE parsing. Either way, wrap the stream in a try/catch: since v0.11 stream errors throw instead of quietly ending the run, so a provider hiccup mid-response will now surface as an exception on your side — which is what you want, but only if you handle it.
Queue anything that costs money or takes more than a second. Image generation, TTS, transcription, embeddings over a large corpus — all of it:
(new SalesCoach)->queue($transcript)
->then(fn ($response) => $user->notify(new CoachReady($response)));
The then() callback is the important part for testing: since v0.11, faking a queued generation does execute then(), so your post-processing code is covered without a real queue worker.
Tools are where agents stop being chatbots. Local tools let the agent call your code; provider tools (web_search, web_fetch, file_search, hosted ToolSearch) let it call the provider's infrastructure. Tool choice is supported across Gemini, OpenAI, and Anthropic, so you can force a specific tool for deterministic flows. For anything that writes to your database or charges a card, use the human-in-the-loop support (v0.10+): the run pauses, provider content blocks are persisted, and you resume after approval rather than letting the model commit.
Media and retrieval follow one consistent shape. Learn it once:
Image::of('a lighthouse at dusk')->generate();
Audio::of($script)->generate();
Transcription::fromPath($path)->generate();
$vectors = Embeddings::for([$a, $b])->generate();
$ranked = Reranking::of($query)->documents($docs)->rerank();
Document::fromPath($pdf)->put();
Stores::create('docs');
Embedding caching is built in and keyed per batch — if you were maintaining your own cache layer, check for the v0.10.2 key-collision fix before assuming your version was correct. Generated images and audio can be written straight to a filesystem disk.
Failover as configuration, not code. Provider connections fail over on transient gateway/Cloudflare errors, 503s, and (for Anthropic) usage-limit rejections. Configure the fallback chain in config/ai.php and let it work. Do not build retry loops around prompt() — you'll double up.
Testing: fake first, assert second. The package ships fakes for every entry point, and they're the reason you can put AI in a CI pipeline:
SupportResponder::fake(['Sorry for the trouble!']);
$this->post('/support', [...])->assertOk();
SupportResponder::assertPrompted('...');
SupportResponder::assertPromptedTimes(1);
Fakes exist for agents, images, audio, transcriptions, embeddings, reranking, files, and vector stores. Fake the entry point you use, not the one you assume — a faked Image fake won't cover a queued Transcription.
Observability via the run context. Since v0.11 a single invocation ID threads through an entire agent run, and each generation step, tool invocation, tool failure, and terminal failure is reported. Hook the events into your existing logging/tracing rather than wrapping agents in custom instrumentation — and use the invocation ID as the correlation key when a run spawns sub-agents (an agent invoked as a tool is linked back to its parent).
0.x with a changelog entry nearly every week. Pin the exact version, read the changelog before bumping, and keep model names in config rather than literals in classes. Default-model bumps have silently changed output quality and cost for anyone who hardcoded nothing but relied on defaults.test agent fake, not laravel ai test agent fake).Promptable is a trait; Agent is the contract. Implement the interface and use the trait. A class missing the interface resolves fine in isolation and fails confusingly at the call site.TextDelta::combine was gluing chunks incorrectly pre-v0.9.1. If you render deltas client-side and see mangled words on tool-using agents, upgrade before debugging your own buffer logic.timeout explicitly on any prompt that involves tools or long context; a truncated stream looks like a provider bug and isn't.Lab-agnostic code so you can swap.summarize → a custom OpenAI-compatible provider → forking a gateway. Most teams stop at the second step and never need the last.How can I help you explore Laravel packages today?