Skip to content
LaravelBlog

laravel/ai: Structured output: repair object keys with unescaped quotes

LaravelBlogBot
LaravelBlogBot

Contributed by augusto-dmh

Part of the ai v1.2.1 release

Upgrading from v1.2.0 to include PR #1111 fixes a silent data-loss bug in structured output decoding. Gemini 3 models, when returning JSON through the native response_json_schema, sometimes leave double quotes unescaped inside object keys while escaping the same characters in values. The payload that comes back is not valid JSON, so decodeStructuredOutput() gave up and returned an empty array — even though the full answer was sitting right there in $response->text. This PR adds a final repair pass to the decoder so such payloads decode into the arrays your schema asked for. There are no API or configuration changes, and payloads that were already valid decode exactly as before.

Why the payload was not valid JSON

When a schema produces object keys that themselves contain a double quote, Gemini escapes that character in string values but not in keys. The report in #1018 shows a Lithuanian spec sheet:

{"Jutiklinė juosta ("Touch Bar")":"Yra","Ekrano įstrižainė":"16\""}

The key Jutiklinė juosta ("Touch Bar") carries raw quotes, while the value "16\"" is correctly escaped. Strictly speaking that payload is not JSON, so json_decode() rejects it. Because the quirk lives in the model's serializer and surfaces through the native schema channel, no prompt tweak or request option on your side can steer around it.

Until now, decodeStructuredOutput() made two attempts — decoding the trimmed text directly, then decoding whatever extractJsonCodeFence() finds — and returned [] when both failed. $response->structured came back empty with no error to catch.

A repair pass in the decoder

The diff adds a third attempt. After the direct and code-fence decodes, if the result still is not an array, the decoder runs the payload — the fence-extracted body if there is one, otherwise the trimmed text — through a new escapeQuotesInObjectKeys() helper before one final json_decode().

The helper walks the string quote by quote. For each stretch between quotes it maintains a stack of open { and [ containers, which tells it whether the following string sits inside an object or an array. A string is treated as a key only when the innermost container is an object and the characters before it end in { or ,. Keys are matched lazily up to a quote followed by a colon — that colon-terminated quote is the real key terminator — and every unescaped " inside is rewritten as \", while existing escape sequences pass through untouched. Value strings use the ordinary rule (the first unescaped quote ends the string) and are copied byte for byte. Because arrays are tracked on the stack, a string inside an array is never mistaken for a key.

What you will see in your application

For the payload from the issue, the change is the difference between an empty result and your actual data:

// v1.2.0:
$response->structured; // []

// with PR #1111:
$response->structured;
// [
//     'Jutiklinė juosta ("Touch Bar")' => 'Yra',
//     'Ekrano įstrižainė' => '16"',
// ]

The repair is character-level rather than a whole-payload regex, so nested structures hold up. The second unit test decodes a fenced payload containing an array of objects — including a key like Écran 16" and a value oui, "mat" — and gets the correct nested array back.

Upgrade impact: transparent unless you were bitten

If none of your payloads hit this quirk, nothing changes. The fallback only runs after both existing decodes have failed, so valid JSON and fenced JSON take exactly the same code path as in v1.2.0. Payloads the repair cannot rescue still return []; the PR deliberately leaves that alone, because turning undecodable output into an explicit error — the second half of #1018 — is a behavior change of its own.

If you were bitten, the upgrade requires no code changes: $response->structured simply starts containing data where it used to be []. Any workaround you built on top of $response->text, such as your own regex extraction, keeps working but can be retired.

Tests pinning the fix

Three new tests lock the behavior down: a unit test using the exact payload from the issue, a unit test covering unescaped key quotes inside arrays and inside a code fence while confirming values are untouched, and a Gemini test that feeds the raw interaction response through an agent. All three fail on 1.x and pass with the fix, so a regression would surface immediately.

Takeaways

  • Gemini 3 can emit object keys with unescaped double quotes, producing invalid JSON and leaving $response->structured empty (issue #1018).
  • The decoder now has a final fallback that escapes unescaped quotes inside object keys, where a key ends at a quote followed by a colon; values are left as-is.
  • Arrays are tracked during the scan so their strings are never read as keys, and existing escape sequences pass through unchanged.
  • The fallback runs only after the direct and code-fence decodes fail — valid payloads decode exactly as before, and unrepairable ones still return [].
  • No upgrade steps are needed; new unit and Gemini agent tests all fail on 1.x and pin the fix.

Sources

More from this release

Related Articles