Tech Verse Logo
Enable dark mode
Wiring LLMs with Tool Function Calling

Wiring LLMs with Tool Function Calling

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

When OpenAI introduced function calling, the pitch sounded simple: hand the model a JSON schema, and it will hand back clean arguments to invoke native application code. In practice, LLMs break schema rules often. They invent optional parameters, cast integer IDs into strings, pass null for required fields, or omit parameters entirely when prompt context gets full.

Treating raw LLM tool call payloads as trusted typed input leads directly to SQL exceptions and 500 errors. You need strict schema definitions, runtime argument validation, and an error feedback loop that lets the model correct its own bad payloads.

Designing Unambiguous Tool Schemas

Your schema isn't just a validation rule set for your backend code; it's the primary prompt structure directing the model's intent. Ambiguous descriptions yield flaky function calls.

When defining tool schemas in PHP 8.3 or Next.js 16, keep these rules in mind:

  • Explicit enum values: Never ask for 'a status like open or closed' in a text description. Define explicit JSON Schema enums with string literals.
  • Strict property requirements: Set additionalProperties: false in JSON Schema definitions when using OpenAI's Structured Outputs feature to prevent hallucinated keys.
  • Types over descriptions: A field defined as type: "integer" is far more reliable than a type: "string" field with a human-readable note saying 'must be an integer ID'.

Defining Tools in Laravel 12 with PHP 8.3

Here is how to structure a tool handler class in PHP 8.3 using openai-php/client v0.10 inside a Laravel 12 application. We define the JSON Schema directly alongside a typed execution method.

namespace App\Services\LLM\Tools;

use Illuminate\Support\Facades\Validator;
use InvalidArgumentException;

class SearchDatabaseTool
{
    public static function definition(): array
    {
        return [
            'type' => 'function',
            'function' => [
                'name' => 'search_database',
                'description' => 'Search customer records by email or account ID.',
                'parameters' => [
                    'type' => 'object',
                    'properties' => [
                        'query' => [
                            'type' => 'string',
                            'description' => 'The search term (email address or customer string ID)',
                        ],
                        'limit' => [
                            'type' => 'integer',
                            'description' => 'Maximum results to return. Default is 5, max is 20.',
                        ],
                    ],
                    'required' => ['query'],
                    'additionalProperties' => false,
                ],
                'strict' => true,
            ],
        ];
    }

    public function handle(array $arguments): array
    {
        $validator = Validator::make($arguments, [
            'query' => ['required', 'string', 'min:3'],
            'limit' => ['nullable', 'integer', 'min:1', 'max:20'],
        ]);

        if ($validator->fails()) {
            throw new InvalidArgumentException($validator->errors()->toJson());
        }

        $validated = $validator->validated();
        $limit = $validated['limit'] ?? 5;

        return [
            'status' => 'success',
            'results' => [
                ['id' => 'acc_102', 'name' => 'Acme Corp', 'email' => $validated['query']],
            ],
        ];
    }
}

Runtime Argument Validation

Notice that we don't assume the payload matches our schema just because the API provider accepted it. Local runtime validation using Laravel's validator or Zod schemas in Next.js 16 guarantees that malformed input never hits your database or external APIs.

When validation fails, the worst choice is throwing an unhandled exception that returns a 500 status to your frontend. The LLM won't know what happened, and the user gets stuck with a broken interface.

Building a Self-Healing Error Feedback Loop

When tool argument validation fails, pass the validation error message right back to the model inside a tool message role. Models like GPT-4o and Claude 3.5 Sonnet parse these validation errors and fix their function parameters in the following step.

Here is a complete execution loop in PHP 8.3 that runs tool calls, catches validation failures, and gives the model up to two attempts to correct its arguments before throwing an error.

namespace App\Services\LLM;

use App\Services\LLM\Tools\SearchDatabaseTool;
use OpenAI\Client;
use InvalidArgumentException;
use RuntimeException;

class LLMExecutor
{
    public function __construct(private Client $client) {}

    public function runPrompt(string $userPrompt): string
    {
        $messages = [
            ['role' => 'user', 'content' => $userPrompt],
        ];

        $tool = new SearchDatabaseTool();
        $tools = [SearchDatabaseTool::definition()];

        for ($attempt = 0; $attempt < 3; $attempt++) {
            $response = $this->client->chat()->create([
                'model' => 'gpt-4o',
                'messages' => $messages,
                'tools' => $tools,
            ]);

            $choice = $response->choices[0];
            $message = $choice->message;

            $messages[] = $message->toArray();

            if (empty($message->toolCalls)) {
                return $message->content ?? '';
            }

            foreach ($message->toolCalls as $toolCall) {
                if ($toolCall->function->name !== 'search_database') {
                    continue;
                }

                $rawArgs = json_decode($toolCall->function->arguments, true) ?? [];

                try {
                    $result = $tool->handle($rawArgs);

                    $messages[] = [
                        'role' => 'tool',
                        'tool_call_id' => $toolCall->id,
                        'content' => json_encode($result),
                    ];
                } catch (InvalidArgumentException $e) {
                    $messages[] = [
                        'role' => 'tool',
                        'tool_call_id' => $toolCall->id,
                        'content' => json_encode([
                            'error' => 'Invalid parameters provided.',
                            'details' => json_decode($e->getMessage(), true),
                        ]),
                    ];
                }
            }
        }

        throw new RuntimeException('Model failed to produce valid tool arguments after retries.');
    }
}

Tool Handling in Next.js 16 Route Handlers

If you build in TypeScript on Next.js 16 with React 19, the core pattern remains identical, but Zod gives you inferred static types along with runtime parsing. Here is how a Next.js App Router route handler parses and validates tool calls with Zod:

import { NextResponse } from 'next/server';
import { z } from 'zod';

const SearchArgsSchema = z.object({
  query: z.string().min(3, 'Query must be at least 3 characters'),
  limit: z.number().int().min(1).max(20).default(5),
});

export async function POST(req: Request) {
  const { toolCallId, rawArguments } = await req.json();

  const parseResult = SearchArgsSchema.safeParse(rawArguments);

  if (!parseResult.success) {
    return NextResponse.json({
      role: 'tool',
      tool_call_id: toolCallId,
      content: JSON.stringify({
        status: 'validation_error',
        errors: parseResult.error.flatten().fieldErrors,
      }),
    });
  }

  const { query, limit } = parseResult.data;

  return NextResponse.json({
    role: 'tool',
    tool_call_id: toolCallId,
    content: JSON.stringify({
      status: 'success',
      results: [{ id: 'acc_102', query, limit }],
    }),
  });
}

Production Gotchas and Cost Trade-offs

Feeding validation errors back to the model keeps application execution stable, but it introduces specific production issues you need to manage:

1. Latency Overhead

Every recovery round-trip adds 350ms to 900ms of API network latency. If your model gets stuck trying to fix invalid arguments, the response time spikes past 3 seconds. Set a maximum retry limit of 2 or 3 iterations before breaking the loop.

2. Stateful Side-effects

Never run destructive actions like sending an email or initiating a charge inside a tool method before validation passes. If validation fails after partially modifying state, you end up with duplicate operations when the model retries the call.

3. Context Token Growth

Appending failed tool calls and detailed JSON error structures back to the conversation array increases prompt token counts. Keep validation error responses small. Instead of sending full stack traces, return concise error arrays like {"limit": ["Must be an integer"]}.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    LLM API Pricing Comparison for Side Projects

    LLM API Pricing Comparison for Side Projects

    Building Semantic Search with Laravel 12 and Next.js 16

    Building Semantic Search with Laravel 12 and Next.js 16

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Wiring LLMs with Tool Function Calling

    Wiring LLMs with Tool Function Calling

    LLM API Rate Limit Caching and Quota Guarding

    LLM API Rate Limit Caching and Quota Guarding

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Pgvector Similarity Search in Production Postgres

    Pgvector Similarity Search in Production Postgres

    React Drag and Drop with dnd-kit and Laravel

    React Drag and Drop with dnd-kit and Laravel

    Building a Production RAG Pipeline in PHP 8.3

    Building a Production RAG Pipeline in PHP 8.3

    Laravel LLM Integration: Queues and Real-Time Frontend UI

    Laravel LLM Integration: Queues and Real-Time Frontend UI