Build AI Agents with Laravel 13: A Complete Tutorial with Code Examples
Laravel 13 ships with a first-party AI SDK (laravel/ai) — and it changes everything for PHP developers who want to build AI features without touching Python.
No more vendor SDKs scattered across your services. No more home-rolled Http::post() wrappers. No more LangChain envy.
In this tutorial, we'll build a support ticket agent from scratch — with conversation memory, tool calling, structured output, and streaming. Real code, real project structure, everything you can copy and run.
Step 1: Install Laravel 13 and the AI SDK
composer create-project laravel/laravel:^13 my-ai-app
cd my-ai-app
# Install the AI SDK
composer require laravel/ai
# Publish config and migrations
php artisan vendor:publish --provider="Laravel\\Ai\\AiServiceProvider"
# Run migrations (creates agent_conversations + agent_conversation_messages tables)
php artisan migrate
Add your AI provider keys to .env:
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GEMINI_API_KEY=...
# Ollama needs no key for local use
OLLAMA_API_KEY=
The SDK supports 14 providers: OpenAI, Anthropic, Gemini, Azure OpenAI, AWS Bedrock, Groq, xAI, DeepSeek, Mistral, Ollama, OpenRouter, Cohere, Jina, and VoyageAI.
Quick sanity check — add this to routes/web.php:
use function Laravel\Ai\agent;
Route::get('/ping-ai', function () {
return (string) agent(
instructions: 'You are terse. Reply in five words or fewer.',
)->prompt('Is Laravel 13 out?');
});
Hit /ping-ai. If you get a five-word response, your credentials work.
Step 2: Create Your First Agent
Agents are PHP classes — not loose prompts. Each one encapsulates instructions, tools, memory, and output schema.
php artisan make:agent SupportAgent
This generates app/Ai/Agents/SupportAgent.php. Let's build it out:
<?php
namespace App\Ai\Agents;
use App\Ai\Tools\LookupOrder;
use Laravel\Ai\Attributes\MaxSteps;
use Laravel\Ai\Attributes\MaxTokens;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\Temperature;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
#[Provider(Lab::Anthropic)]
#[Model('claude-haiku-4-5-20251001')]
#[MaxTokens(1024)]
#[Temperature(0.3)]
#[MaxSteps(5)]
class SupportAgent implements Agent, Conversational, HasTools
{
use Promptable, RemembersConversations;
public function instructions(): string
{
return <<<PROMPT
You are a customer support agent. Use tools to look up real data.
Never guess order details — always use the LookupOrder tool.
Be concise, helpful, and friendly.
PROMPT;
}
public function tools(): iterable
{
return [
new LookupOrder,
];
}
}
Key concepts:
- PHP Attributes (
#[Provider],#[Model], etc.) configure the agent declaratively RemembersConversationspersists chat history to the database automaticallyMaxSteps(5)prevents runaway tool loops — the most important production guard railHasToolslets the agent call PHP functions when it needs real data
Step 3: Build a Tool — Let the Agent Query Your Database
Tools are PHP classes the AI model can call on its own. The SDK handles the entire back-and-forth loop.
php artisan make:tool LookupOrder
Implement app/Ai/Tools/LookupOrder.php:
<?php
namespace App\Ai\Tools;
use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
class LookupOrder implements Tool
{
public function description(): string
{
return 'Look up an order by its public order number and return status, total, and line items.';
}
public function handle(Request $request): string
{
$order = Order::where('number', $request['number'])->first();
if (! $order) {
return "No order found with number {$request['number']}.";
}
return json_encode([
'number' => $order->number,
'status' => $order->status,
'total' => $order->total->format(),
'items' => $order->items->map->only(['name', 'quantity'])->all(),
]);
}
public function schema(JsonSchema $schema): array
{
return [
'number' => $schema->string()
->description('The public order number, e.g. ORD-12345')
->required(),
];
}
}
The model decides when to call this tool. If a user asks "Where's my order ORD-12345?", the agent calls LookupOrder, gets real data, and responds with actual order details.
Step 4: Use the Agent in a Controller
<?php
namespace App\Http\Controllers;
use App\Ai\Agents\SupportAgent;
use Illuminate\Http\Request;
class SupportChatController extends Controller
{
public function chat(Request $request)
{
$request->validate(['message' => 'required|string']);
$user = $request->user();
$response = (new SupportAgent)
->forUser($user)
->prompt($request->string('message'));
return [
'reply' => (string) $response,
'conversation_id' => $response->conversationId,
];
}
public function continue(Request $request)
{
$request->validate([
'message' => 'required|string',
'conversation_id' => 'required|string',
]);
$user = $request->user();
$response = (new SupportAgent)
->forUser($user)
->continue($request->conversation_id, as: $user)
->prompt($request->string('message'));
return [
'reply' => (string) $response,
'conversation_id' => $response->conversationId,
];
}
}
First call creates a conversation. Pass the conversation_id back on subsequent requests and the SDK reloads prior messages automatically.
Step 5: Add Structured Output
Sometimes you don't want free-form text — you want typed, validated JSON. Let's build a ticket classifier:
php artisan make:agent TicketClassifier --structured
<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
#[Provider(Lab::OpenAI)]
#[Model('gpt-4.1-mini')]
class TicketClassifier implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): string
{
return 'Classify the support ticket into a category, urgency, and suggested reply.';
}
public function schema(JsonSchema $schema): array
{
return [
'category' => $schema->string()
->enum(['billing', 'bug', 'feature-request', 'other'])
->required(),
'urgency' => $schema->string()
->enum(['low', 'medium', 'high'])
->required(),
'suggested_reply' => $schema->string()->required(),
'tags' => $schema->array()
->items($schema->string())
->required(),
];
}
}
Use it:
$classification = (new TicketClassifier)->prompt($ticket->body);
$ticket->update([
'category' => $classification['category'], // "billing"
'urgency' => $classification['urgency'], // "high"
'tags' => $classification['tags'], // ["invoice", "overdue"]
]);
No regex. No parsing. The model returns exactly the schema you defined.
Step 6: Stream Responses to a Livewire Component
For anything longer than a sentence, streaming is mandatory — users won't wait 10 seconds staring at a blank screen.
// routes/web.php
Route::post('/chat/stream', function (Request $request) {
return (new SupportAgent)
->forUser($request->user())
->stream($request->string('message'));
});
That's the entire backend — it returns a Server-Sent Events stream.
Livewire component:
<?php
namespace App\Livewire;
use App\Ai\Agents\SupportAgent;
use Livewire\Component;
class SupportChat extends Component
{
public string $message = '';
public string $reply = '';
public ?string $conversationId = null;
public function send()
{
$this->reply = '';
$agent = (new SupportAgent)->forUser(auth()->user());
$stream = $this->conversationId
? $agent->continue($this->conversationId, as: auth()->user())->stream($this->message)
: $agent->stream($this->message);
foreach ($stream as $event) {
if ($event->isText()) {
$this->reply .= $event->text;
$this->stream('reply'); // push token to the browser
}
}
$this->conversationId = $stream->response()->conversationId;
}
public function render()
{
return view('livewire.support-chat');
}
}
Blade view:
<div>
<div wire:stream="reply">{{ $reply }}</div>
<form wire:submit="send">
<input wire:model="message" placeholder="Ask about your order..." />
<button type="submit">Send</button>
</form>
</div>
Tokens appear as they arrive. No waiting.
Step 7: Provider Failover — Built In
In production, you don't want your app to crash because one provider is down:
$response = $agent->prompt(
'Check order ORD-12345',
provider: [Lab::Anthropic, Lab::OpenAI, Lab::Gemini],
);
The SDK tries Anthropic first. If it fails — rate limit, outage, whatever — it falls back to OpenAI, then Gemini. No custom retry logic needed.
Step 8: Queue Bulk Operations
Processing thousands of items? Don't block the request:
$agent->queue("Classify ticket: {$ticket->body}")
->then(function ($response) use ($ticket) {
$ticket->update([
'category' => $response['category'],
'urgency' => $response['urgency'],
'tags' => $response['tags'],
]);
})
->catch(function (Throwable $e) use ($ticket) {
Log::error("Classification failed for ticket {$ticket->id}", [
'error' => $e->getMessage(),
]);
});
Works with any Laravel queue driver — database, Redis, SQS.
Step 9: Test Your AI Features
This is the part that was missing from every AI integration before this SDK. Agent::fake():
public function test_classifies_support_ticket(): void
{
TicketClassifier::fake([
json_encode([
'category' => 'billing',
'urgency' => 'high',
'suggested_reply' => 'Please check your invoice.',
'tags' => ['invoice', 'overdue'],
]),
]);
$response = $this->post('/api/classify', [
'body' => 'I was charged twice for my order',
]);
$response->assertOk();
TicketClassifier::assertPrompted(
fn ($prompt) => str_contains($prompt, 'charged twice')
);
}
public function test_prevents_stray_api_calls(): void
{
TicketClassifier::fake()->preventStrayPrompts();
// If any code tries to make a real API call, the test fails.
// No accidental API charges during CI.
}
No HTTP mocks. No fixture files. Just fake() and assertions.
What We Built
In 9 steps, you now have:
- ✅ A support agent with conversation memory and tool calling
- ✅ A ticket classifier with structured, typed output
- ✅ Streaming responses to a Livewire chat UI
- ✅ Provider failover for resilience
- ✅ Queue integration for bulk operations
- ✅ Testable AI code with
Agent::fake()
All in PHP. All Laravel. No Python microservice. No vendor lock-in.
What Can You Build Next?
- Customer support bots that read your docs and answer questions
- Document analysis that extracts structured data from text
- E-commerce product writers with SEO optimization and deduplication
- Multi-step research agents that chain reasoning and tool calls
- Content moderation pipelines with classification and routing
Need help building AI features for your business? Get in touch →
Sources
- Laravel AI SDK docs: https://laravel.com/docs/13.x/ai-sdk
- Laravel official blog: https://laravel.com/blog/building-ai-agents-with-laravel-no-python-required
- RichDynamix complete guide: https://richdynamix.com/articles/laravel-ai-sdk-complete-guide
- Tobias Schäfer practical guide: https://tobias-schaefer.com/blog/laravel-ai-sdk-practical-guide/