Building AI Agents with Laravel: Tools, Memory, and Reasoning Loops
·
9 min read
An AI chatbot answers questions. An AI agent takes actions. It can search your database, send emails, create records, and decide what to do next — all autonomously.
This guide shows you how to build production-ready AI agents in Laravel using tool-use, memory, and reasoning loops.
Chatbot vs Agent
Chatbot:
User: "What's my account balance?"
Bot: "I don't have access to that information."
Agent:
User: "What's my account balance?"
Agent thinks: "I need to look up the user's balance. I'll use the get_balance tool."
Agent calls: get_balance(user_id: 42)
Agent gets: { balance: "$1,234.56" }
Agent responds: "Your account balance is $1,234.56."
The difference: agents have tools and a reasoning loop.
Architecture Overview
User Message
↓
[Agent Loop]
↓
Send to LLM (with tool definitions)
↓
LLM responds with either:
a) Final answer → Return to user
b) Tool call → Execute tool → Feed result back → Loop
Step 1: Define Tools
Tools are PHP functions the AI can call:
// app/AI/Tools/SearchPostsTool.php
namespace App\AI\Tools;
class SearchPostsTool
{
public static function definition(): array
{
return [
'type' => 'function',
'function' => [
'name' => 'search_posts',
'description' => 'Search blog posts by query. Returns matching post titles and slugs.',
'parameters' => [
'type' => 'object',
'properties' => [
'query' => [
'type' => 'string',
'description' => 'The search query',
],
'limit' => [
'type' => 'integer',
'description' => 'Max results to return (default: 5)',
],
],
'required' => ['query'],
],
],
];
}
public static function execute(array $args): string
{
$query = $args['query'];
$limit = $args['limit'] ?? 5;
$posts = Post::where('title', 'like', "%{$query}%")
->orWhere('body', 'like', "%{$query}%")
->limit($limit)
->get(['title', 'slug', 'published_at']);
if ($posts->isEmpty()) {
return json_encode(['result' => 'No posts found matching: ' . $query]);
}
return json_encode([
'result' => $posts->map(fn ($p) => [
'title' => $p->title,
'slug' => $p->slug,
'date' => $p->published_at?->format('Y-m-d'),
])->toArray(),
]);
}
}
// app/AI/Tools/GetWeatherTool.php
class GetWeatherTool
{
public static function definition(): array
{
return [
'type' => 'function',
'function' => [
'name' => 'get_weather',
'description' => 'Get current weather for a city',
'parameters' => [
'type' => 'object',
'properties' => [
'city' => [
'type' => 'string',
'description' => 'City name',
],
],
'required' => ['city'],
],
],
];
}
public static function execute(array $args): string
{
$response = Http::get('https://api.weatherapi.com/v1/current.json', [
'key' => config('services.weather.key'),
'q' => $args['city'],
]);
if ($response->failed()) {
return json_encode(['error' => 'Could not fetch weather']);
}
$data = $response->json();
return json_encode([
'city' => $data['location']['name'],
'temp_c' => $data['current']['temp_c'],
'condition' => $data['current']['condition']['text'],
]);
}
}
// app/AI/Tools/SendEmailTool.php
class SendEmailTool
{
public static function definition(): array
{
return [
'type' => 'function',
'function' => [
'name' => 'send_email',
'description' => 'Send an email to a recipient. Use only when explicitly asked by the user.',
'parameters' => [
'type' => 'object',
'properties' => [
'to' => ['type' => 'string', 'description' => 'Recipient email'],
'subject' => ['type' => 'string', 'description' => 'Email subject'],
'body' => ['type' => 'string', 'description' => 'Email body (plain text)'],
],
'required' => ['to', 'subject', 'body'],
],
],
];
}
public static function execute(array $args): string
{
// Safety: validate email format
if (!filter_var($args['to'], FILTER_VALIDATE_EMAIL)) {
return json_encode(['error' => 'Invalid email address']);
}
Mail::raw($args['body'], function ($message) use ($args) {
$message->to($args['to'])->subject($args['subject']);
});
return json_encode(['result' => 'Email sent successfully']);
}
}
Step 2: Tool Registry
// app/AI/ToolRegistry.php
namespace App\AI;
use App\AI\Tools\SearchPostsTool;
use App\AI\Tools\GetWeatherTool;
use App\AI\Tools\SendEmailTool;
class ToolRegistry
{
private static array $tools = [
'search_posts' => SearchPostsTool::class,
'get_weather' => GetWeatherTool::class,
'send_email' => SendEmailTool::class,
];
public static function definitions(): array
{
return array_map(
fn ($class) => $class::definition(),
self::$tools,
);
}
public static function execute(string $name, array $args): string
{
$class = self::$tools[$name] ?? null;
if (!$class) {
return json_encode(['error' => "Unknown tool: {$name}"]);
}
return $class::execute($args);
}
}
Step 3: The Agent Loop
// app/AI/Agent.php
namespace App\AI;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class Agent
{
private array $messages = [];
private int $maxIterations = 10;
public function __construct(
private string $model = 'gpt-4o',
private string $systemPrompt = 'You are a helpful assistant. Use tools when needed to answer questions accurately.',
) {
$this->messages[] = [
'role' => 'system',
'content' => $this->systemPrompt,
];
}
public function chat(string $userMessage): string
{
$this->messages[] = [
'role' => 'user',
'content' => $userMessage,
];
for ($i = 0; $i < $this->maxIterations; $i++) {
$response = $this->callLLM();
$choice = $response['choices'][0];
$message = $choice['message'];
// Add assistant message to history
$this->messages[] = $message;
// Check if LLM wants to call tools
if (($choice['finish_reason'] ?? '') === 'tool_calls' || !empty($message['tool_calls'])) {
$this->executeToolCalls($message['tool_calls']);
continue; // Loop back to LLM with tool results
}
// Final answer
return $message['content'];
}
return 'I was unable to complete the task within the allowed steps.';
}
private function callLLM(): array
{
$response = Http::withToken(config('services.openai.key'))
->timeout(60)
->post('https://api.openai.com/v1/chat/completions', [
'model' => $this->model,
'messages' => $this->messages,
'tools' => ToolRegistry::definitions(),
'tool_choice' => 'auto',
]);
if ($response->failed()) {
Log::error('LLM API failed', ['status' => $response->status()]);
throw new \RuntimeException('LLM API request failed');
}
return $response->json();
}
private function executeToolCalls(array $toolCalls): void
{
foreach ($toolCalls as $toolCall) {
$name = $toolCall['function']['name'];
$args = json_decode($toolCall['function']['arguments'], true);
Log::info("Agent executing tool: {$name}", $args);
$result = ToolRegistry::execute($name, $args);
// Feed tool result back to LLM
$this->messages[] = [
'role' => 'tool',
'tool_call_id' => $toolCall['id'],
'content' => $result,
];
}
}
}
Step 4: Controller & Streaming
// app/Http/Controllers/AgentController.php
class AgentController extends Controller
{
public function chat(Request $request)
{
$request->validate([
'message' => 'required|string|max:2000',
]);
$agent = new Agent(
systemPrompt: 'You are a blog assistant. You can search posts, check weather, and send emails when asked.',
);
// Load conversation history from session
$history = session('agent_history', []);
foreach ($history as $msg) {
$agent->addMessage($msg);
}
$response = $agent->chat($request->message);
// Save updated history
session(['agent_history' => $agent->getMessages()]);
return response()->json(['response' => $response]);
}
}
Step 5: Memory — Short-Term & Long-Term
Short-Term Memory (Conversation Context)
Already handled by the $messages array. But conversations get long. Implement a sliding window:
class Agent
{
private function trimHistory(): void
{
// Keep system message + last 20 messages
if (count($this->messages) > 22) {
$system = $this->messages[0];
$recent = array_slice($this->messages, -20);
$this->messages = [$system, ...$recent];
}
}
}
Long-Term Memory (Database)
// app/Models/AgentMemory.php
class AgentMemory extends Model
{
protected $fillable = ['user_id', 'key', 'content', 'type'];
}
// Memory tool for the agent
class SaveMemoryTool
{
public static function definition(): array
{
return [
'type' => 'function',
'function' => [
'name' => 'save_memory',
'description' => 'Save important information for later recall. Use this to remember user preferences or important facts.',
'parameters' => [
'type' => 'object',
'properties' => [
'key' => ['type' => 'string', 'description' => 'Short label for this memory'],
'content' => ['type' => 'string', 'description' => 'The information to remember'],
],
'required' => ['key', 'content'],
],
],
];
}
public static function execute(array $args): string
{
AgentMemory::updateOrCreate(
['user_id' => auth()->id(), 'key' => $args['key']],
['content' => $args['content'], 'type' => 'fact'],
);
return json_encode(['result' => 'Memory saved']);
}
}
Load memories into the system prompt:
$memories = AgentMemory::where('user_id', $user->id)->get();
$memoryContext = $memories->map(fn ($m) => "- {$m->key}: {$m->content}")->join("\n");
$agent = new Agent(
systemPrompt: "You are a helpful assistant.\n\nUser memories:\n{$memoryContext}",
);
Safety: Tool Permissions
Not all tools should be callable without restrictions:
class ToolRegistry
{
private static array $dangerousTools = ['send_email', 'delete_post'];
public static function execute(string $name, array $args): string
{
// Require confirmation for dangerous tools
if (in_array($name, self::$dangerousTools)) {
Log::warning("Agent attempting dangerous tool: {$name}", $args);
if (!session("confirmed_tool_{$name}")) {
return json_encode([
'error' => 'This action requires user confirmation.',
'requires_confirmation' => true,
'tool' => $name,
'args' => $args,
]);
}
}
$class = self::$tools[$name] ?? null;
return $class ? $class::execute($args) : json_encode(['error' => 'Unknown tool']);
}
}
Testing Agents
class AgentTest extends TestCase
{
public function test_agent_uses_search_tool(): void
{
Post::factory()->create(['title' => 'Laravel Caching Tips']);
$agent = new Agent();
$response = $agent->chat('Find posts about caching');
$this->assertStringContainsString('Caching', $response);
}
public function test_agent_stops_after_max_iterations(): void
{
// Mock LLM to always return tool calls
Http::fake([
'api.openai.com/*' => Http::response([
'choices' => [[
'finish_reason' => 'tool_calls',
'message' => [
'role' => 'assistant',
'tool_calls' => [[
'id' => 'call_1',
'function' => ['name' => 'search_posts', 'arguments' => '{"query":"test"}'],
]],
],
]],
]),
]);
$agent = new Agent();
$response = $agent->chat('test');
$this->assertStringContainsString('unable to complete', $response);
}
}
Using in a Laravel Controller
// routes/web.php
Route::middleware('auth')->group(function () {
Route::post('/agent/chat', [AgentController::class, 'chat']);
});
// app/Http/Controllers/AgentController.php
class AgentController extends Controller
{
public function chat(Request $request)
{
$request->validate(['message' => 'required|string|max:2000']);
$memories = AgentMemory::forUser($request->user()->id)->limit(10)->get();
$memoryContext = $memories->map(fn ($m) => "- {$m->key}: {$m->content}")->join("\n");
$agent = new Agent(
systemPrompt: "You are a blog assistant.\n\nUser context:\n{$memoryContext}",
);
$response = $agent->chat($request->input('message'));
return response()->json(['response' => $response]);
}
}
Conclusion
AI agents in Laravel are surprisingly simple to build:
- Define tools as PHP classes with schema + execute method — the most important part
- Build the loop: send to LLM → execute tools → feed results back → repeat
- Add memory: conversation for short-term, database for long-term
- Streaming: Server-Sent Events for smooth UX
- Add safety: permission checks, logging, max iterations
The hard part isn't the code — it's:
- Designing good tool descriptions (LLM reads descriptions to decide)
- Writing system prompts that guide the agent's reasoning
- Handling edge cases (LLM hallucinating tool names, malformed arguments)
- Balancing safety vs usability (too many confirmations = frustrating UX)