Laravel AI SDK Structured Output — JSON Schema Enforcement for LLM Responses

Make every LLM response match a typed JSON schema with the Laravel AI SDK's HasStructuredOutput interface — across OpenAI, Anthropic and Gemini providers.

Steven Richardson
Steven Richardson
· 9 min read

The model returned ```json\n{...}\n``` with a chatty preamble. Your json_decode crashed. Again. Free-text LLM parsing breaks one request in twenty, and every fix is a regex tax. The Laravel AI SDK has shipped a first-class structured output API since the Laravel 13 launch — implement one interface, describe the shape, and the provider returns already-validated JSON. Here's the pattern I now reach for every time an agent has to feed structured data back into the application.

Install the Laravel AI SDK#

Pull in laravel/ai, publish its config and migrations, and run the database migrations so the conversation tables exist. Even if you aren't using conversation memory today, you'll want the tables in place before you start writing tests against fake agents, because the SDK's Promptable trait expects them on agents that opt into Conversational. The full surface of the package is walked through in my complete guide to the Laravel AI SDK; this article zooms in on the structured-output path specifically.

composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

Set at least one provider key in .env (OPENAI_API_KEY, ANTHROPIC_API_KEY, or GEMINI_API_KEY). The SDK's provider matrix means the same agent class works against any text-capable provider you have a key for, and you can switch per-prompt.

Generate a structured agent#

The SDK has a dedicated make:agent command with a --structured flag. The flag stubs the HasStructuredOutput interface, imports Illuminate\Contracts\JsonSchema\JsonSchema, and adds an empty schema() method so you don't have to wire any of that by hand. Run it once per agent class — agents are first-class objects, just like form requests or jobs.

php artisan make:agent InvoiceExtractor --structured

The result lands at app/Ai/Agents/InvoiceExtractor.php. Pick a name that reads as a noun — the agent is an invoice extractor — because you'll be calling (new InvoiceExtractor)->prompt(...) from controllers and jobs, and the verb tense reads better.

Define the response schema#

This is the meat of the work. Inside the generated agent, replace the empty schema() body with the actual shape you want the model to return. The fluent JsonSchema builder handles primitives, enums, nested objects, arrays of objects, and required-field declarations — it's the same builder Laravel uses internally when describing tool input schemas, so anything you can model as JSON Schema you can express here. Give the agent its instructions on the instructions() method; the model treats them as the system prompt.

<?php

namespace App\Ai\Agents;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;

class InvoiceExtractor implements Agent, HasStructuredOutput
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return 'Extract invoice metadata and line items from the supplied text. '
            .'If a field is not present, return null — never invent values.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'invoice_number' => $schema->string()->required(),
            'issued_at'      => $schema->string()->format('date')->required(),
            'currency'       => $schema->string()->enum(['GBP', 'USD', 'EUR'])->required(),
            'line_items'     => $schema->array()
                ->items(
                    $schema->object(fn ($schema) => [
                        'description' => $schema->string()->required(),
                        'quantity'    => $schema->integer()->min(1)->required(),
                        'unit_price'  => $schema->integer()->required(), // pence/cents
                    ])
                )
                ->required(),
        ];
    }
}

A few things to flag. Use integer minor units (pence, cents) for money — JSON Schema doesn't have a fixed-precision decimal type and floats round badly. Constrain enums where the domain is closed (currency codes, statuses, priorities) because the model is less likely to hallucinate an out-of-range value when the enum is in the schema, and the provider rejects it server-side if it does. Mark everything you actually need as required() — partial responses are a downstream problem you don't want to debug.

Prompt the agent and read typed fields#

Instantiate the agent, call prompt() with the source text, and access the response like an array. The SDK returns a StructuredAgentResponse that implements ArrayAccess, so the keys you defined in schema() are the keys you read. Nested objects and arrays come back as the same shape — $response['line_items'] is an iterable of associative arrays matching the nested object you described.

use App\Ai\Agents\InvoiceExtractor;

$response = (new InvoiceExtractor)->prompt($pdfText);

$invoiceNumber = $response['invoice_number'];
$currency      = $response['currency'];

foreach ($response['line_items'] as $item) {
    $description = $item['description'];
    $totalPence  = $item['quantity'] * $item['unit_price'];
}

If you prefer typed objects, map the array into a constructor-promoted DTO at the boundary — new InvoiceLineItem(...$item) works once you've named the DTO properties to match the schema keys. The SDK doesn't generate the schema from a DTO via reflection (the original brief I worked from assumed it did, but the public API is the schema() method on the agent), but you can keep your domain layer in DTOs by hydrating them yourself in the controller or job that owns the prompt.

Handle malformed responses with a guarded retry#

Schema enforcement happens at the provider — OpenAI's strict structured output, Anthropic's tool-use JSON schema, Gemini's grounded mode — so a successful response is already valid against the schema. The places it can still go wrong are the network (timeout, connection reset) and the provider itself (rate limit, overload, content-policy refusal). The SDK throws typed exceptions you can catch and route accordingly, and a single retry usually clears transient failures.

use App\Ai\Agents\InvoiceExtractor;
use Laravel\Ai\Exceptions\RateLimitedException;
use Laravel\Ai\Exceptions\ProviderOverloadedException;

try {
    $response = (new InvoiceExtractor)->prompt($pdfText);
} catch (RateLimitedException|ProviderOverloadedException $e) {
    // Push to the queue and let exponential backoff handle it.
    ExtractInvoice::dispatch($pdfText)->delay(now()->addSeconds(30));

    return;
}

For batched extractions, drive the whole pipeline from a queued job and lean on Laravel's built-in retries with backoff. If you're already running a reliable agent loop with a verifier and bounded retries, my walkthrough of production AI agents with reliable loops covers the architecture end-to-end — structured output slots into the verifier step cleanly because the LLM can no longer return garbage.

Add a failover provider that respects the schema#

Production agents should never single-thread on one provider. Pass an array to prompt(provider: ...) and the SDK fails over automatically when a FailoverableException is raised — rate limits, overloads, insufficient credits. Ordinary errors like a validation failure don't trigger failover. Critically, the schema enforcement is preserved on every attempt: the SDK translates one schema() into each provider's native structured-output mode before each call.

use App\Ai\Agents\InvoiceExtractor;
use Laravel\Ai\Enums\Lab;

$response = (new InvoiceExtractor)->prompt(
    $pdfText,
    provider: [Lab::OpenAI, Lab::Anthropic, Lab::Gemini],
);

If you need a specific model per provider, pass an associative array keyed by Lab::*->value instead of bare enum cases — PHP can't use enum instances as array keys, so the SDK accepts the underlying string value:

$response = (new InvoiceExtractor)->prompt(
    $pdfText,
    provider: [
        Lab::OpenAI->value     => 'gpt-4.1-mini',
        Lab::Anthropic->value  => 'claude-haiku-4-5-20251001',
    ],
);

Wire this into a job for batch extraction and the same job survives a provider outage without the calling code knowing anything about it. For the wider picture on tracing what each provider actually returned (helpful when a fallback fires and you want to know why), I pair the failover chain with Langfuse observability for Laravel AI calls.

Test structured output with Agent::fake()#

The SDK ships a first-party fake that reads your schema() method and generates conforming fake data automatically — so a Pest test never has to hit a real provider, never burns tokens, and still exercises the contract the rest of your app depends on. Call fake() in the test setup, prompt the agent like you would in production, and assert on the typed fields.

use App\Ai\Agents\InvoiceExtractor;

it('extracts the invoice number from the source text', function () {
    InvoiceExtractor::fake();

    $response = (new InvoiceExtractor)->prompt('Invoice INV-001 for £42.00 ...');

    expect($response['invoice_number'])->toBeString();
    expect($response['line_items'])->toBeArray();

    InvoiceExtractor::assertPrompted(fn ($prompt) => str_contains($prompt->prompt, 'INV-001'));
});

For tests where you need a deterministic response (e.g. a downstream invoice-total assertion), pass a closure to fake() and return the exact array shape. Add InvoiceExtractor::fake()->preventStrayPrompts() in your beforeEach() if you want the suite to scream when an agent is invoked without a registered fake — that one line turns silent production regressions into loud test failures.

Gotchas and Edge Cases#

A few things bite in production. First, enum mismatches don't throw, they coerce — if your schema declares currency as enum ['GBP','USD','EUR'] and the model returns 'gbp', OpenAI normalises lowercase to the declared case but Anthropic returns the literal lowercase string. Normalise at the boundary (strtoupper($response['currency'])) before persisting. Second, deeply nested schemas with arrays of objects of arrays of objects can exceed the 100-property limit OpenAI's strict mode enforces; if you hit the limit, denormalise into a flat top-level shape and reconstruct the tree in PHP. Third, the make:agent --structured command imports Illuminate\Contracts\JsonSchema\JsonSchema from the framework's contracts namespace, not the SDK's own — don't fix the import on autopilot, that's the correct namespace. Fourth, the model treats the schema as a hint about shape but treats instructions() as a hint about content. Vague instructions still produce schema-valid garbage. Be specific about what should be null, what is forbidden, and what units to use.

Wrapping Up#

Structured output is the single biggest reliability win the Laravel AI SDK gives you over hand-rolling provider HTTP calls. Implement HasStructuredOutput, describe the shape once with the JsonSchema builder, and the same agent works across providers, fails over cleanly, and tests without tokens. Ship one agent against your tightest schema first — invoice line items, support-ticket triage, lead enrichment — then expand the pattern. If you want to feed the extracted data straight into vector search for retrieval-augmented answers, building a RAG pipeline with the Laravel AI SDK and pgvector is the natural next step.

FAQ#

What is structured output in the Laravel AI SDK?

Structured output is the SDK's contract for forcing an LLM response to match a JSON schema you define in PHP. You implement the Laravel\Ai\Contracts\HasStructuredOutput interface on an agent, define a schema() method that returns the shape using the fluent JsonSchema builder, and the SDK translates that into each provider's native structured-output mode — OpenAI strict mode, Anthropic tool-use JSON schema, Gemini grounded mode. The response comes back as a StructuredAgentResponse you read like an array.

How do I define a JSON schema for an LLM response in Laravel?

Add a schema(JsonSchema $schema): array method to an agent that implements HasStructuredOutput. Return an associative array where keys are the field names and values are fluent JsonSchema calls, for example 'score' => $schema->integer()->min(1)->max(10)->required(). Nest objects with $schema->object(fn ($schema) => [...]) and lists with $schema->array()->items(...). Use ->required() on every field you can't tolerate being null on the response.

Does structured output work with Anthropic Claude as well as OpenAI?

Yes. The Laravel AI SDK abstracts the provider-specific implementation, so the same schema() definition produces a strict response from OpenAI, Anthropic, and Gemini. Each provider implements schema enforcement differently under the hood — Anthropic uses tool-use with a JSON schema, OpenAI uses its strict structured outputs feature, Gemini uses grounded mode — but the agent class you write is identical. You can also pass an array of providers to prompt(provider: ...) and the SDK fails over while keeping the schema enforced.

What happens if the model returns invalid JSON?

In practice the provider blocks invalid JSON before it reaches you — schema enforcement runs server-side inside the provider's structured-output pipeline, so the response is already valid against the schema by the time the SDK returns it. The failure modes you actually see are transport errors and provider exceptions: RateLimitedException, ProviderOverloadedException, InsufficientCreditsException, plain timeouts. Catch the failover-eligible exceptions and either retry through the SDK's provider: [] array failover or push the work onto the queue with a backoff.

Can I use a Laravel DTO class as the schema?

Not directly — the SDK doesn't reflect on a DTO class to generate the schema. The public API is the schema() method on the agent, which uses the fluent JsonSchema builder. If you want typed DTOs in your domain layer, define the schema on the agent and hydrate a DTO from the response at the boundary, for example new InvoiceLineItem(...$response['line_items'][0]). That keeps the contract in one place and gives you immutable typed objects everywhere downstream.

How do I test LLM structured output in Pest?

Call YourAgent::fake() in the test, then prompt the agent normally and assert on the response. When fake() is invoked on an agent that implements HasStructuredOutput, the SDK automatically generates fake data that matches the declared schema, so you don't have to stub a response by hand. Pass a closure to fake(function ($prompt) { return [...]; }) when you need a deterministic response, and use YourAgent::fake()->preventStrayPrompts() to force the test suite to fail loudly if a prompt is issued without a registered fake.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.