If you've been using OpenAI's JSON mode and calling it "structured output," you're building on a guarantee that's weaker than it looks. JSON mode promises syntactically valid JSON. It does not promise that status will be a string, that confidence will be a float, or that items won't quietly disappear from the response entirely. In production, that distinction costs you. We've watched pipelines collapse at 3am because a field the downstream queue worker expected simply wasn't there, and JSON mode had no complaint.
Using OpenAI structured outputs in Laravel, specifically response_format with type: json_schema and strict: true, closes that gap. Schema compliance is enforced at the generation level using constrained decoding. The model literally cannot emit a token that would violate your schema. This article covers how to wire that up correctly in Laravel, what the real failure modes are (they're not what you'd expect), and how structured outputs slot into an agentic pipeline where downstream steps depend on typed, predictable payloads.
This article focuses specifically on OpenAI's implementation. Gemini exposes the same concept via generationConfig.responseSchema, and Claude approaches it through tool definitions. The JSON Schema structure is largely portable across all three. What differs is the implementation layer: how each provider receives the schema, the refusal mechanism, strict mode constraints, and the error surface. Those differences warrant their own focused articles. If you want the cross-provider picture first, the Laravel AI integration architecture guide covers how they compare at the architectural level.
Why JSON Mode Is No Longer Good Enough
The table below is worth keeping. The differences are subtle on paper and catastrophic under load.
| Feature |
json_object mode |
json_schema strict mode |
|---|---|---|
| Guarantees valid JSON | ✅ | ✅ |
| Guarantees all required keys present | ❌ | ✅ |
| Guarantees correct types | ❌ | ✅ |
| Enforces enum values | ❌ | ✅ |
Enforces additionalProperties: false
|
❌ | ✅ |
Exposes refusal field |
❌ | ✅ |
| Compliant with JSON Schema spec subset | ❌ | ✅ |
JSON mode guarantees syntactic correctness but does not guarantee schema adherence, even fields you mark as "required" in your prompt can go missing from the response. OpenAI's own documentation now treats json_object mode as legacy. Strict mode with json_schema is the production default for data extraction and agentic workflows.
If your Laravel application is making decisions (routing jobs, updating records, triggering downstream agents) based on AI-generated JSON, you need the stronger guarantee.
How OpenAI Structured Outputs Work Under the Hood
Understanding the mechanism matters because it explains both the capability and the constraints.
OpenAI uses a Context-Free Grammar engine to mask invalid tokens before generation, the model literally cannot produce a non-conforming response. This is not prompt-level enforcement. No amount of instruction-following or fine-tuning does what constrained decoding does. The schema is compiled into the generation process itself.
Safety policies still apply. The model will abide by its existing safety rules and may refuse an unsafe request. When it does, it returns a refusal string value on the response, allowing you to programmatically detect that the model generated a refusal instead of schema-conforming output.
Two things to internalise before writing a line of code:
- If the model can answer, it will conform to your schema. That's the guarantee.
- If the model won't answer (safety refusal), you get a
refusalfield instead ofcontent. You must handle both branches explicitly.
Defining Your JSON Schema in Laravel
Schema definitions scattered across service classes are a maintenance problem. Keep them in a dedicated config file: the Laravel-native home for static, environment-aware definitions that require no class instantiation to access.
<?php
// config/ai-schemas.php
return [
'review_analysis' => [
'name' => 'review_analysis',
'strict' => true,
'schema' => [
'type' => 'object',
'additionalProperties' => false,
'required' => ['sentiment', 'confidence', 'summary', 'flags', 'escalate'],
'properties' => [
'sentiment' => [
'type' => 'string',
'enum' => ['positive', 'negative', 'neutral', 'mixed'],
],
'confidence' => [
'type' => 'number',
],
'summary' => [
'type' => 'string',
],
'flags' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
'escalate' => [
'type' => ['boolean', 'null'],
],
],
],
],
];
Pull it into your service with a single config call:
'response_format' => [
'type' => 'json_schema',
'json_schema' => config('ai-schemas.review_analysis'),
],
Config is publishable, overridable per environment, and version-controlled alongside your codebase. When your schema changes, you change one file. No class to instantiate, no namespace to import.
Strict Mode Constraints You Must Know
OpenAI's strict mode is enforced at the generation level using constrained decoding, not at the prompt level. That distinction matters because it means the constraints are hard, not advisory. The API will reject your request outright if your schema violates them, so know the rules before you deploy.
additionalProperties must be set to false for every object in the schema, and all fields in properties must appear in required. If your schema violates either rule, the API rejects the request immediately. You won't get a gracefully degraded response, you'll get an HTTP error.
The following JSON Schema keywords are not supported and will cause the API to reject your request:
-
defaultvalues -
minLength/maxLengthon strings -
minimum/maximumon numbers -
patternfor regex constraints -
if/then/elseconditional schemas
Work within these constraints, not around them. Post-API validation in Laravel (covered below) handles the domain rules that OpenAI won't enforce.
[Edge Case Alert] JSON Schema $defs are supported by strict mode for reusable sub-schemas. If you reference a $def that isn't declared in the same schema object, the API will reject the request silently in some SDK versions rather than returning a useful error. Always validate complex schemas locally before deploying. A unit test that calls json_validate() on your schema definition is cheap insurance.
Handling Optional Fields
This is where developers get tripped up. You cannot simply omit a field from required, strict mode requires every property to be required. The idiomatic solution is to make the type nullable:
'escalate' => [
'type' => ['boolean', 'null'],
],
The model can now return null for escalate, which your PHP code handles cleanly. Do not try to work around this by removing optional fields from your schema entirely; you lose the benefit of a complete, predictable contract.
Making the API Call with Strict Schema Enforcement
Wrap the API call in a dedicated service. This is not optional in production. You need a single place to apply retry logic, log token usage, and handle the response contract.
<?php
namespace App\AI\Services;
use App\Exceptions\AI\ModelRefusalException;
use App\Exceptions\AI\TruncatedResponseException;
use Illuminate\Support\Facades\Log;
use OpenAI\Laravel\Facades\OpenAI;
use OpenAI\Exceptions\TransporterException;
use OpenAI\Exceptions\ErrorException;
class ReviewAnalysisService
{
private const MODEL = 'gpt-4o';
private const MAX_RETRIES = 3;
private const RETRY_DELAY_MS = 500;
public function analyse(string $reviewText): array
{
$attempt = 0;
while ($attempt < self::MAX_RETRIES) {
$attempt++;
try {
$response = OpenAI::chat()->create([
'model' => self::MODEL,
'messages' => [
[
'role' => 'system',
'content' => 'You are a review analysis assistant. Analyse the provided customer review and respond according to the schema.',
],
[
'role' => 'user',
'content' => $reviewText,
],
],
'response_format' => [
'type' => 'json_schema',
'json_schema' => config('ai-schemas.review_analysis'),
],
'max_tokens' => 1024,
]);
return $this->parseResponse($response);
} catch (ErrorException $e) {
// Rate limit: 429, server error: 5xx
if ($e->getCode() === 429 || $e->getCode() >= 500) {
if ($attempt < self::MAX_RETRIES) {
usleep(self::RETRY_DELAY_MS * 1000 * $attempt);
continue;
}
}
Log::error('OpenAI structured output error', [
'message' => $e->getMessage(),
'code' => $e->getCode(),
]);
throw $e;
} catch (TransporterException $e) {
// Network-level failure, retry
if ($attempt < self::MAX_RETRIES) {
usleep(self::RETRY_DELAY_MS * 1000 * $attempt);
continue;
}
throw $e;
}
}
throw new \RuntimeException('Max retries exceeded for structured output request.');
}
private function parseResponse(mixed $response): array
{
$choice = $response->choices[0];
// Handle truncation before checking refusal
if ($choice->finishReason === 'length') {
throw new TruncatedResponseException(
'Structured output response was truncated. Increase max_tokens or simplify the schema.'
);
}
$message = $choice->message;
if (!empty($message->refusal)) {
throw new ModelRefusalException($message->refusal);
}
return json_decode($message->content, true, 512, JSON_THROW_ON_ERROR);
}
}
Register this service in the Service Container via a provider, and inject it where needed. Never reach for the facade directly in controllers if you can avoid it.
The Two Failure Modes You Must Handle
Schema enforcement eliminates the malformed JSON failure class entirely. But two failure modes survive strict mode, and both require explicit handling.
Refusals
When the model declines to answer due to a safety policy, message.content is null and message.refusal contains the refusal explanation. Do not treat this as a generic exception, it's a first-class response state. Log it separately, because a spike in refusals often signals a prompt injection attempt or a shift in input data quality.
<?php
namespace App\Exceptions\AI;
class ModelRefusalException extends \RuntimeException
{
public function __construct(string $refusalReason)
{
parent::__construct("Model refused the request: {$refusalReason}");
}
}
In production, we've routed these to a separate monitoring channel. A refusal on a data-extraction job is almost always either a malformed upstream payload or a policy edge case worth reviewing manually.
Truncated Responses
finish_reason: 'length' means the model ran out of output tokens before completing the schema. In strict mode, a truncated response is always invalid JSON. The constrained decoder cannot produce partial schema-conforming output, so the token budget cuts the response mid-stream. The fix is almost always to increase max_tokens or to reduce schema complexity.
[Production Pitfall] Truncation in structured outputs is not recoverable at the application level. You cannot repair a half-written JSON object. Always set max_tokens generously and monitor finish reasons in your telemetry. A length finish reason on a structured output call is a configuration bug, not an edge case.
Laravel Validation Beyond the Schema
Strict mode guarantees structural compliance. It does not guarantee semantic correctness. A confidence field of 999.5 is perfectly valid JSON, schema-compliant, and completely wrong for your business logic.
A dedicated Laravel Data Transfer Object with validation handles the gap cleanly:
<?php
namespace App\AI\DTOs;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
readonly class ReviewAnalysisResult
{
public function __construct(
public string $sentiment,
public float $confidence,
public string $summary,
public array $flags,
public ?bool $escalate,
) {}
public static function fromArray(array $data): self
{
$validator = Validator::make($data, [
'sentiment' => ['required', 'string', 'in:positive,negative,neutral,mixed'],
'confidence' => ['required', 'numeric', 'between:0,1'],
'summary' => ['required', 'string', 'min:10', 'max:1000'],
'flags' => ['required', 'array'],
'flags.*' => ['string'],
'escalate' => ['nullable', 'boolean'],
]);
if ($validator->fails()) {
throw new ValidationException($validator);
}
return new self(
sentiment: $data['sentiment'],
confidence: (float) $data['confidence'],
summary: $data['summary'],
flags: $data['flags'],
escalate: $data['escalate'] ?? null,
);
}
}
This follows the same validation patterns documented in our complete guide to Laravel OpenAI integration. The schema enforces structure; the DTO enforces domain rules. Both layers earn their place.
[Architect's Note] The combination of strict schema + DTO validation gives you something genuinely new: an AI response you can type-hint. Downstream code that receives a ReviewAnalysisResult can trust its shape as reliably as any other value object in your application. This is how you stop writing defensive null-checks six layers deep.
Integrating Structured Outputs Into an Agentic Pipeline
This is where the real value materialises. In an agentic workflow, one AI call produces the input that triggers the next step. Without schema guarantees, you're writing defensive code at every handoff. With strict mode, the handoff is a typed contract.
<?php
namespace App\Jobs;
use App\AI\DTOs\ReviewAnalysisResult;
use App\AI\Services\ReviewAnalysisService;
use App\Exceptions\AI\ModelRefusalException;
use App\Exceptions\AI\TruncatedResponseException;
use App\Models\Review;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
class AnalyseReviewJob implements ShouldQueue
{
use InteractsWithQueue, Queueable;
public int $tries = 1; // Retries are handled inside the service
public function __construct(
private readonly int $reviewId
) {}
public function handle(ReviewAnalysisService $service): void
{
$review = Review::findOrFail($this->reviewId);
try {
$raw = $service->analyse($review->body);
$result = ReviewAnalysisResult::fromArray($raw);
$review->update([
'sentiment' => $result->sentiment,
'ai_confidence' => $result->confidence,
'ai_summary' => $result->summary,
'flags' => $result->flags,
]);
// Gate the next pipeline step on a semantic condition
if ($result->escalate === true) {
dispatch(new EscalateReviewJob($this->reviewId));
}
} catch (ModelRefusalException $e) {
Log::warning('Review analysis refused by model', [
'review_id' => $this->reviewId,
'reason' => $e->getMessage(),
]);
$review->update(['ai_status' => 'refused']);
} catch (TruncatedResponseException $e) {
Log::error('Review analysis truncated', [
'review_id' => $this->reviewId,
]);
$this->fail($e);
}
}
}
Notice what we're not doing: we're not writing isset($result['escalate']) before every downstream action. The DTO guarantees the key exists and has the correct type. The agentic branching logic is clean because the data contract is clean. This is what our production AI architecture contracts and governance guide refers to as treating AI outputs as typed system interfaces rather than raw text.
For heavier pipelines where multiple agents hand off between each other, pair this approach with schema validation against LLM hallucinations. Structured outputs remove structural hallucinations, but semantic drift (a confidence value that's technically valid but meaninglessly high) still needs an application-level assertion layer.
On the infrastructure side, every structured output job should run through Laravel AI middleware for token tracking so you capture token consumption per job class. Structured outputs tend to have stable, predictable token counts once your schema is stable, which makes anomaly detection on token spend genuinely useful. And if you want to understand how the temperature and max_tokens parameters interact with constrained decoding, the Laravel LLM inference control guide covers the mechanics.
Using a Laravel Abstraction Layer
Everything covered in this article is the raw implementation. For teams who don't want to manage schema definitions, response parsing, and retry logic manually, Prism PHP handles it through a unified SDK that wraps OpenAI, Anthropic, and Gemini behind a consistent interface.
The low-level approach earns its place for two reasons. First, Prism's structured output support is built on exactly these primitives, so understanding what the abstraction does underneath makes you a better consumer of it. Second, when the abstraction breaks (and it will, at a provider API boundary, under load, with an edge-case schema), you need to know what to reach for.
The decision point is straightforward. Greenfield project with multiple AI providers, or a team that shouldn't be managing provider-specific quirks: use Prism. Single-provider integration where you want explicit control over the request lifecycle, retry behaviour, and token telemetry: go direct.
Top comments (4)
Structured outputs are not an only OpenAI feature. So the post is a bit too narrow.
Also instead of creating the array from scratch you can use JsonSchema which is added to Laravel. The contract restrictions make it less error prone than an unrestricted array.
Creating an object for the schema feels wrong. Either you set it as a part of the AI request, like Laravel AI does. Or it is config. Using an object to wrap the schema adds no value.
I'm not sure why a schema registry would have any benefit? When the schema changes, the further processing changes. So even if the registry allows you to switch schemas, how are you going to switch to the alternative schema processing?
I do like the post for the low level insights, but for a production application I suggest using Laravel AI.
Thanks for the feedback. A couple of clarifications though. The narrow scope is intentional. This is an OpenAI article on my blog, targeting that specific API. Gemini and Claude handle structured outputs differently enough to warrant their own posts.
On the schema class, I'd push back slightly. A named, injectable class is testable in a way a raw inline array isn't. That said, the JsonSchema builder is the better implementation and I'll update it.
The registry critique is fair. Schema and processing logic are too tightly coupled for that abstraction to hold. That section is coming out.
Prism PHP is a solid recommendation for teams that want the abstraction. The low-level approach here is deliberate, for readers who want to understand what's happening underneath it.
I wasn't aware of the structured output so I looked at the documentation. And the thing I noticed is that the schema's aren't that different. So I assume the difference is in the error handling?
If the schema's where that different I don't think the Laravel AI library would have used the JsonSchema class.
The way I would explore the structured layout is to have at least two articles. One with the similarities and one with the differences. And show off a very limited multi LLM SDK.
Of course if you have it three specific articles they can score better on search engines.
The class only contains a raw array. What is the value of the test? That the class can be injected, isn't that a test that is already done by the dependency injection feature?
Good points, and the article has been updated to reflect them. The schema class is gone, replaced with a config file which is the right Laravel-native home for it. You're correct that the injectable argument doesn't hold when the class only wraps a raw array. That's a useful distinction. I learned something today.
The multi-article approach is already the plan. One per provider, then a cross-provider piece showing where the abstraction layer fits. Three focused articles will outrank one broad overview, which is exactly your point.
On the schema differences, I agree that the structure is largely portable. The implementation layer is where they diverge, how each provider receives the schema, the refusal mechanism, and the error surface. The article now makes that distinction explicit.