Building Tool-Calling AI Agents in Laravel with Prism
The "chat with your data" demo is easy. A production agent that calls real tools, recovers from errors, and persists multi-turn context across requests is not. This article focuses on that harder problem using Prism, the first-class Laravel AI SDK, with OpenAI-compatible providers.
Why Tool Calling Changes Everything
A plain completion call is stateless and safe. A tool-calling loop is a state machine: the model emits a tool call, your code executes it, the result feeds back, and the loop continues until the model emits a final text response or you abort. Each iteration can mutate real data. Getting this wrong means runaway loops, duplicate side effects, and unbounded token spend.
Defining Typed Tools
Prism tools are plain PHP objects. Keep them thin — they should validate input and delegate to an existing service, never contain business logic themselves.
use EchoLabs\Prism\Tool;
use EchoLabs\Prism\Schema\StringSchema;
use EchoLabs\Prism\Schema\NumberSchema;
$lookupOrder = Tool::as('lookup_order')
->for('Retrieve an order by its numeric ID')
->withParameter(new NumberSchema('order_id', 'The order ID to look up'))
->using(function (int $order_id): string {
$order = Order::with('lines')->findOrFail($order_id);
return json_encode([
'id' => $order->id,
'status' => $order->status->value,
'total' => $order->total_cents / 100,
]);
});
The closure must return a string — that string becomes the tool result message the model sees next.
Persisting Conversation History
Multi-turn agents need history. Store it as a JSON column on a conversations table and hydrate Prism Message objects on each request.
// Migration
$table->json('messages')->default('[]');
// Hydration
use EchoLabs\Prism\ValueObjects\Messages\UserMessage;
use EchoLabs\Prism\ValueObjects\Messages\AssistantMessage;
$history = collect($conversation->messages)->map(fn (array $m) =>
$m['role'] === 'user'
? new UserMessage($m['content'])
: new AssistantMessage($m['content'])
)->all();
After each completed agent turn, serialize the updated message list back:
$conversation->update([
'messages' => collect($response->messages)
->map(fn ($m) => ['role' => $m->role->value, 'content' => $m->content])
->all(),
]);
Running the Agent Loop with an Abort Guard
Never let the model loop unbounded. Enforce a hard iteration cap and surface a clean error when it trips.
use EchoLabs\Prism\Prism;
use EchoLabs\Prism\Enums\Provider;
use EchoLabs\Prism\Enums\FinishReason;
$MAX_STEPS = 6;
$response = Prism::text()
->using(Provider::OpenAI, 'gpt-4o')
->withSystemPrompt('You are a helpful order support agent.')
->withMessages($history)
->withPrompt($userMessage)
->withTools([$lookupOrder, $cancelOrder])
->withMaxSteps($MAX_STEPS)
->asText();
if ($response->finishReason === FinishReason::ToolCalls) {
// Model still wanted to call tools after MAX_STEPS — abort gracefully
throw new AgentLoopException("Agent exceeded {$MAX_STEPS} steps.");
}
withMaxSteps tells Prism how many tool-call/result round trips to allow before it stops and returns whatever the model last produced.
Idempotency for Destructive Tools
Tools like cancel_order must be idempotent. The model may call the same tool twice if the first result was ambiguous. Guard at the service layer:
->using(function (int $order_id): string {
$order = Order::findOrFail($order_id);
if ($order->status === OrderStatus::Cancelled) {
return "Order {$order_id} was already cancelled.";
}
$order->cancel(); // fires domain event, sends email, etc.
return "Order {$order_id} cancelled successfully.";
})
Takeaways
- Cap iterations with
withMaxStepsand handle theToolCallsfinish reason explicitly. - Persist messages as JSON and hydrate typed
Messageobjects — never pass raw strings back into the model. - Keep tool closures thin: validate, delegate, return a string. Business logic belongs in services.
- Make destructive tools idempotent — the model will sometimes call them twice.
- Serialize after every turn, not just at conversation end, so a crash mid-session doesn't lose context.