Building Tool-Calling Agents in Laravel with Conversation Persistence
The Laravel AI SDK (the prism package from EchoLabs, or the first-party laravel/ai direction emerging in the ecosystem) gives you a clean PHP-first interface for building agents that can invoke tools, maintain conversation history, and return structured output. This article focuses on the practical wiring: typed tool definitions, safe state persistence, and avoiding the common trap of leaking conversation context across unrelated sessions.
Defining Typed Tools
A tool is a callable the LLM can request. Keep each tool as a dedicated class implementing a Tool contract so it stays testable in isolation.
use EchoLabs\Prism\Tool;
use EchoLabs\Prism\Schema\StringSchema;
use EchoLabs\Prism\Schema\ObjectSchema;
class LookupOrderTool extends Tool
{
public function __construct(private OrderRepository $orders) {}
public function name(): string { return 'lookup_order'; }
public function description(): string
{
return 'Fetch the current status of a customer order by order ID.';
}
public function parameters(): ObjectSchema
{
return new ObjectSchema(
name: 'lookup_order_params',
description: 'Parameters for order lookup',
properties: [new StringSchema('order_id', 'The UUID of the order')],
requiredFields: ['order_id'],
);
}
public function handle(string $order_id): string
{
$order = $this->orders->findOrFail($order_id);
return json_encode(['status' => $order->status, 'eta' => $order->eta]);
}
}
Bind it in a service provider so the container resolves its dependencies automatically:
$this->app->bind(LookupOrderTool::class, fn ($app) =>
new LookupOrderTool($app->make(OrderRepository::class))
);
Persisting Conversation History
The most common mistake is storing conversation turns in the session or, worse, in a static property that survives across Octane workers. Use a proper conversations table instead.
// Migration
Schema::create('ai_conversation_turns', function (Blueprint $table) {
$table->id();
$table->ulid('conversation_id')->index();
$table->string('role'); // user | assistant | tool
$table->text('content');
$table->json('tool_calls')->nullable();
$table->timestamps();
});
A thin repository hydrates the message array Prism expects:
class ConversationRepository
{
public function history(string $conversationId): array
{
return AiConversationTurn::where('conversation_id', $conversationId)
->orderBy('id')
->get()
->map(fn ($turn) => [
'role' => $turn->role,
'content' => $turn->content,
])
->all();
}
public function append(string $conversationId, string $role, string $content): void
{
AiConversationTurn::create(compact('conversationId', 'role', 'content'));
}
}
Running the Agent Loop
use EchoLabs\Prism\Prism;
use EchoLabs\Prism\Enums\Provider;
class AgentService
{
public function __construct(
private ConversationRepository $conversations,
private LookupOrderTool $lookupOrder,
) {}
public function respond(string $conversationId, string $userMessage): string
{
$this->conversations->append($conversationId, 'user', $userMessage);
$response = Prism::text()
->using(Provider::OpenAI, 'gpt-4o-mini')
->withMessages($this->conversations->history($conversationId))
->withTools([$this->lookupOrder])
->withMaxSteps(5)
->generate();
$reply = $response->text;
$this->conversations->append($conversationId, 'assistant', $reply);
return $reply;
}
}
withMaxSteps caps the tool-call loop so a misbehaving model cannot spin indefinitely and exhaust your token budget.
Structured Output Contracts
When you need machine-readable responses, use a schema-bound response instead of parsing free text:
use EchoLabs\Prism\Schema\ObjectSchema;
use EchoLabs\Prism\Schema\StringSchema;
use EchoLabs\Prism\Schema\EnumSchema;
$schema = new ObjectSchema(
name: 'support_decision',
description: 'Triage decision for a support ticket',
properties: [
new EnumSchema('priority', 'Ticket priority', ['low', 'medium', 'high']),
new StringSchema('summary', 'One-sentence summary'),
],
requiredFields: ['priority', 'summary'],
);
$response = Prism::structured()
->using(Provider::OpenAI, 'gpt-4o-mini')
->withPrompt($ticketBody)
->withSchema($schema)
->generate();
$decision = $response->structured; // typed array matching your schema
Key Takeaways
- Persist turns to the database, never to session or static state — especially critical under Octane.
- Cap
withMaxStepsto prevent runaway tool-call loops from burning tokens. - One class per tool keeps each tool independently testable and container-resolvable.
- Structured output schemas eliminate brittle regex parsing of LLM responses.
- Bind tools via the service container so they receive their own dependencies cleanly.