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.
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 namespace: testable, versioned, and injectable.
<?php
namespace App\AI\Schemas;
class ReviewAnalysisSchema
{
public static function definition(): array
{
return [
'name' => 'review_analysis',
'strict' => true,
'schema' => [
'type' => 'object',
'additionalProperties' => false,
'required' => ['sentiment', 'confidence', 'summary', 'flags'],
'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'],
],
],
],
];
}
}
Strict Mode Constraints You Must Know
Strict mode requires additionalProperties to be set to false for every object in the schema, and all fields in properties must be listed 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 schema subset that strict mode supports is deliberately narrow. These JSON Schema features are not supported in strict mode 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 constraints OpenAI won’t.
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\AI\Schemas\ReviewAnalysisSchema;
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' => ReviewAnalysisSchema::definition(),
],
'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.
Reusable Schema Registry Pattern
As your application grows, you’ll have schemas for review analysis, document extraction, lead scoring, and entity recognition. All of them will need versioning. A simple registry keeps this manageable:
<?php
namespace App\AI\Schemas;
class SchemaRegistry
{
private static array $schemas = [];
public static function register(string $name, array $schema): void
{
static::$schemas[$name] = $schema;
}
public static function get(string $name): array
{
if (!isset(static::$schemas[$name])) {
throw new \InvalidArgumentException("Schema '{$name}' is not registered.");
}
return static::$schemas[$name];
}
}
Register schemas in a service provider:
<?php
namespace App\Providers;
use App\AI\Schemas\ReviewAnalysisSchema;
use App\AI\Schemas\SchemaRegistry;
use Illuminate\Support\ServiceProvider;
class AIServiceProvider extends ServiceProvider
{
public function boot(): void
{
SchemaRegistry::register('review_analysis', ReviewAnalysisSchema::definition());
}
}
This pattern pairs cleanly with feature flags if you need to A/B test schema versions without deploying new code, a detail covered in OpenAI’s structured outputs documentation.
[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.
Top comments (0)