An LLM JSON output parsing error almost always comes from one of five things: the model wrapped the JSON in a Markdown code fence, added a sentence before or after it, hit its output token limit and stopped mid-object, emitted a raw control character inside a string, or returned valid JSON with the wrong shape. The fix is a pipeline, not a regex: check the stop reason first, extract the JSON, decode it with exceptions turned on, validate the shape, and retry once or twice with the exact error sent back to the model.
This guide does that in PHP 8.3 (it uses json_validate(), added in 8.3, and array_is_list(), added in 8.1). No Composer packages are required for the code below.
Why PHP Hides an LLM JSON Output Parsing Error
In Python the parser throws and you see it. In PHP, the default behavior is quieter. The php.net manual for json_decode() (read 2026-09-23) states that the function returns null when the JSON cannot be decoded, and also returns null when the input is the perfectly valid JSON string null. Nothing is thrown unless you ask for it.
So this line looks fine and fails silently:
$data = json_decode($modelReply, true);
$title = $data['title'] ?? 'Untitled';
When the model returns a fenced block, $data is null, the null coalescing operator hides it, and your app saves “Untitled” to the database. That is the classic shape of a bug where the code runs but gives the wrong result: no warning, no stack trace, just bad data downstream. The first rule is to make failure loud by passing JSON_THROW_ON_ERROR, which the same manual page notes has been available since PHP 7.3.
The Five Failure Shapes, and What Each One Needs
Before writing any cleanup code, know which failure you are looking at. They need different responses, and treating them all as “bad JSON, retry” wastes calls.
| What you see in the raw reply | Typical PHP error message | Likely cause | Right response |
|---|---|---|---|
Starts with three backticks and json | Syntax error | Model formatted for a chat UI | Strip the fence locally, no retry needed |
| “Here is the data:” before the brace | Syntax error | Conversational preamble | Extract from first { to last } |
| Ends mid-string or mid-array | Syntax error | Output token limit reached | Raise the limit or split the task; do not retry as-is |
| Parses, but a key is missing or a number is a string | No decode error at all | Model ignored or misread the schema | Validate the shape, retry with the specific complaint |
| A raw line break inside a string value | Control character error, possibly incorrectly encoded | Unescaped newline or tab | Retry with feedback; do not “repair” blindly |
The row competitors tend to skip is the fourth one. Most guides for this error stop once the JSON decodes, but valid JSON with the wrong shape is the failure that reaches production, because nothing throws.
Step 1: Check the Stop Reason Before You Parse
Every major chat API reports why the model stopped. If it stopped because it ran out of output tokens, the reply is incomplete by definition, even if the fragment happens to parse. A cut that lands just after a closing brace of a nested object can leave you with syntactically valid JSON that is missing half its fields.
The field name depends on your provider. As one example, the OpenAI API reference for creating a chat completion (read 2026-09-23) documents finish_reason with the value stop for a natural stop and length when the token limit was reached. Other providers use different names for the same idea, so check your provider’s own reference rather than copying a field name from a tutorial.
Normalize this in your adapter to a single boolean, complete, and treat anything other than a normal stop as a hard failure. Retrying a truncated reply with the same token limit usually just produces another truncated reply.
Step 2: Extract and Decode With Exceptions On
This code targets PHP 8.3 with declare(strict_types=1). It assumes your schema has a JSON object at the top level, which is the common case and makes extraction simpler: find the first { and the last }.
<?php
declare(strict_types=1);
class ModelOutputException extends RuntimeException {}
final class TruncatedOutputException extends ModelOutputException {}
function extractJsonCandidate(string $raw): string
{
$text = trim($raw);
// Drop a UTF-8 byte order mark if the reply starts with one.
if (str_starts_with($text, "\u{FEFF}")) {
$text = substr($text, 3);
}
// Unwrap a Markdown code fence such as ```json ... ```
if (preg_match('/^```[a-zA-Z]*\s*(.*?)\s*```$/s', $text, $m) === 1) {
$text = $m[1];
}
$open = strpos($text, '{');
if ($open === false) {
throw new ModelOutputException('No JSON object found in the reply.');
}
$close = strrpos($text, '}');
if ($close === false || $close < $open) {
throw new TruncatedOutputException('JSON object opens but never closes.');
}
return substr($text, $open, $close - $open + 1);
}
function decodeModelJson(string $raw): array
{
$candidate = extractJsonCandidate($raw);
try {
$data = json_decode($candidate, true, 64, JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING);
} catch (JsonException $e) {
throw new ModelOutputException('Invalid JSON: ' . $e->getMessage() . '.', 0, $e);
}
if (!is_array($data)) {
throw new ModelOutputException('Expected a JSON object at the top level.');
}
return $data;
}
A few choices here are deliberate. The depth is lowered from the default of 512 to 64, because no reasonable schema nests that deep and a runaway reply should fail fast. JSON_BIGINT_AS_STRING keeps a very large integer ID from being silently converted to a float and losing precision. The first-brace, last-brace extraction handles preamble and trailing chatter, but it will misfire if the model’s trailing prose itself contains a closing brace. That is rare, and the decode step catches it when it happens.
You may be tempted to call json_validate() first and then decode. The php.net manual for that function advises against calling it immediately before json_decode(), because the string gets parsed twice. Use json_validate() only when you need a yes or no answer and do not need the data, such as a quick check in a log-scanning script.
Step 3: Validate the Shape, Not Just the Syntax
Decoding tells you the reply is JSON. It does not tell you the reply is your JSON. Suppose you asked for a ticket summary with a title, a list of tags and an integer confidence from 0 to 100:
function validateSummary(array $data): array
{
$errors = [];
if (!isset($data['title']) || !is_string($data['title']) || trim($data['title']) === '') {
$errors[] = '"title" must be a non-empty string';
}
if (!isset($data['tags']) || !is_array($data['tags']) || !array_is_list($data['tags'])) {
$errors[] = '"tags" must be an array of strings';
} else {
foreach ($data['tags'] as $i => $tag) {
if (!is_string($tag)) {
$errors[] = "\"tags[$i]\" must be a string";
}
}
}
if (!isset($data['confidence']) || !is_int($data['confidence'])
|| $data['confidence'] < 0 || $data['confidence'] > 100) {
$errors[] = '"confidence" must be an integer from 0 to 100';
}
if ($errors !== []) {
throw new ModelOutputException('Wrong shape: ' . implode('; ', $errors) . '.');
}
return $data;
}
This catches the quiet failures: "confidence": "85" as a string, 85.0 as a float, "tags": "php, wordpress" as one string instead of a list, or a key the model renamed to ticket_title. Models also invent fields you never asked for, the JSON cousin of an AI inventing a function that does not exist, and this approach simply ignores extras rather than trusting them. For larger schemas, a JSON Schema validator installed through Composer is less tedious than hand-written checks, but the principle is the same: every field your code reads gets checked before it is used.
Step 4: Retry With the Real Error, and Stop
When decoding or validation fails, the most useful retry is not the identical request. Send the model its own bad reply plus the exact error message, and ask for the corrected object only. Specific feedback like "confidence" must be an integer from 0 to 100 gives the model something to act on, while “that was invalid” does not.
/**
* $callModel receives the message list and returns
* ['text' => string, 'complete' => bool]. Set 'complete' to true only
* when your provider reports a normal stop, not a token-limit cutoff.
*/
function getSummary(callable $callModel, string $prompt, int $maxAttempts = 3): array
{
$messages = [['role' => 'user', 'content' => $prompt]];
$lastError = 'no attempt made';
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$reply = $callModel($messages);
try {
if ($reply['complete'] !== true) {
throw new TruncatedOutputException('Reply stopped before the model finished.');
}
return validateSummary(decodeModelJson($reply['text']));
} catch (TruncatedOutputException $e) {
throw $e; // Same limit, same cutoff. Fix the limit or split the task.
} catch (ModelOutputException $e) {
$lastError = $e->getMessage();
error_log(sprintf(
'Model JSON attempt %d failed: %s | raw: %s',
$attempt,
$lastError,
substr($reply['text'], 0, 2000)
));
$messages[] = ['role' => 'assistant', 'content' => $reply['text']];
$messages[] = ['role' => 'user', 'content' =>
"Your last reply could not be used. {$lastError} "
. 'Reply again with only the corrected JSON object, no code fence, no commentary.'];
}
}
throw new ModelOutputException("No usable JSON after {$maxAttempts} attempts. Last error: {$lastError}");
}
You can test the whole pipeline without an API key by passing a fake callable:
$fake = fn (array $messages): array => [
'text' => "Sure! Here it is:\n```json\n{\"title\": \"Login loop\", \"tags\": [\"php\"], \"confidence\": 90}\n```",
'complete' => true,
];
var_dump(getSummary($fake, 'Summarize this ticket as JSON.'));
Keep the attempt cap small. Each retry is another billed call that resends the whole conversation, so a loop without a ceiling turns one bad reply into a cost problem. If you want a sense of how those calls add up, see what AI coding tools actually cost on a real project.
Log the Raw Reply, Carefully
The single most useful thing when debugging this error later is the raw text the model actually sent. The retry function above writes the first 2,000 bytes of it with error_log(). On a WordPress site, that call lands in the debug log when logging is enabled, and you can keep it there without showing anything to visitors by following the setup for WP_DEBUG_LOG without displaying errors.
Log the reply, never the request. The request object usually carries an Authorization header or your key in a config array, and a careless print_r($request) in an error path writes it straight into a file that plugins, backups and support staff can read. The API key security checklist for AI coding assistants covers where keys leak and how to keep them out of logs. Also consider whether the reply itself can contain user data before you log it verbatim.
What This Cannot Fix
Be honest with yourself about the limits of cleanup code.
- “Repair” libraries can change your data. Closing an unclosed array makes truncated JSON parse, but the items that never arrived are simply gone, and your code cannot tell. Deleting a trailing comma is harmless; guessing where a missing quote belongs is not. Prefer rejecting and retrying over silently patching.
- Structured output modes reduce syntax errors, not logic errors. Many providers now offer a JSON mode or schema-constrained output. Use it when your provider supports it, because it removes most fences and preamble at the source. It does not guarantee the values are correct, and a model can still refuse the task or be cut off by the token limit. Read your provider’s documentation for exactly what it promises, since these features change between API versions.
- A refusal is not a parse error. If the model says it cannot help, retrying with “return only JSON” may just get you a refusal wrapped in JSON. Detect it and handle it as its own case.
- Validation cannot check truth. A
confidenceof 90 passes the shape check whether or not the model has any reason to be confident.
The pipeline makes failures visible and bounded. It does not make the model reliable, and your code should still assume any single reply can be wrong.
FAQ
Why does json_decode() return null for my model’s reply?
Because the reply is not valid JSON as-is, most often due to a Markdown code fence or a sentence before the opening brace. By default json_decode() returns null instead of throwing. Pass JSON_THROW_ON_ERROR so you get a JsonException with a message such as “Syntax error”.
How do I remove the “`json code fence from an LLM response in PHP?
Trim the reply, then match it against /^```[a-zA-Z]*\s*(.*?)\s*```$/s and keep the captured group. As a fallback for replies with text around the JSON, take everything from the first { to the last } before decoding.
Should I use json_validate() before json_decode()?
Not when you also need the data. The php.net manual advises against calling json_validate() immediately before json_decode() because it parses the string twice. Decode with JSON_THROW_ON_ERROR and catch the exception instead.
What causes an “Unexpected end” or unterminated JSON reply from a model?
Usually the model hit its output token limit and stopped mid-object. Check the stop reason your provider returns before parsing. Raise the output limit, ask for less data per call, or split the task, because retrying with the same limit tends to cut off again.
Is JSON mode enough to prevent LLM JSON output parsing errors?
It removes most formatting problems, but you should still validate the shape of every reply, check the stop reason for truncation, and handle refusals. JSON mode guarantees much less about the content than about the syntax.

