A Model That Asks Does Not Invent
Wiring a language model into an application takes a few lines of code these days. You send text, you get text back. As long as you want a translation or a summary, it works beautifully. The trouble starts the moment it has to answer something only your application knows.
Say you are building customer support for an e-shop. You want the model to handle questions like “where is my parcel” or “when will my order arrive”. It writes well, it stays polite, it phrases an answer nicely. It just has no idea what orders sit in your database, because you can see into it and the model cannot.
The first idea is usually to attach the data it needs to the question. But then you would have to know in advance what the customer will ask. Sending the whole database is not an option, and even if it were, you pay for every word you send.
And if you do not give it the data, something worse can happen than a refusal to answer. The model does not have to admit that it does not know. Now and then it makes an answer up, and a made-up answer sounds every bit as convincing as a true one. Your customer walks away with a tracking number that never existed.
There is another way, though: swap the roles. Instead of guessing what the model will need, you describe what your application is able to find out. The model then speaks up on its own, saying it would like to look up order 2026–001234, you hand it the result and it composes the answer for the customer. The technique is called tool calling, sometimes also function calling, and in PHP it is handled for you by the AI Access library, which has just reached version 1.0.
One thing about it matters more than anything else: the model does not run your code. It only says what it would like called and with which arguments. Whether that actually happens is your application's decision. It sounds like a detail, but it is the whole difference between a model that asks and a model that commands.
Tool calling is a technique that allows LLMs (like ChatGPT or Claude) to request your PHP application to execute a specific function, fetching real-time data or performing an action. Structured output, on the other hand, guarantees that the model returns its response in a strictly defined, valid JSON format. In this guide, we will implement both using the AI Access PHP library.
How to Setup Tool Calling in PHP
You describe a tool with a Tool object: a name, a description of
what it is for, a schema of the arguments and the function that performs it. Do
not look for the model name in the example, the client carries the one you gave
it when you created it; you can also pass it straight to
createChat():
use AIAccess\Chat\Tool;
$chat = $client->createChat();
$chat->addTool(new Tool(
name: 'findOrder',
description: 'Returns the status and shipping date of the order with the given number.',
parameters: [
'type' => 'object',
'properties' => [
'orderNumber' => ['type' => 'string', 'description' => 'Order number in the form 2026-001234'],
],
'required' => ['orderNumber'],
],
handler: fn(array $args) => $this->orders->findByNumber($args['orderNumber'])->toArray(),
));
echo $chat->sendMessage('Where is my order 2026-001234?')->getText();
That is all of it. A single sendMessage() covers the whole
exchange: the model asks for a tool, the library calls your handler, sends the
result back and waits for the model to answer. The handler returns an array or a
string, because that is exactly what the model gets to read.
The description deserves a pause, because it is easily dismissed as paperwork. It is a prompt like any other. The model decides from it whether to call the tool at all, and it has no other clue to go on. “Returns the status and shipping date of the order with the given number” is a good description. “Orders” is a bad one. The same goes for the individual arguments: tell the model what an order number looks like and it will stop putting the customer's name there.
The Tool Loop and API Call Limits
One round means one question from the model and one answer from you. The interesting part is that several of them happen in a row without you doing anything about it.
Add two more tools to the previous one, findCustomer to look up
a customer by e-mail and listOrders to list their orders. Asked
what orders does jana@example.com have, the model first calls
findCustomer, waits for the result, takes the identifier out of it
and only then reaches for listOrders. You never described that
procedure; the model assembled it from the descriptions you gave it.
The whole series is called a loop. It has a cap, because otherwise the model could go round in circles at your expense:
$chat->setToolLoop(maxRounds: 3);
The default of eight rounds is plenty for ordinary work; if the conversation
does not settle even by then, you get a TooManyRoundsException. And
if you want to know what the whole exchange cost, ask
getTotalUsage(), because getUsage() on the response
speaks only about the last round.
Security and Authorization in Function Calling
So far it looks as though the model can reach anything you offer it. For looking up an order that is fine. For cancelling one it is not, and there you want to decide yourself.
The automatic loop starts only when every tool being called has a handler. As soon as one is missing, the library stops and hands control back to you. It is not an oversight you have to guard against, but the way to say “I will watch this one myself”:
$chat->addTool(new Tool(
name: 'cancelOrder',
description: 'Cancels the order with the given number.',
parameters: [
'type' => 'object',
'properties' => ['orderNumber' => ['type' => 'string']],
'required' => ['orderNumber'],
],
// the handler is deliberately missing
));
$response = $chat->sendMessage('Please cancel my order 2026-001234.');
foreach ($response->getToolCalls() as $call) {
$number = $call->arguments['orderNumber'];
if ($this->user->isAllowed('order', 'cancel')) {
$this->orders->cancel($number);
$chat->addToolResult($call, "Order $number has been cancelled.");
} else {
$chat->addToolResult($call, 'You are not allowed to cancel orders.', isError: true);
}
}
echo $chat->sendMessage()->getText();
The model asks for the cancellation, but your code performs it only after the
check. Note the second branch: a refusal is a valid result too. You send
it back with the isError flag and the model knows what to do with
it, so the customer learns why it could not be done instead of waiting for
something that will never come.
The same route fits anywhere you want to merely log or restrict the calls.
And when you need the opposite, namely to make the model reach for one
particular tool, you ask for it with setToolChoice().
Error Handling and AI Hallucinations
Every so often the model asks for a tool that does not exist, or sends arguments that do not match the schema. It happens now and then, and above all it is not your application's fault, so the library does not treat it as an exception.
It sends the mistake back to the model as an error result and lets it correct itself. Models are surprisingly good at this: they read what was wrong and call the tool again properly. Had an exception been thrown instead, you would lose the whole answer over a mistake the model can fix on its own.
A failure of your own handler is a different matter. When the database goes down, the exception propagates to you, which is right, because a broken database is not for the model to deal with. If you do want it to learn about the failure and try another route, you ask for that:
$chat->setToolLoop(catchErrors: true);
Even then one exception to the exceptions holds: errors of the
Error kind, meaning typos in your own code, always propagate. Were
they sent to the model as a tool result, your bug would hide inside the
conversation and you would never hear about it.
More happens under the hood than would be worth listing here. Gemini, for instance, never announces a tool call among the reasons an answer ended, and reasoning models want their thoughts back unchanged in the next round or they reject the request. The library takes care of that for you, so you only ever meet it when you look into raw responses.
Structured Output in PHP: Enforcing JSON Schemas
So far the model has been asking. Now turn it around: you want to pull data out of a customer e-mail and into your database. No question, no action, just values.
The obvious move is to ask for JSON in the prompt. Nine times out of ten it works. The tenth time you get this:
Certainly! Here is the requested data:
```json
{"category": "complaint", "orderNumber": "2026-001234"}
```
The answer is factually right, but json_decode() fails on it,
because there is a sentence and a markdown block around it. Another time the
model names a key differently than you wanted, or returns a number as text. You
will not catch this in testing, because most of the time it simply works. You
catch it in production, usually on data nobody expected.
The fix is not to ask more nicely, but to prescribe the shape of the answer with a JSON schema:
$chat->setResponseSchema([
'type' => 'object',
'properties' => [
'category' => ['type' => 'string', 'enum' => ['complaint', 'question', 'spam']],
'orderNumber' => ['type' => ['string', 'null'], 'description' => 'Order number if the text mentions one'],
'urgency' => ['type' => 'integer', 'description' => 'Urgency from 1 to 5'],
],
'required' => ['category', 'orderNumber', 'urgency'],
'additionalProperties' => false,
]);
$data = $chat->sendMessage($email)->getJson();
getJson() returns the decoded data directly. No stripping of
markdown, no extra json_decode(). And above all: the difference
from a prompt is not that the model suddenly respects the instruction better.
The shape of the answer is enforced by the provider, not by the
model's good will.
Three things about schemas are worth knowing. Describe the individual fields,
the model reads them and follows them; the description “amount without the
currency and without spaces” gets you 1500, whereas a bare
“amount” may well get you "1 500 USD". Use enum
for closed lists, as with the category above, and the model cannot invent a
fourth option. And expect strict mode: OpenAI and Grok demand
additionalProperties: false and every key in required,
so keep an optional value there and let it be null instead, exactly
as with the order number.
One exception is worth knowing in advance: DeepSeek has no schemas. Its API
can ask for JSON but cannot prescribe its shape, so
setResponseSchema() is there, yet it always ends in an exception
pointing you to plain JSON mode. The other four providers and the generic client for the
OpenAI dialect all handle schemas.
Structured Output vs. Tool Calling: Which One to Choose?
Both make the model produce JSON following a schema, which is why they get confused. The difference is in who hands what to whom.
Structured output is the shape of the answer. The model finishes and you receive data. Use it when you want a result from the model: sorting an e-mail into a category, extracting values from text, splitting an address into parts.
A tool call is a question aimed at you. The model stops and waits for you to find something out, then carries on. Use it when the model needs information or an action that your application owns.
Put simply: structured output is an answer, a tool is a question. And nothing stops you from using both at once, letting the model first find out the status of an order and then hand you a finished record for the database.
AI Access is a PHP
library that unifies OpenAI, Claude, Gemini, DeepSeek and Grok behind one
interface, with no dependencies at all. Besides tool calling and structured
output it does streaming, images and documents as
input, embeddings for
meaning-based search and batch processing at half the
price. Install it with composer require ai-access/ai-access, it
needs PHP 8.3 and ships around twenty runnable examples. I wrote about how
it came about on my own blog.
Sign in to submit a comment