Skip to content

Laravel AI SDK: Tool-Calling Agents and Conversation Persistence

Build reliable tool-calling AI agents in Laravel using the AI SDK, with typed tool definitions, structured responses, and conversation history persisted to your database without leaking state between requests.

Published · Updated · 3 min read

Laravel AI SDK: Tool-Calling Agents and Conversation Persistence

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.

Frequently asked questions

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.

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.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project