← Writing

Reliable Structured Output from LLMs in Laravel (and the One Line That Saves You)

Structured output is the unglamorous workhorse of production AI features. Nobody demos it. But the moment you want an LLM's answer to reach a database column instead of a chat bubble, it's the whole ballgame — and "just ask it to return JSON and json_decode" falls apart the first week, in ways that are annoying to debug because they're intermittent.

This is how I run typed output in a Laravel app with the Laravel AI SDK, the schema shapes that actually hold up, and the one defensive line that has saved me more than any other.

The problem with "just return JSON"

Prompting for JSON and parsing the reply fails in a specific, tiresome sequence. The model wraps the JSON in a ```json fence. Then it stops doing that but adds a sentence of preamble. Then it returns valid JSON with "priority": "very high" when your column is an enum of four values. Then, once a month, it returns something that isn't JSON at all and your queued job throws.

Every one of those is a schema problem being solved with string manipulation. Structured output moves the constraint to where it belongs: you declare the shape, the provider enforces it.

Declaring a schema

In the Laravel AI SDK, an agent that returns typed output implements HasStructuredOutput and defines schema(). The SDK injects a JsonSchema builder, so the shape is PHP rather than a hand-written JSON Schema blob.

This is the real agent that triages an ingested customer conversation and extracts the explicit asks:

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\{MaxTokens, Model, Provider, Timeout};
use Laravel\Ai\Contracts\{Agent, HasStructuredOutput};

#[Provider('anthropic')]
#[Model('claude-sonnet-4-6')]
#[MaxTokens(1536)]
#[Timeout(90)]
class ConversationTriageAgent implements Agent, HasStructuredOutput
{
    public function schema(JsonSchema $schema): array
    {
        return [
            'no_action' => $schema->boolean()
                ->description('True when the conversation has no concrete ask for Eduardo.'),
            'reason' => $schema->string()
                ->description('One-line reason, required when no_action is true.'),
            'action_items' => $schema->array()
                ->items($schema->object([
                    'text' => $schema->string()->description('The ask as a clear, self-contained instruction.'),
                    'owner' => $schema->string()->description('Who must act (e.g. Eduardo, Emerson).'),
                    'priority' => $schema->string()->description('critical | high | medium | low.'),
                    'effort' => $schema->integer()->description('Rough hours: 1 | 2 | 4 | 8.'),
                ]))
                ->description('Extracted asks. Empty when no_action is true.'),
        ];
    }
}

Three things in there are load-bearing:

The descriptions are prompt text. ->description() is not a docblock — it ships to the model. 'critical | high | medium | low.' is doing real work; without it you get "very high" and "P1" and "urgent" in the same week. Write these for the model, not for the next developer.

Booleans beat sentinel strings. no_action as a real boolean means the calling code branches on (bool), not on whether the model wrote "none" or "N/A" or an empty string.

Nested objects are worth declaring properly. ->array()->items($schema->object([...])) gives you a typed list of typed records. The lazy version — a flat array of strings you split later — pushes parsing back into your PHP and reintroduces exactly the fragility you were escaping.

Calling it

The call site is ordinary SDK usage, and the result supports array access:

$result = ConversationTriageAgent::make(
    projectId: $conversation->project_id,
    conversationId: $conversation->id,
)->prompt(json_encode($payload));

$noAction = (bool) ($result['no_action'] ?? false);
$items = $noAction ? [] : ($result['action_items'] ?? []);

Note the ?? [] defaults. Structured output makes a shape likely, not guaranteed for all eternity across provider versions — the null-coalesce costs nothing and turns a fatal into a no-op.

The one line that saves you

Here is the failure that cost me real debugging time, and the reason this article exists.

A nested field can come back double-encoded. The outer response parses correctly, no_action is a proper boolean — and action_items is a JSON string containing the array, rather than the array itself. It doesn't happen every call. In my experience it shows up more when routing through an LLM gateway or proxy than when hitting the provider directly, which is exactly the setup that's hardest to reproduce locally.

If you feed that straight into a writer method expecting an array, you get either a fatal or — worse — a silent write of garbage.

The guard:

// Structured output via the gateway sometimes returns the nested
// action_items array as a JSON string instead of an array — normalize so
// the writer always receives an array of item objects.
if (is_string($items)) {
    $decoded = json_decode($items, true);
    $items = is_array($decoded) ? $decoded : [];
}
$items = array_values(array_filter((array) $items, 'is_array'));

Two moves worth copying. First, json_decode with an is_array check on the result — a failed decode returns null, and null sailing into your loop is the same bug one step later. Second, array_filter(..., 'is_array') throws away any element that isn't a record, so a single malformed item degrades that one item instead of the whole batch.

That is the general principle: normalize at the boundary, degrade per-item, never let unvalidated model output reach the writer.

Propose-first, which makes the eval set free

The triage agent doesn't write action items directly. Its output lands as proposed goals that a human approves or edits.

That was a product decision, but it pays a compounding engineering dividend: every proposal a human accepts or corrects is a labelled example. The edit rate is the accuracy metric, gathered from real traffic at no extra cost. You don't get to auto-write until that number earns it.

If you're shipping extraction into anything consequential, do this. "The model is usually right" is not a number, and you cannot tune what you haven't measured.

Metering, because typed output still costs money

Structured output is a normal completion — you pay prompt and completion tokens. The SDK exposes usage on the result, so attribution is a two-liner at the call site:

app(StripeTokenMeter::class)->report(
    $conversation->trackedCustomer?->stripe_customer_id,
    $result->usage->promptTokens,
    $result->usage->completionTokens,
    $model,
);

Meter before any guard that might discard the result — the tokens were spent whether or not you liked the answer. The longer version of this, including per-customer billing, is in metering and billing AI tokens in Laravel.

What I'd tell you to do on Monday

  1. Any place you're prompting for JSON and calling json_decode — move it to a declared schema. It's a smaller change than it looks.
  2. Put the allowed values in the field description. Most "the model is wrong" complaints are underspecified fields.
  3. Add the is_string guard on every nested array field before you hit it in production, not after.
  4. Land the output as proposed until an edit rate tells you it's safe to auto-write.

None of this is hard. It's just the difference between a feature that demos and a feature you can leave running.

If you're building AI features into a Laravel app and want the production shape rather than the tutorial shape — that's what I do, $60/hr with a weekly cap, no retainer and no contract. If you'd rather have one feature shipped fixed-price, there's an AI Agent Sprint ($4k, two weeks).

Work with me See pricing