Laravel AI SDK: Tool-Calling Agents and Conversation Persistence
#laravel #ai #agents #prism #llm

Laravel AI SDK: Tool-Calling Agents and Conversation Persistence

3 min read Mohamed Said Mohamed Said

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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use providers other than OpenAI with Prism tool-calling?
Yes. Prism supports Anthropic, Gemini, Ollama, and others via its driver system. Tool-calling availability depends on the underlying model's capabilities — check the provider's documentation for function-calling support before switching drivers.
Q02 How do I test a tool-calling agent without hitting the real API?
Prism ships with a fake driver you can bind in tests. Use `Prism::fake()` to return pre-scripted responses, then assert that your tool closures were invoked with the expected arguments using standard Pest expectations on the side-effects they produce.
Q03 What is a safe value for withMaxSteps in production?
It depends on your use case, but 5–10 steps covers the vast majority of real workflows. Set it conservatively, monitor average steps per conversation in your logs, and raise it only when you have evidence a legitimate task requires more.

Continue reading

More Articles

View all