The Dev Log › AI & Machine Learning
Building an AI Agent in PHP: Tools, Memory and Guardrails
By Jezer Niel Blanca, Full Stack Developer ·
·
6 min read
How I build AI agents in Laravel: tool classes the model can call, a safe agent loop, conversation memory, and the guardrails that keep it from doing damage.
"AI agent" has become one of those terms that means everything and nothing. Strip away the hype and an agent is simply a loop: a language model decides which tool to call, your code runs that tool, the result goes back to the model, and the loop continues until the model has an answer. The good news for PHP developers is that none of this requires Python. Laravel already gives you everything you need to build a useful, safe agent. In this post I'll show you how I structure one: the tools, the loop, memory, and the guardrails that stop it from doing something you'll regret.
What an Agent Actually Is
A chatbot answers from what the model already knows plus whatever you put in the prompt. An agent can act. It can look up an order, check a calendar, create a draft, or query your database through functions you expose. The model never touches your system directly. It only asks for a tool by name with some arguments, and your code decides whether and how to run it.
That separation is the most important idea in this whole post:
- The model suggests actions.
- Your application validates, authorizes and executes them.
- The model reads the results and decides what to do next.
Most modern LLM APIs support this through "tool calling" or "function calling". The examples below use the widely supported OpenAI-compatible chat completions format, so they work with many providers by changing a base URL and model name in config.
Defining Tools as PHP Classes
I like each tool to be a small class with a name, a description, a JSON schema for its arguments, and a handle method. A shared interface keeps them consistent:
<?php
namespace App\Agent;
use App\Models\User;
interface AgentTool
{
public function name(): string;
public function description(): string;
/**
* @return array<string, mixed>
*/
public function parameters(): array;
/**
* @param array<string, mixed> $arguments
* @return array<string, mixed>
*/
public function handle(array $arguments, User $user): array;
}
Here is a read-only tool that looks up an order for the current user:
<?php
namespace App\Agent\Tools;
use App\Agent\AgentTool;
use App\Models\User;
class FindOrderTool implements AgentTool
{
public function name(): string
{
return 'find_order';
}
public function description(): string
{
return 'Look up one of the current user\'s orders by its order number.';
}
public function parameters(): array
{
return [
'type' => 'object',
'properties' => [
'order_number' => ['type' => 'string', 'description' => 'The order number, e.g. ORD-1042'],
],
'required' => ['order_number'],
];
}
public function handle(array $arguments, User $user): array
{
$order = $user->orders()
->where('number', $arguments['order_number'] ?? '')
->first(['number', 'status', 'total', 'created_at']);
return $order?->toArray() ?? ['error' => 'Order not found.'];
}
}
Notice that the query goes through $user->orders(). The model can ask for any order number it likes, but it can only ever see orders that belong to the signed-in user. Authorization lives in your code, not in the prompt.
The Agent Loop
The loop sends the conversation and the tool definitions to the model. If the model responds with tool calls, we run them, append the results, and ask again. If it responds with plain text, we're done.
public function run(User $user, array $messages): string
{
for ($step = 0; $step < self::MAX_STEPS; $step++) {
$response = Http::withToken(config('services.llm.key'))
->timeout(30)
->post(config('services.llm.url').'/chat/completions', [
'model' => config('services.llm.model'),
'messages' => $messages,
'tools' => $this->toolDefinitions(),
])
->throw()
->json();
$message = $response['choices'][0]['message'];
$messages[] = $message;
if (empty($message['tool_calls'])) {
return $message['content'] ?? '';
}
foreach ($message['tool_calls'] as $call) {
$result = $this->callTool($call['function']['name'], $call['function']['arguments'], $user);
$messages[] = [
'role' => 'tool',
'tool_call_id' => $call['id'],
'content' => json_encode($result),
];
}
}
return 'Sorry, I could not finish that request. Could you try rephrasing it?';
}
The helper that dispatches a call is where most of the safety lives:
private function callTool(string $name, string $rawArguments, User $user): array
{
$tool = $this->tools[$name] ?? null;
if ($tool === null) {
return ['error' => "Unknown tool: {$name}"];
}
$arguments = json_decode($rawArguments, true);
if (! is_array($arguments)) {
return ['error' => 'Arguments must be a JSON object.'];
}
Log::info('Agent tool call', ['tool' => $name, 'user_id' => $user->id]);
return $tool->handle($arguments, $user);
}
Returning errors as data, instead of throwing, lets the model recover. If it guesses a wrong order number, it sees "Order not found" and can ask the user to double-check.
Memory: Short-Term and Long-Term
Language models are stateless. Every request must include whatever context the model needs. I think about memory in two layers:
- Short-term memory is the conversation itself. I store messages in a
conversations and messages table and send the most recent ones with each request.
- Long-term memory is facts worth keeping across conversations, like a user's preferred language or their company name. I store those as simple rows and inject them into the system prompt.
As conversations grow, you'll hit context limits and rising token costs. The practical fixes are to send only the last N messages, and to periodically summarize older messages into a short paragraph that replaces them. Keep tool results compact too. Returning three fields is better than returning an entire model with every relationship loaded.
Guardrails That Actually Protect You
This is the part people skip, and it's the part that matters most in production.
Treat every tool call as untrusted user input, because that is exactly what it is.
The guardrails I put on every agent:
- An allowlist of tools. The model can only call what you register. No generic "run SQL" or "call any URL" tools.
- A step limit.
MAX_STEPS stops runaway loops that burn tokens and time.
- Validation. Run arguments through Laravel's
Validator before using them, just like a form request.
- Authorization. Scope every query to the current user or team, and reuse your existing policies.
- Confirmation for writes. Tools that send emails, delete data or spend money should create a draft or pending action that a human approves.
- Rate limiting. Use Laravel's
RateLimiter per user so one person can't drain your API budget.
- Logging. Record which tools were called, by whom, and with what outcome, so you can debug and audit later.
Watch Out for Prompt Injection
If a tool returns text written by other people, such as a support ticket or a web page, that text can contain instructions aimed at the model. The model may follow them. The defence is architectural: keep tools narrowly scoped and permissioned so that even a successfully manipulated model can't do anything the current user couldn't already do.
Wrapping up
An agent in PHP is not exotic. It's a set of small tool classes, a loop around an HTTP call, a place to store messages, and a firm set of guardrails. Start with read-only tools, add step limits and logging from day one, and only introduce write actions behind human confirmation. Build it that way and you get something genuinely useful instead of an unpredictable demo. If you want to add a practical AI agent to your product, I'd be glad to design and build it with you and my team.
Tags: AI, Agents, PHP, Laravel, LLM