Building Tool-Calling Agents in Laravel with Prism
Large language models become genuinely useful when they can act — querying a database, calling an API, or reading a file — rather than just generating text. The Prism package gives Laravel developers a clean, driver-agnostic interface for exactly this. This article focuses on two hard problems: wiring tools safely and persisting conversation state between HTTP requests without leaking memory or context.
Defining a Tool
Prism tools are plain PHP objects that declare their schema and a handler closure. Keep them thin — delegate real work to your existing service layer.
use EchoLabs\Prism\Tool;
$orderLookup = Tool::as('get_order')
->for('Fetch an order by its ID')
->withStringParameter('order_id', 'The UUID of the order')
->using(function (string $order_id): string {
$order = Order::findOrFail($order_id);
return json_encode([
'status' => $order->status,
'total' => $order->total_cents,
'shipped' => $order->shipped_at?->toIso8601String(),
]);
});
The using closure must return a string — that string is injected back into the model's context as the tool result. Returning structured JSON is idiomatic.
Running a Multi-Turn Agent Loop
A single generate() call is not enough for agents. You need an agentic loop that keeps running until the model stops requesting tools.
use EchoLabs\Prism\Prism;
use EchoLabs\Prism\Enums\Provider;
use EchoLabs\Prism\ValueObjects\Messages\UserMessage;
$messages = [
new UserMessage('What is the status of order 550e8400-e29b-41d4-a716-446655440000?'),
];
$response = Prism::text()
->using(Provider::OpenAI, 'gpt-4o')
->withMessages($messages)
->withTools([$orderLookup])
->withMaxSteps(5) // hard cap — never let the loop run unbounded
->generate();
echo $response->text;
withMaxSteps is your circuit breaker. Without it, a confused model can spin indefinitely, burning tokens and time.
Persisting Conversation History
HTTP is stateless; agents are not. The naive approach — storing the full message array in the session — breaks under load and leaks data across users. A better pattern: persist messages to a conversations table and rehydrate on each request.
// Migration
Schema::create('conversation_messages', function (Blueprint $table) {
$table->id();
$table->ulid('conversation_id')->index();
$table->string('role'); // user | assistant | tool
$table->longText('content');
$table->json('tool_calls')->nullable();
$table->timestamps();
});
// Rehydrating messages for Prism
use EchoLabs\Prism\ValueObjects\Messages\AssistantMessage;
use EchoLabs\Prism\ValueObjects\Messages\UserMessage;
$stored = ConversationMessage::where('conversation_id', $id)
->orderBy('id')
->get();
$messages = $stored->map(fn ($row) => match ($row->role) {
'user' => new UserMessage($row->content),
'assistant' => new AssistantMessage($row->content),
default => null,
})->filter()->values()->all();
After each agent run, persist the new messages returned in $response->messages back to the table. This keeps your PHP process stateless while the conversation lives safely in Postgres.
Authorising Tool Execution
Tools run server-side with your application's full privileges. Always scope them to the authenticated user:
->using(function (string $order_id) use ($user): string {
$order = Order::where('user_id', $user->id)
->findOrFail($order_id); // 404 if not owned
// ...
})
Never trust the model to enforce ownership — it will not.
Key Takeaways
- Tools return strings. Encode complex data as JSON; the model reads it as context.
- Always set
withMaxSteps. Unbounded agentic loops are a production incident waiting to happen. - Persist messages in the database, not the session. Rehydrate per request for true statelessness.
- Authorise inside the tool closure, not outside it. The model controls which tool is called; you control what it can see.
- Keep tools thin. Delegate to services so tools remain testable in isolation without mocking the LLM.