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:

  1. Define tools as PHP classes with schema + execute method — the most important part
  2. Build the loop: send to LLM → execute tools → feed results back → repeat
  3. Add memory: conversation for short-term, database for long-term
  4. Streaming: Server-Sent Events for smooth UX
  5. 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)

Comments