Building Tool-Calling AI Agents in Laravel
Most Laravel AI tutorials stop at a single chat() call. Production agents are different: they need to call your application's own services as tools, enforce typed output contracts, and remember what happened in previous turns — all without leaking state between users.
This article uses Prism, the first-class Laravel AI SDK, to wire all three concerns together cleanly.
1. Registering Tools the Right Way
Prism tools are plain PHP objects that implement EchoLabs\Prism\Contracts\Tool. Keep one tool per class and resolve dependencies through the service container.
// app/AiTools/LookupOrderTool.php
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 order status and line items for a given order ID.';
}
public function parameters(): ObjectSchema
{
return new ObjectSchema(
properties: [new StringSchema('order_id', 'The UUID of the order')],
required: ['order_id'],
);
}
public function handle(string $order_id): string
{
$order = $this->orders->findOrFail($order_id);
return json_encode([
'status' => $order->status,
'total' => $order->total_cents,
'items' => $order->lines->pluck('sku'),
]);
}
}
Bind it in a service provider so Prism can resolve it:
$this->app->bind(LookupOrderTool::class, fn ($app) =>
new LookupOrderTool($app->make(OrderRepository::class))
);
2. Enforcing Structured Output Contracts
Free-form LLM text is a liability. Use Prism's withSchema() to force the model into a typed response shape and validate it immediately.
use EchoLabs\Prism\Prism;
use EchoLabs\Prism\Schema\ObjectSchema;
use EchoLabs\Prism\Schema\StringSchema;
use EchoLabs\Prism\Schema\EnumSchema;
$schema = new ObjectSchema(
properties: [
new EnumSchema('intent', 'User intent', ['order_status', 'refund', 'other']),
new StringSchema('order_id', 'Extracted order UUID, or empty string'),
],
required: ['intent', 'order_id'],
);
$response = Prism::text()
->using('openai', 'gpt-4o-mini')
->withSchema($schema)
->withPrompt('Classify this message: "Where is order abc-123?"')
->generate();
$data = json_decode($response->text, associative: true);
// $data['intent'] === 'order_status'
// $data['order_id'] === 'abc-123'
This gives you a PHP array you can pass directly into a DTO or action without regex hacks.
3. Persisting Conversation History
Multi-turn agents need history. Store it in your database, not in a session or cache, so it survives queue workers and horizontal scaling.
// migrations: ai_conversations (id, user_id, messages JSON, created_at, updated_at)
class ConversationRepository
{
public function loadMessages(int $userId): array
{
return AiConversation::firstOrCreate(['user_id' => $userId])
->messages ?? [];
}
public function appendMessages(int $userId, array $newMessages): void
{
AiConversation::updateOrCreate(
['user_id' => $userId],
['messages' => array_merge($this->loadMessages($userId), $newMessages)]
);
}
}
Then feed history back into Prism on every turn:
$history = $repo->loadMessages($userId);
$response = Prism::text()
->using('openai', 'gpt-4o-mini')
->withMessages($history) // prior turns
->withTools([app(LookupOrderTool::class)])
->withMaxSteps(5) // cap tool-call loops
->withPrompt($userMessage)
->generate();
$repo->appendMessages($userId, [
['role' => 'user', 'content' => $userMessage],
['role' => 'assistant', 'content' => $response->text],
]);
withMaxSteps() is critical — it prevents runaway tool-call loops from burning tokens when the model gets confused.
4. Dispatching Agent Turns as Jobs
For non-interactive flows (webhooks, scheduled summaries), dispatch each agent turn as a queued job and write results back to the database. This decouples the LLM latency from your HTTP response time entirely.
class RunAgentTurnJob implements ShouldQueue
{
use Dispatchable, Queueable;
public function __construct(
public readonly int $userId,
public readonly string $message,
) {}
public function handle(ConversationRepository $repo): void
{
// same Prism call as above
// write $response->text back to a results table
}
}
Takeaways
- One tool, one class — keep tools small, injected via the container, and independently testable.
- Schema-first output — never parse free-form LLM text; enforce a JSON schema and validate immediately.
- Database-backed history — sessions and caches are wrong for conversation state; a proper table survives restarts and scales horizontally.
- Cap tool-call steps —
withMaxSteps()is a hard budget, not optional. - Queue agent turns — decouple LLM latency from HTTP with jobs; poll or broadcast results back to the UI.