The model answered. That is not the same as an answer you can use.
Between "the request succeeded" and "the feature worked" sit three separate questions that beginners collapse into one. Is it valid JSON? Does it have the keys my code reads? Are those keys true? They fail independently, they fail at different rates, and each needs a different defence.
This chapter is about building all three, in the right places, so that by the time generated content reaches your database it is the same shape every time — or it never got there at all.
Ask For JSON, and Mean It
Every serious provider has a mode that constrains output to syntactically valid JSON. Turn it on. There is no scenario where parsing prose you asked nicely for is better.
$data = $this->send('POST', '/chat/completions', [
'model' => $model,
'messages' => [
['role' => 'user', 'content' => $prompt],
],
'response_format' => ['type' => 'json_object'],
]);
That single option removes an entire class of failure: no preamble, no "Sure! Here's the JSON you asked for:", no trailing explanation of what it just produced.
What it does not do — and this is the distinction the rest of the chapter turns on — is make the JSON correct. It guarantees the output will parse. It says nothing about whether the object has a hero, whether that hero has a title, or whether the title is about the right business.
Syntax and meaning are different problems. Solve them separately, in different places.
The Fence It Adds Anyway
Not every call can use structured-output mode. Sometimes you are on an endpoint that does not support it, sometimes you are asking for a shape the mode cannot express, and sometimes you inherit a call written before you got there.
In those cases the model will, with great confidence, wrap your JSON in a markdown code fence. It has read a great deal of documentation, and in documentation JSON comes in fences.
$raw = trim((string) $result->json('choices.0.message.content', ''));
$raw = (string) preg_replace('/^```(?:json)?\s*/i', '', $raw);
$raw = trim((string) preg_replace('/\s*```$/', '', $raw));
$decoded = json_decode($raw, true);
Three lines, no cleverness, and they will fire more often than you expect. Do not try to be smart here — no brace-matching, no recursive extraction. Strip the fence, decode, and if the result is not what you wanted, treat it as a failure rather than trying to rescue it. A partial parse of a malformed response is how a null ends up in a database.
There is a second normalisation worth stealing. When you ask for a list of things, the model will sometimes return a single object instead of a one-element array, because your example showed one item.
if (! is_array($decoded) || $decoded === []) {
return [['action' => null, 'message' => 'Could not parse AI response.']];
}
if (! array_is_list($decoded)) {
return [$decoded];
}
return $decoded;
Note what the failure branch returns: the shape the caller expects, carrying a message, not null and not an empty array. The caller has one code path. It renders a message either way. That is the difference between a feature that degrades and a feature that explodes — and it costs one line.