Forcing LLMs to return valid JSON, typed PHP objects via the Laravel AI SDK, schema validation, fallback strategies, and the exact pattern that makes AI responses as reliable as a database query — the feature that separates production AI from prototype AI.
Every Laravel developer building AI features hits the same wall at the same point. The demo works. The model returns something coherent. You ship it. Then production arrives — different phrasing, different context, a slightly ambiguous document — and the model returns valid English with the data in the wrong place, or wraps the JSON in a markdown code block, or decides that “confidence: high” is more descriptive than a number. Your json_decode() returns null. Your preg_match() finds something that looks like what you wanted. Your application quietly does the wrong thing.
Structured outputs fix this at the protocol level. Not by writing better prompts. Not by adding more retry logic. By forcing the model to return data that conforms to a schema the same way a database query returns rows that match your table definition. This post covers what structured outputs actually do, how the Laravel AI SDK implements them, and the patterns that make AI responses genuinely reliable rather than just usually correct.
Why Free Text Parsing Is a Dead End
Before structured outputs, the standard approach was prompt engineering: “Respond only in JSON. Format your response exactly like this example. Do not include any additional text.”
This works until it doesn’t. The problem isn’t that models are bad at following instructions — they’re quite good at it. The problem is that “quite good” in a stochastic system means occasional failures at a rate that compounds across volume. At 100 requests per day, a 1% failure rate is one broken response. At 100,000 requests per day, it’s 1,000.
The failure modes are varied enough that no single parsing strategy handles all of them:
What you asked for: {"score": 8, "sentiment": "positive"}
What you sometimes get:
→ Wrapped in markdown: ```json\n{"score": 8, "sentiment": "positive"}\n```
→ With commentary: Sure! Here's the JSON: {"score": 8, "sentiment": "positive"}
→ Wrong field names: {"rating": 8, "tone": "positive"}
→ Wrong types: {"score": "8", "sentiment": "positive"}
→ Missing fields: {"score": 8}
→ Valid JSON, wrong schema: {"analysis": {"score": 8, "sentiment": "positive"}}
→ Refused: I can't evaluate sentiment for this content.
Every one of these requires a different recovery path. The code that handles them grows. The confidence in the output shrinks. You start treating AI responses the way you treat user input — always suspect, always requiring defensive validation — which defeats the purpose of having the AI do the work.
Structured outputs replace this with a constraint: the model is forced at the token generation level to produce output that conforms to your schema. It cannot emit a token that would violate it. The result isn’t “the model tried its best to follow the format” — it’s “the format is enforced by the decoding process.”
The Laravel AI SDK Structured Output API
The Laravel AI SDK (laravel/ai, first-party, shipped with Laravel 12) implements structured outputs through the HasStructuredOutput interface. You define a schema on the agent class; the SDK enforces it against whatever provider you’re using.
A minimal example — sentiment analysis that returns typed data instead of free text:
<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Stringable;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
class SentimentAnalyzer implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): Stringable|string
{
return 'Analyze the sentiment of the provided customer review. Be precise.';
}
public function schema(JsonSchema $schema): array
{
return [
'sentiment' => $schema->string()
->enum(['positive', 'negative', 'neutral'])
->required(),
'score' => $schema->integer()
->min(1)
->max(10)
->required(),
'confidence' => $schema->number()
->min(0.0)
->max(1.0)
->required(),
'key_phrases' => $schema->array()
->items($schema->string())
->required(),
];
}
}
Resolution and access:
$response = (new SentimentAnalyzer)->prompt($reviewText);
// Access like an array — types match the schema exactly
$sentiment = $response['sentiment']; // string: 'positive' | 'negative' | 'neutral'
$score = $response['score']; // int: 1–10
$confidence = $response['confidence']; // float: 0.0–1.0
$phrases = $response['key_phrases']; // string[]
$response['score'] is an integer. Not a string containing a number. Not null with a defensive fallback. An integer, guaranteed by the schema. This is the shift that structured outputs actually represent — the response is a typed contract, not a string you parse.
Schema Primitives: What You Can Enforce
The JsonSchema helper exposes the full range of JSON Schema types. Understanding what each enforces tells you what you can guarantee:
public function schema(JsonSchema $schema): array
{
return [
// String with allowed values — enum enforced at generation time
'status' => $schema->string()
->enum(['pending', 'approved', 'rejected'])
->required(),
// Integer with range constraints
'priority' => $schema->integer()
->min(1)
->max(5)
->required(),
// Float/number
'confidence' => $schema->number()
->min(0.0)
->max(1.0)
->required(),
// Boolean
'requires_human_review' => $schema->boolean()->required(),
// Array of typed items
'tags' => $schema->array()
->items($schema->string())
->required(),
// Nullable field — the correct pattern for optional fields
// In strict mode, every field must be present; use nullable for optional data
'rejection_reason' => $schema->string()->nullable()->required(),
];
}
The nullable pattern deserves attention. In strict structured output mode (which is what the Laravel AI SDK uses, and what you want), every property declared in the schema must be present in the response. There are no optional fields — there are only required fields and nullable required fields. If a value doesn’t apply, the model returns null. Your PHP code handles null explicitly rather than handling missing keys defensively.
// ❌ Defensive null-check cascade — symptom of untyped AI responses
if (isset($response['rejection_reason']) && $response['rejection_reason'] !== null) {
$this->notify($response['rejection_reason']);
}
// ✅ Explicit nullable — the schema guarantees the key exists
if ($response['rejection_reason'] !== null) {
$this->notify($response['rejection_reason']);
}
Small difference in the code. Significant difference in confidence.
Nested Objects and Arrays of Objects
Real extraction tasks rarely have flat schemas. The object() method with a closure handles nested structure:
class InvoiceExtractor implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): Stringable|string
{
return 'Extract all invoice data from the provided document. Be thorough and precise.';
}
public function schema(JsonSchema $schema): array
{
return [
'invoice_number' => $schema->string()->required(),
'vendor' => $schema->object(fn ($schema) => [
'name' => $schema->string()->required(),
'email' => $schema->string()->nullable()->required(),
'address' => $schema->string()->nullable()->required(),
])->required(),
'line_items' => $schema->array()
->items(
$schema->object(fn ($schema) => [
'description' => $schema->string()->required(),
'quantity' => $schema->integer()->required(),
'unit_price' => $schema->number()->required(),
'total' => $schema->number()->required(),
])
)
->required(),
'totals' => $schema->object(fn ($schema) => [
'subtotal' => $schema->number()->required(),
'tax' => $schema->number()->required(),
'total' => $schema->number()->required(),
])->required(),
'due_date' => $schema->string()->nullable()->required(),
'currency' => $schema->string()->enum(['USD', 'EUR', 'GBP', 'INR'])->required(),
];
}
}
Usage with an uploaded PDF attachment:
use Laravel\Ai\Files;
$response = (new InvoiceExtractor)->prompt(
'Extract all data from this invoice.',
attachments: [Files\Document::fromPath($pdfPath)],
);
// Typed access through the entire structure
$vendor = $response['vendor']['name']; // string, guaranteed
$lineItems = $response['line_items']; // array of objects, guaranteed
$total = $response['totals']['total']; // float, guaranteed
$currency = $response['currency']; // 'USD' | 'EUR' | 'GBP' | 'INR', guaranteed
Before structured outputs, extracting invoice data meant prompting for JSON, parsing the response, validating every field, handling the cases where the model decided “line_items” should be “items” or “lines.” Now the schema enforces the field names. The model cannot call it anything else.
Mapping to PHP DTOs
The StructuredAgentResponse array interface is clean for access, but for anything that flows deeper into your application, mapping to a typed PHP DTO removes the array indexing entirely and gives you IDE autocompletion and static analysis support.
The DTO:
<?php
namespace App\DataTransferObjects;
readonly class SentimentResult
{
public function __construct(
public string $sentiment, // 'positive' | 'negative' | 'neutral'
public int $score, // 1–10
public float $confidence, // 0.0–1.0
public array $keyPhrases,
) {}
public static function fromResponse(array $response): static
{
return new static(
sentiment: $response['sentiment'],
score: $response['score'],
confidence: $response['confidence'],
keyPhrases: $response['key_phrases'],
);
}
public function isPositive(): bool
{
return $this->sentiment === 'positive';
}
public function isHighConfidence(): bool
{
return $this->confidence >= 0.8;
}
}
The service layer wraps the agent and returns the DTO:
<?php
namespace App\Services;
use App\Ai\Agents\SentimentAnalyzer;
use App\DataTransferObjects\SentimentResult;
class SentimentService
{
public function analyze(string $text): SentimentResult
{
$response = (new SentimentAnalyzer)->prompt($text);
return SentimentResult::fromResponse($response->toArray());
}
}
Downstream code receives a typed object:
class ReviewController extends Controller
{
public function store(ReviewRequest $request, SentimentService $sentiment): JsonResponse
{
$review = Review::create($request->validated());
$result = $sentiment->analyze($review->body);
$review->update([
'sentiment' => $result->sentiment,
'score' => $result->score,
'confidence' => $result->confidence,
]);
if (! $result->isHighConfidence()) {
FlagForHumanReview::dispatch($review);
}
return response()->json($review);
}
}
No $response['sentiment'] ?? 'unknown'. No (int) $response['score'] type coercion. No null checks six levels deep. The schema guarantees the data shape; the DTO guarantees the type; the controller receives an object it can trust.
The Two Failure Modes That Structured Outputs Don’t Fix
Structured outputs remove structural failure — wrong field names, wrong types, missing fields, markdown wrapping. They do not fix semantic failure, and it’s important to understand the distinction.
Structural failure (fixed by structured outputs):
→ {"rating": 8} instead of {"score": 8} ← wrong field name
→ {"score": "8"} instead of {"score": 8} ← wrong type
→ {"score": 8} instead of {"score": 8, ...} ← missing fields
→ ```json {"score": 8} ``` instead of {"score": 8} ← formatting noise
Semantic failure (not fixed by structured outputs):
→ {"score": 9, "sentiment": "positive"} for clearly negative text ← wrong judgment
→ {"confidence": 0.95} when the model is guessing ← false confidence
→ {"tags": ["invoice", "payment"]} for a purchase order ← wrong classification
The schema enforces shape. It cannot enforce correctness. A model can return {"sentiment": "positive", "score": 9} — perfectly valid against the schema — for a review that’s clearly negative.
This is where application-level validation lives, not schema validation:
class SentimentService
{
public function analyze(string $text): SentimentResult
{
$response = (new SentimentAnalyzer)->prompt($text);
$result = SentimentResult::fromResponse($response->toArray());
// Schema guarantees structure — application logic validates semantics
if ($result->confidence < 0.5) {
// Low confidence: flag for human review rather than acting
return $result->withHumanReviewRequired();
}
if ($result->score > 8 && $result->sentiment !== 'positive') {
// Contradiction between score and sentiment label
// Log it, handle it, don't silently accept it
Log::warning('Sentiment contradiction detected', [
'score' => $result->score,
'sentiment' => $result->sentiment,
'text' => Str::limit($text, 200),
]);
}
return $result;
}
}
The schema handles structure. Your application handles semantics. Both layers earn their place.
Fallback Strategies When the Model Refuses
Structured outputs with schema constraints can still fail — not because the model returns wrong-shaped data (that’s prevented), but because the model refuses to answer at all. Refusals happen when the content triggers safety filters, the document is too ambiguous to classify, or the model genuinely can’t satisfy the schema with the provided input.
The SDK surfaces refusals as exceptions. The correct pattern:
class DocumentClassifier
{
public function classify(string $content): ClassificationResult
{
try {
$response = (new DocumentClassificationAgent)->prompt($content);
return ClassificationResult::fromResponse($response->toArray());
} catch (\Laravel\Ai\Exceptions\ModelRefusalException $e) {
// Model explicitly refused — content may be ambiguous or out of scope
Log::info('Model refused classification', [
'reason' => $e->getMessage(),
'content' => Str::limit($content, 500),
]);
return ClassificationResult::unclassified(reason: 'model_refusal');
} catch (\Laravel\Ai\Exceptions\StructuredOutputException $e) {
// Schema couldn't be satisfied — shouldn't happen with strict mode, but handle anyway
Log::error('Structured output failed', ['error' => $e->getMessage()]);
return ClassificationResult::unclassified(reason: 'schema_error');
} catch (\Exception $e) {
// Network failure, timeout, provider error
Log::error('AI classification failed', ['error' => $e->getMessage()]);
return ClassificationResult::unclassified(reason: 'provider_error');
}
}
}
The unclassified() pattern is the key: instead of returning null or throwing, you return a valid result object with a known state that downstream code handles explicitly. Null propagation is replaced with explicit failure modes.
For high-volume pipelines, combine this with Laravel’s retry mechanism:
public function classify(string $content): ClassificationResult
{
return retry(
times: 3,
callback: function () use ($content) {
$response = (new DocumentClassificationAgent)->prompt($content);
return ClassificationResult::fromResponse($response->toArray());
},
sleepMilliseconds: 1000,
when: fn ($e) => $e instanceof \Laravel\Ai\Exceptions\StructuredOutputException,
// Only retry on schema errors, not refusals or application logic errors
);
}
Using anyOf for Polymorphic Responses
Sometimes the right response shape depends on what the model finds in the input. anyOf handles this without falling back to free text:
class ContentRouter implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): Stringable|string
{
return 'Analyze the submitted content and classify it as either an article or a product listing. Extract the appropriate fields for each type.';
}
public function schema(JsonSchema $schema): array
{
return [
'content' => $schema->anyOf([
$schema->object(fn ($schema) => [
'type' => $schema->string()->enum(['article'])->required(),
'title' => $schema->string()->required(),
'author' => $schema->string()->nullable()->required(),
'word_count' => $schema->integer()->required(),
'topics' => $schema->array()->items($schema->string())->required(),
]),
$schema->object(fn ($schema) => [
'type' => $schema->string()->enum(['product'])->required(),
'name' => $schema->string()->required(),
'price' => $schema->number()->nullable()->required(),
'sku' => $schema->string()->nullable()->required(),
'category' => $schema->string()->required(),
]),
])->required(),
];
}
}
Usage — the discriminator field type drives downstream routing:
$response = (new ContentRouter)->prompt($submittedContent);
$content = $response['content'];
match ($content['type']) {
'article' => ArticleImporter::dispatch($content),
'product' => ProductImporter::dispatch($content),
};
The model decides which shape applies. The anyOf constraint ensures it returns one of the valid shapes. Your application routes on the type discriminator. No string parsing, no shape detection logic, no defensive checks for missing fields.
Anonymous Agents for One-Off Extractions
Not every structured output task needs a full agent class. The SDK provides a functional syntax for quick extractions:
use Illuminate\Contracts\JsonSchema\JsonSchema;
use function Laravel\Ai\{agent};
// Extract key data from a support ticket without a dedicated agent class
$response = agent(
instructions: 'Extract the key information from this support ticket.',
schema: fn (JsonSchema $schema) => [
'urgency' => $schema->string()->enum(['low', 'medium', 'high', 'critical'])->required(),
'category' => $schema->string()->enum(['billing', 'technical', 'account', 'other'])->required(),
'sentiment' => $schema->string()->enum(['frustrated', 'neutral', 'satisfied'])->required(),
'summary' => $schema->string()->required(),
],
)->prompt($ticketBody);
$urgency = $response['urgency'];
$category = $response['category'];
Full agent classes are right for repeated use, testability, and complex logic. Anonymous agents are right for one-off extractions in service methods or jobs where a class would be overhead.
Structured Outputs in Queued Jobs
AI extraction at scale belongs in queued jobs, not request handlers. The structured output API composes cleanly with Laravel’s queue system:
<?php
namespace App\Jobs;
use App\Ai\Agents\InvoiceExtractor;
use App\DataTransferObjects\InvoiceData;
use App\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Laravel\Ai\Files;
class ProcessInvoiceDocument implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public int $tries = 3;
public int $timeout = 120;
public function __construct(
private readonly Invoice $invoice,
private readonly string $documentPath,
) {}
public function handle(): void
{
$response = (new InvoiceExtractor)->prompt(
'Extract all invoice data from this document.',
attachments: [Files\Document::fromPath($this->documentPath)],
);
$data = InvoiceData::fromResponse($response->toArray());
$this->invoice->update([
'vendor_name' => $data->vendor->name,
'invoice_number' => $data->invoiceNumber,
'total_amount' => $data->totals->total,
'currency' => $data->currency,
'due_date' => $data->dueDate,
'line_items' => $data->lineItems,
'status' => 'extracted',
]);
}
public function failed(\Throwable $exception): void
{
$this->invoice->update(['status' => 'extraction_failed']);
Log::error('Invoice extraction failed', [
'invoice_id' => $this->invoice->id,
'error' => $exception->getMessage(),
]);
}
}
The job is clean because the response is clean. No parsing in the job body. No defensive null-checks before every update() field. The schema guaranteed the shape; the DTO guaranteed the types; the job just maps data to the model.
The Pattern That Makes AI Responses as Reliable as Database Queries
Putting it together — the full production pattern:
1. Agent class → defines schema, implements HasStructuredOutput
2. Schema → enforces field names, types, enums, ranges at generation time
3. DTO → maps response array to typed PHP object, adds domain methods
4. Service layer → wraps agent, handles exceptions, adds semantic validation
5. Job or handler → uses the service, handles the DTO, never touches raw AI output
// 1. Agent
class LeadQualifier implements Agent, HasStructuredOutput { /* ... schema ... */ }
// 2. DTO
readonly class LeadQualification
{
public static function fromResponse(array $r): static { /* ... */ }
public function isHighValue(): bool { return $this->score >= 8; }
public function requiresImmediateFollowUp(): bool { /* ... */ }
}
// 3. Service — the only layer that knows about the AI
class LeadQualificationService
{
public function qualify(Lead $lead): LeadQualification
{
try {
$response = (new LeadQualifier)->prompt($lead->toQualificationContext());
return LeadQualification::fromResponse($response->toArray());
} catch (ModelRefusalException) {
return LeadQualification::unqualifiable();
}
}
}
// 4. Job — never touches raw AI output
class QualifyLead implements ShouldQueue
{
public function handle(LeadQualificationService $service): void
{
$qualification = $service->qualify($this->lead);
$this->lead->update([
'score' => $qualification->score,
'tier' => $qualification->tier,
'qualified' => $qualification->isHighValue(),
]);
if ($qualification->requiresImmediateFollowUp()) {
NotifySalesTeam::dispatch($this->lead, $qualification);
}
}
}
The AI layer is contained. The schema is the contract between the AI and your application. Everything downstream of the service layer interacts with PHP objects, not strings.
What Changes When You Stop Parsing Free Text
The immediate change is reliability — the structural failure modes disappear. But the second-order change matters more: your code stops being defensive about AI responses in the same way it’s not defensive about database responses.
You don’t write $user->name ?? 'Unknown' every time you access a database column, because you know the column exists and has the type you declared. With structured outputs and a DTO layer, you write AI-consuming code the same way. $result->sentiment is a string. $result->score is an integer. $result->lineItems is an array of typed objects.
That change — from “AI responses are strings I parse” to “AI responses are typed data I consume” — is what separates production AI features from prototype AI features. The prototype works when the model cooperates. The production feature works because it enforces cooperation.
