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

Laravel AI SDK: Tool-Calling Agents and Conversation Persistence

3 min read Mohamed Said Mohamed Said

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 withMaxSteps to 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.

Found this useful?

Frequently Asked Questions

2 questions
Q01 How do I prevent conversation history from growing too large and exceeding the context window?
Implement a sliding-window strategy in your repository: return only the last N turns, or summarise older turns into a single system message. You can also store a `summary` column on the conversation and regenerate it periodically with a background job.
Q02 Can tool-calling agents be tested without hitting the real OpenAI API?
Yes. Prism ships with a fake driver you can swap in tests via `Prism::fake()`. You provide canned responses and assert which tools were called, keeping your test suite fast and deterministic.

Continue reading

More Articles

View all