Laravel AI SDK Tool Calling: Let the Model Run Your PHP

Laravel AI SDK tool calling lets the model run your PHP. Define typed tools with a JSON schema, tie parameters to enums, and cap agent loops with MaxSteps.

Steven Richardson
Steven Richardson
· 7 min read

An LLM that can only chat is a dead end in a real app. The moment a user asks "where's my order?", you need the model to actually look it up — hit your database, return a real answer, not a confident guess. That's what Laravel AI SDK tool calling gives you: typed PHP classes the model can invoke mid-conversation, with the SDK running them and feeding results back automatically.

This walks through building a real tool end to end. If you haven't wired up the SDK yet, start with the complete Laravel AI SDK guide and come back — I'm assuming you already have a provider configured.

Install and configure the Laravel AI SDK#

Pull the package in with Composer, publish its config and migrations, then run them. The migrations back conversation storage, so you want them even if tools are all you're after right now.

composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

Add at least one provider key to your .env. The SDK speaks to OpenAI, Anthropic, Gemini and more:

OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GEMINI_API_KEY=...

Everything else — the default provider, the default model — lives in config/ai.php.

Define a typed tool class#

A tool is a PHP class implementing the Tool contract. It answers three questions for the model: what it does (description()), what inputs it accepts (schema()), and how to run (handle()). Scaffold one with Artisan:

php artisan make:tool GetOrderStatus

That drops a class in app/Ai/Tools. Here's a real one that looks up an order and returns its status:

<?php

namespace App\Ai\Tools;

use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class GetOrderStatus implements Tool
{
    public function description(): Stringable|string
    {
        return 'Look up the current status of a customer order by its numeric ID.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'order_id' => $schema->integer()->min(1)->required(),
        ];
    }

    public function handle(Request $request): Stringable|string
    {
        $order = Order::find($request['order_id']);

        if ($order === null) {
            return "No order found for ID {$request['order_id']}.";
        }

        // Assumes Order casts `status` to the OrderStatus enum defined below.
        return "Order {$order->id} is currently: {$order->status->value}.";
    }
}

Two things worth calling out. The $request object behaves like an array — $request['order_id'] is the argument the model supplied, already coerced to the integer your schema declared. And handle() returns a string (or Stringable); that string is what the model reads back, so make it a readable sentence, not a raw JSON dump.

The schema() builder is the same JsonSchema fluent API the SDK uses for structured agent output. You get ->string(), ->integer(), ->array() and ->object(), each chainable with ->required(), ->min(), ->max() and ->enum().

Tie tool parameters to PHP enums#

Real inputs aren't free-form. An order status is one of a fixed set, and you've probably already modelled that as a backed enum. The schema's ->enum() constraint takes an array of strings, so the trick is to make your enum the single source of truth and derive the allowed values from it — never hand-type the list twice.

<?php

namespace App\Enums;

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';
}

Now a FindOrdersByStatus tool. Feed the enum's values straight into ->enum() so the schema and your domain model can't drift apart:

public function schema(JsonSchema $schema): array
{
    return [
        'status' => $schema->string()
            ->enum(array_column(OrderStatus::cases(), 'value'))
            ->required(),
    ];
}

public function handle(Request $request): Stringable|string
{
    // The schema already constrained the value, so from() is safe here.
    $status = OrderStatus::from($request['status']);

    $orders = Order::where('status', $status)
        ->latest()
        ->limit(10)
        ->get();

    if ($orders->isEmpty()) {
        return "No orders with status {$status->value}.";
    }

    return $orders->map(fn (Order $order) => "#{$order->id}")
        ->join(', ', ' and ');
}

array_column(OrderStatus::cases(), 'value') pulls ['pending', 'paid', 'shipped', 'cancelled'] out of the enum — add a case tomorrow and the tool's schema updates itself. Inside handle(), re-hydrate with OrderStatus::from(); because the model can only ever send a value the schema allowed, you get a real enum back without defensive parsing. If you're new to driving model casts off enums, my notes on backed enums in Laravel models cover that side.

Register tools and prompt the Laravel AI SDK agent#

Tools do nothing until an agent exposes them. An agent is a class implementing Agent and HasTools; its tools() method returns the instances the model is allowed to call. Generate one with make:agent:

php artisan make:agent SupportAgent
<?php

namespace App\Ai\Agents;

use App\Ai\Tools\FindOrdersByStatus;
use App\Ai\Tools\GetOrderStatus;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
use Stringable;

class SupportAgent implements Agent, HasTools
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return 'You are a support assistant. Use the provided tools to answer questions about orders. Never guess an order status.';
    }

    public function tools(): iterable
    {
        return [
            new GetOrderStatus,
            new FindOrdersByStatus,
        ];
    }
}

Send it a prompt and read the reply. The SDK handles the round trip: it offers the tools to the model, runs whichever one the model picks, hands the result back, and repeats until the model has a final answer.

$response = (new SupportAgent)->prompt('Where is order 4821?');

return (string) $response;
// "Order 4821 is currently: shipped."

You never call GetOrderStatus yourself. The model decides it needs an order lookup, the SDK executes handle(), and the returned string flows back into the conversation. You can pin a provider and model per prompt too — handy for running tool-heavy work on a cheaper model, and the foundation for provider fallback and failover:

use Laravel\Ai\Enums\Lab;

$response = (new SupportAgent)->prompt(
    'Where is order 4821?',
    provider: Lab::Anthropic,
    model: 'claude-haiku-4-5-20251001',
);

Cap agent steps with the MaxSteps attribute#

Tool calling is a loop: the model calls a tool, reads the result, then maybe calls another. Left unbounded, a confused model can churn through a dozen calls before it answers — every one a paid request. Cap it with the #[MaxSteps] attribute on the agent. Note it's a PHP attribute, not a fluent method — this is the usual mistake with Laravel AI SDK maxSteps.

<?php

namespace App\Ai\Agents;

use Laravel\Ai\Attributes\MaxSteps;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;

#[MaxSteps(5)]
class SupportAgent implements Agent, HasTools
{
    use Promptable;

    // instructions() and tools() as above...
}

Five steps is plenty for most support flows: look up an order, maybe check its line items, answer. The attribute sits alongside the SDK's other config attributes — #[Provider], #[Model], #[MaxTokens], #[Temperature] — so you set sane defaults in one place. To see what a call actually cost, inspect the response's usage after a prompt; a creeping token count is your early warning that a fuzzy tool description is sending the model into loops.

Wrapping Up#

Tool calling turns the AI SDK from a chat box into something that acts on your data — safely, because every input is typed and every tool is code you wrote. Keep tools small and single-purpose, return human-readable strings, and bound the loop with MaxSteps before you ship. From here, write tests so a model swap can't silently break a tool — faking AI agents in Pest shows how — and if you want other AI clients to reach these same tools, expose them over an MCP server.

FAQ#

What is tool calling in the Laravel AI SDK?

Tool calling lets the language model invoke your PHP code during a conversation. You register tools — classes with a description, a typed schema, and a handle method — on an agent, and the model chooses when to call them. The SDK executes the tool, feeds the result back to the model, and the model uses it to form its answer. It's how you let an AI reach real data instead of hallucinating it.

How do I define a tool for a Laravel AI agent?

Run php artisan make:tool to scaffold a class in app/Ai/Tools, then implement the Tool contract's three methods. description() tells the model what the tool is for, schema() returns a typed input definition built from the injected JsonSchema builder, and handle() receives the validated arguments and returns a string. You make it available by returning an instance from your agent's tools() method.

How does the Laravel AI SDK decide when to call a tool?

It doesn't — the model does. On each prompt the SDK sends the tool descriptions and schemas to the provider, and the model decides whether answering needs a tool and which one. Your job is a clear description() and a tight schema so the model picks correctly; a vague description is the usual reason a tool gets ignored or misused.

How do I stop an AI agent from looping forever?

Add the #[MaxSteps(n)] attribute to the agent class. Each tool call plus the model's follow-up counts as a step, so the attribute hard-caps how many round trips a single prompt can trigger. Without it, a model that keeps deciding it needs one more tool call can rack up cost fast; a limit of around five is a sane default for most workflows.

Can Laravel AI SDK tool parameters use PHP enums?

The schema's enum() constraint takes an array of strings rather than a PHP enum class directly, but bridging the two is easy. Pass array_column(YourEnum::cases(), 'value') into enum() so the allowed values come straight from your backed enum, then call YourEnum::from() inside handle() to turn the validated string back into an enum instance. That keeps your domain model as the single source of truth.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.