Building Tool-Calling AI Agents in Laravel
The Laravel AI SDK (prism-php/prism, or the first-party laravel/ai package landing in Laravel 13) gives you a clean abstraction over LLM providers. The interesting engineering challenge is not the API call itself — it is making agents reliable: persisting conversation turns, enforcing structured output, and keeping tool execution auditable.
This article focuses on those three concerns with concrete, production-ready patterns.
Persisting Conversation History
Every multi-turn agent needs a conversation store. A simple agent_conversations table works well:
Schema::create('agent_conversations', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('agent');
$table->json('messages')->default('[]');
$table->timestamps();
});
The messages column stores the raw message array that the LLM expects, so replaying or resuming a conversation is trivial.
final class ConversationRepository
{
public function append(AgentConversation $conv, array $newMessages): void
{
$conv->messages = array_merge($conv->messages, $newMessages);
$conv->save();
}
public function forUser(int $userId, string $agent): AgentConversation
{
return AgentConversation::firstOrCreate(
['user_id' => $userId, 'agent' => $agent],
['messages' => []]
);
}
}
Registering Tools
Tools are plain PHP callables with a schema. Keep each tool in its own class so it can be unit-tested independently.
final class GetOrderStatusTool
{
public string $name = 'get_order_status';
public string $description = 'Returns the current status of an order by ID.';
public function parameters(): array
{
return [
'order_id' => ['type' => 'string', 'description' => 'The order UUID'],
];
}
public function __invoke(string $order_id): string
{
$order = Order::findOrFail($order_id);
return json_encode([
'status' => $order->status->value,
'updated_at' => $order->updated_at->toIso8601String(),
]);
}
}
Bind all tools through the service container so the agent class stays slim:
$this->app->tag([
GetOrderStatusTool::class,
CancelOrderTool::class,
], 'agent.tools.order');
The Agent Loop
A tool-calling agent runs in a loop until the model stops requesting tools or a step limit is reached.
final class OrderSupportAgent
{
public function __construct(
private readonly ConversationRepository $repo,
private readonly PrismClient $prism,
private readonly iterable $tools,
) {}
public function handle(int $userId, string $userMessage): string
{
$conv = $this->repo->forUser($userId, 'order-support');
$this->repo->append($conv, [['role' => 'user', 'content' => $userMessage]]);
$steps = 0;
do {
$response = $this->prism->chat(
model: 'gpt-4o-mini',
messages: $conv->messages,
tools: $this->tools,
);
$this->repo->append($conv, $response->newMessages());
if ($response->finishReason() === 'tool_calls') {
$toolResults = $this->executeTools($response->toolCalls());
$this->repo->append($conv, $toolResults);
}
$steps++;
} while ($response->finishReason() === 'tool_calls' && $steps < 5);
return $response->text();
}
private function executeTools(array $calls): array { /* ... */ }
}
The $steps < 5 guard prevents runaway loops — a must in production.
Enforcing Structured Output
When you need machine-readable responses (not just prose), use a typed DTO and validate the model output against it:
final readonly class RefundDecision
{
public function __construct(
public bool $approved,
public string $reason,
public ?float $amount,
) {}
public static function fromArray(array $data): self
{
return new self(
approved: (bool) ($data['approved'] ?? false),
reason: (string) ($data['reason'] ?? ''),
amount: isset($data['amount']) ? (float) $data['amount'] : null,
);
}
}
Request JSON mode from the provider and decode into the DTO immediately. If decoding fails, throw a domain exception — never let a malformed LLM response silently corrupt downstream state.
Key Takeaways
- Persist messages as a JSON column keyed by user + agent; replay is free.
- One tool = one class: testable, taggable, and swappable via the container.
- Always cap the agent loop with a step limit to prevent infinite tool-call cycles.
- Decode LLM output into typed DTOs immediately; validate at the boundary.
- Log every tool invocation with its input/output for debugging and auditing.