OpenAI Function Calling in a Laravel App
How to wire OpenAI function calling into a Laravel backend so an LLM can trigger real actions, plus the validation and loop-depth safeguards you actually need.
I built a support chatbot for a client's Laravel app last month, and the first version was useless the moment someone asked "what's the status of order #4821." The model could talk about orders in general terms, but it had no way to actually go look one up — it would just guess or hedge. That's the exact problem OpenAI function calling solves, and it's a different kind of integration than "call the chat endpoint and print the response," which is usually as far as most tutorials go.
Function calling (OpenAI now also calls it "tool calling") lets the model decide, mid-conversation, that it needs to run one of your actual backend functions — fetch an order, create a ticket, check inventory — and hand back structured arguments for it instead of free text. Your Laravel app runs the real function, and feeds the result back in. The model never touches your database directly; it just asks for what it needs in a predictable shape.
Defining the tools the model can call
You describe each available function as a JSON schema and send that schema along with every chat request. Keep the description honest and specific — a vague description is the single biggest reason models call the wrong tool or skip calling one entirely:
$tools = [['type' => 'function','function' => ['name' => 'get_order_status','description' => 'Look up the current status and tracking info for a customer order by its order number.','parameters' => ['type' => 'object','properties' => ['order_number' => ['type' => 'string', 'description' => 'The numeric order ID, e.g. 4821'],],'required' => ['order_number'],],],],];
Sending the request and handling a tool call
The response won't always contain a tool call — plenty of messages are just conversational and don't need one. Check finish_reason before assuming there's a function to run:
$response = Http::withToken(config('services.openai.key'))->post('https://api.openai.com/v1/chat/completions', ['model' => 'gpt-4o-mini','messages' => $this->conversationHistory,'tools' => $tools,])->json();$choice = $response['choices'][0];if ($choice['finish_reason'] === 'tool_calls') {foreach ($choice['message']['tool_calls'] as $call) {$result = $this->runTool($call['function']['name'], json_decode($call['function']['arguments'], true));$this->conversationHistory[] = ['role' => 'tool','tool_call_id' => $call['id'],'content' => json_encode($result),];}// send the updated history back so the model can respond using the result}
runTool here is just a match statement dispatching to real Eloquent calls — get_order_status becomes Order::where('number', $args['order_number'])->first(), nothing exotic. The actual authorization and business logic live exactly where they already do in the rest of your app; the model is just deciding when to trigger them.
Where this goes wrong in practice
The arguments the model sends are not guaranteed to be valid, even with a schema. I've seen order_number come back as "4821 " with a trailing space, as an integer when the schema said string, and once as the literal string "the customer's order" because the conversation genuinely hadn't established a real number yet. Validate every tool call's arguments the same way you'd validate a request from an untrusted client, because that's effectively what it is:
$validated = validator($args, ['order_number' => 'required|string|regex:/^[0-9]+$/',])->validate();
If validation fails, don't throw a 500 back up the chain — return a structured error result to the model instead, the same way you'd return one to the user:
return ['error' => 'order_number must be numeric. Ask the customer to confirm their order number.'];
The model will usually handle that gracefully and ask a clarifying question, which is a far better outcome than a crashed job or a silently wrong lookup.
I'd also push back on a common pattern I see in tutorials: exposing a single generic "run_sql_query" or "run_any_action" tool and letting the model figure out the rest. That's tempting because it's less code, but it also means the model effectively has arbitrary access to whatever that tool can reach, and your validation surface becomes "trust the model's judgment," which isn't a real security boundary. Define narrow, specific tools that map to functions you'd be comfortable calling from an untrusted API request, because that's the actual threat model here.
Keeping the loop from running forever
A tool call can lead to another tool call — the model gets the order status, decides it also needs the customer's shipping address, calls another function, and so on. Cap the number of tool-call round trips per request (3-4 is plenty for most support-bot style use cases) and fall back to a plain text response if you hit the cap, rather than letting a single user message turn into an unbounded chain of API calls and Eloquent queries:
if ($this->toolCallDepth >= 4) {return $this->forceTextResponse();}
This matters more than it sounds like it should — I had a version of this bot loop three times on a single confused message before I added the cap, and OpenAI billing per call adds up fast when a bug turns one user message into eight API round trips.
What this actually unlocks
Once the wiring is in place, adding a new capability is genuinely just adding a new tool definition and a new case in runTool — no prompt engineering gymnastics needed to get the model to "remember" to use it correctly, because the schema and description do that work. For the client project, we went from "a chatbot that talks about orders" to "a chatbot that resolves actual order-status tickets without a human" in about two days once the first tool was wired up correctly, and most of that time was writing the validation, not the integration itself.
If you're building anything beyond a pure Q&A bot in a Laravel backend, function calling is the piece that turns an LLM from a text generator into something that can actually do work inside your system — and the validation and tool-scoping decisions above matter more to getting it right than the API call itself does.