The moment you try to use an LLM for anything beyond displaying text to a user, you hit the same problem: you need structured data, not prose. You need a JSON object with specific keys, proper types, and values that actually make sense. And if you've ever tried prompting a model to "return JSON" and then parsing the result, you know how unreliable that can be. Sometimes you get valid JSON. Sometimes you get JSON wrapped in markdown code fences. Sometimes you get a friendly explanation of what the JSON would look like.
Fortunately, the major AI providers now offer mechanisms to guarantee structured output. This guide covers the three main approaches — function calling, JSON mode, and response format schemas — and shows you how to build a robust parsing and validation layer in PHP that handles the remaining edge cases gracefully.
The Three Approaches to Structured Output
Prompt Engineering (Fragile)
The simplest approach is asking the model to return JSON in your prompt. This is also the most unreliable:
// Don't rely on this in production
$prompt = 'Analyze the following product review and return a JSON object with keys:
"sentiment" (positive, negative, or neutral),
"confidence" (0-100),
"topics" (array of strings).
Review: "This laptop is incredibly fast but the battery life is disappointing."';
The model might return valid JSON, or it might wrap it in explanatory text, use single quotes, add trailing commas, or include a "Here is the JSON:" preamble. You can mitigate this with aggressive prompt engineering, but you can't eliminate the failure mode entirely. Use this approach only for prototyping.
JSON Mode (Better)
OpenAI and other providers offer a JSON mode that constrains the model to output valid JSON. In the API request, you set response_format:
$response = Http::withToken(config('ai.providers.openai.api_key'))
->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4o',
'messages' => [
[
'role' => 'system',
'content' => 'You analyze product reviews. Always respond with JSON containing: sentiment, confidence, and topics.',
],
[
'role' => 'user',
'content' => $reviewText,
],
],
'response_format' => ['type' => 'json_object'],
]);
$data = $response->json('choices.0.message.content');
$parsed = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
JSON mode guarantees syntactically valid JSON, but it doesn't guarantee the schema. The model might return {"result": "positive"} instead of {"sentiment": "positive"}. You still need validation on your end.
Structured Outputs with JSON Schema (Best)
The most reliable approach uses a JSON Schema to define exactly what the model must return. OpenAI calls this "structured outputs" and it guarantees both valid JSON and schema conformance:
$response = Http::withToken(config('ai.providers.openai.api_key'))
->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4o',
'messages' => [
[
'role' => 'system',
'content' => 'You analyze product reviews.',
],
[
'role' => 'user',
'content' => "Analyze this review: {$reviewText}",
],
],
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'review_analysis',
'strict' => true,
'schema' => [
'type' => 'object',
'properties' => [
'sentiment' => [
'type' => 'string',
'enum' => ['positive', 'negative', 'neutral', 'mixed'],
],
'confidence' => [
'type' => 'integer',
'description' => 'Confidence score from 0 to 100',
],
'topics' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
'summary' => [
'type' => 'string',
'description' => 'One-sentence summary of the review',
],
],
'required' => ['sentiment', 'confidence', 'topics', 'summary'],
'additionalProperties' => false,
],
],
],
]);
With strict mode enabled, the API guarantees the output matches your schema. The sentiment field will always be one of your enum values. The topics field will always be an array of strings. No surprises.
Function Calling for Structured Extraction
Function calling is another powerful approach, especially when you want the model to decide which structure to return based on context. You define "tools" with parameter schemas, and the model responds with a function call containing structured arguments.
$response = Http::withToken(config('ai.providers.openai.api_key'))
->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4o',
'messages' => [
[
'role' => 'user',
'content' => "Extract contact details from this text: {$emailBody}",
],
],
'tools' => [
[
'type' => 'function',
'function' => [
'name' => 'save_contact',
'description' => 'Save extracted contact information',
'strict' => true,
'parameters' => [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
'email' => ['type' => 'string'],
'phone' => ['type' => ['string', 'null']],
'company' => ['type' => ['string', 'null']],
'role' => ['type' => ['string', 'null']],
],
'required' => ['name', 'email', 'phone', 'company', 'role'],
'additionalProperties' => false,
],
],
],
],
'tool_choice' => [
'type' => 'function',
'function' => ['name' => 'save_contact'],
],
]);
$toolCall = $response->json('choices.0.message.tool_calls.0');
$contact = json_decode($toolCall['function']['arguments'], true);
// $contact is now a structured array:
// ['name' => 'Jane Smith', 'email' => 'jane@example.com', 'phone' => '+1-555-0123', ...]
The tool_choice parameter forces the model to call the specific function rather than responding with text. Combined with strict mode on the function parameters, this gives you the same schema guarantees as structured outputs. The key advantage of function calling is when you have multiple possible output types and want the model to pick the right one.
Building a Schema-Driven Extraction Service
In a real application, you don't want to inline JSON schemas throughout your codebase. Build a service that maps PHP classes to schemas and handles the round-trip for you.
// app/DataTransferObjects/ReviewAnalysis.php
namespace App\DataTransferObjects;
class ReviewAnalysis
{
public function __construct(
public readonly string $sentiment,
public readonly int $confidence,
/** @var array<int, string> */
public readonly array $topics,
public readonly string $summary,
) {}
/** @return array<string, mixed> */
public static function jsonSchema(): array
{
return [
'type' => 'object',
'properties' => [
'sentiment' => [
'type' => 'string',
'enum' => ['positive', 'negative', 'neutral', 'mixed'],
],
'confidence' => [
'type' => 'integer',
'description' => 'Confidence score from 0 to 100',
],
'topics' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'Key topics mentioned in the review',
],
'summary' => [
'type' => 'string',
'description' => 'One-sentence summary of the review',
],
],
'required' => ['sentiment', 'confidence', 'topics', 'summary'],
'additionalProperties' => false,
];
}
/** @param array<string, mixed> $data */
public static function fromArray(array $data): self
{
return new self(
sentiment: $data['sentiment'],
confidence: $data['confidence'],
topics: $data['topics'],
summary: $data['summary'],
);
}
}
Now build the extraction service that ties it together:
// app/Services/Ai/StructuredExtractor.php
namespace App\Services\Ai;
use App\Exceptions\ExtractionFailedException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class StructuredExtractor
{
public function __construct(
private readonly string $apiKey,
private readonly string $model = 'gpt-4o',
) {}
/**
* @template T
* @param class-string<T> $dtoClass
* @return T
*/
public function extract(
string $input,
string $dtoClass,
string $systemPrompt = 'Extract the requested information.',
): mixed {
if (! method_exists($dtoClass, 'jsonSchema') || ! method_exists($dtoClass, 'fromArray')) {
throw new \InvalidArgumentException(
"DTO class must implement jsonSchema() and fromArray() methods."
);
}
$response = Http::withToken($this->apiKey)
->timeout(30)
->retry(2, 1000)
->post('https://api.openai.com/v1/chat/completions', [
'model' => $this->model,
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $input],
],
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => class_basename($dtoClass),
'strict' => true,
'schema' => $dtoClass::jsonSchema(),
],
],
]);
if ($response->failed()) {
Log::error('AI extraction failed', [
'status' => $response->status(),
'dto' => $dtoClass,
]);
throw new ExtractionFailedException(
"API returned status {$response->status()}"
);
}
$content = $response->json('choices.0.message.content');
$finishReason = $response->json('choices.0.finish_reason');
if ($finishReason === 'length') {
throw new ExtractionFailedException(
'Response was truncated due to token limits.'
);
}
$data = json_decode($content, true, flags: JSON_THROW_ON_ERROR);
return $dtoClass::fromArray($data);
}
}
Usage becomes remarkably clean:
$extractor = app(StructuredExtractor::class);
$analysis = $extractor->extract(
input: $review->body,
dtoClass: ReviewAnalysis::class,
systemPrompt: 'Analyze the product review. Be precise with sentiment classification.',
);
echo $analysis->sentiment; // "mixed"
echo $analysis->confidence; // 85
echo $analysis->topics; // ["performance", "battery"]
Validation Beyond the Schema
Schema enforcement guarantees structure, but it doesn't guarantee semantic correctness. A confidence score of 500 is valid according to the schema (it's an integer) but nonsensical according to your domain. Add validation in your DTO:
// app/DataTransferObjects/ReviewAnalysis.php
/** @param array<string, mixed> $data */
public static function fromArray(array $data): self
{
// Clamp confidence to valid range
$confidence = max(0, min(100, (int) $data['confidence']));
// Filter out empty topics
$topics = array_values(array_filter(
$data['topics'],
fn (string $topic) => trim($topic) !== '',
));
if (empty($topics)) {
$topics = ['general'];
}
return new self(
sentiment: $data['sentiment'],
confidence: $confidence,
topics: $topics,
summary: $data['summary'],
);
}
For more complex validation, use Laravel validation rules directly on the AI output:
use Illuminate\Support\Facades\Validator;
/** @param array<string, mixed> $data */
public static function fromArray(array $data): self
{
$validator = Validator::make($data, [
'sentiment' => ['required', 'in:positive,negative,neutral,mixed'],
'confidence' => ['required', 'integer', 'between:0,100'],
'topics' => ['required', 'array', 'min:1'],
'topics.*' => ['string', 'max:100'],
'summary' => ['required', 'string', 'max:500'],
]);
if ($validator->fails()) {
throw new \App\Exceptions\ExtractionFailedException(
'AI output failed validation: ' . $validator->errors()->first()
);
}
$validated = $validator->validated();
return new self(
sentiment: $validated['sentiment'],
confidence: $validated['confidence'],
topics: $validated['topics'],
summary: $validated['summary'],
);
}
This catches cases where the model returns semantically invalid data despite being structurally correct. The validation layer is your last line of defense before AI output enters your domain logic.
Handling Multiple Output Types
Sometimes you need the AI to choose between different output structures based on the input. Function calling with multiple tools handles this elegantly:
// app/Services/Ai/EmailClassifier.php
namespace App\Services\Ai;
use Illuminate\Support\Facades\Http;
class EmailClassifier
{
private const TOOLS = [
[
'type' => 'function',
'function' => [
'name' => 'classify_support_request',
'description' => 'Classify an email as a support request',
'parameters' => [
'type' => 'object',
'properties' => [
'urgency' => [
'type' => 'string',
'enum' => ['low', 'medium', 'high', 'critical'],
],
'category' => [
'type' => 'string',
'enum' => ['billing', 'technical', 'account', 'feature_request'],
],
'summary' => ['type' => 'string'],
],
'required' => ['urgency', 'category', 'summary'],
'additionalProperties' => false,
],
],
],
[
'type' => 'function',
'function' => [
'name' => 'classify_sales_inquiry',
'description' => 'Classify an email as a sales inquiry',
'parameters' => [
'type' => 'object',
'properties' => [
'company_size' => [
'type' => 'string',
'enum' => ['startup', 'smb', 'enterprise', 'unknown'],
],
'interest' => ['type' => 'string'],
'budget_mentioned' => ['type' => 'boolean'],
],
'required' => ['company_size', 'interest', 'budget_mentioned'],
'additionalProperties' => false,
],
],
],
];
public function classify(string $emailBody): array
{
$response = Http::withToken(config('ai.providers.openai.api_key'))
->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4o',
'messages' => [
[
'role' => 'system',
'content' => 'Classify the incoming email by calling the appropriate function.',
],
['role' => 'user', 'content' => $emailBody],
],
'tools' => self::TOOLS,
]);
$toolCall = $response->json('choices.0.message.tool_calls.0');
$functionName = $toolCall['function']['name'];
$arguments = json_decode($toolCall['function']['arguments'], true);
return [
'type' => $functionName,
'data' => $arguments,
];
}
}
The model reads the email and decides whether it's a support request or a sales inquiry, then returns the appropriate structured data for that classification. Your routing logic can branch on the type field and handle each case with the correct DTO.
Graceful Failure Handling
Even with structured outputs, things go wrong. The API times out. The model refuses to process content it considers harmful. The response gets truncated because it hit the token limit. Build a wrapper that handles all of these cases:
// app/Services/Ai/SafeExtractor.php
namespace App\Services\Ai;
use App\Exceptions\ExtractionFailedException;
use Illuminate\Support\Facades\Log;
class SafeExtractor
{
public function __construct(
private readonly StructuredExtractor $extractor,
) {}
/**
* @template T
* @param class-string<T> $dtoClass
* @param T|null $fallback
* @return T|null
*/
public function tryExtract(
string $input,
string $dtoClass,
string $systemPrompt = 'Extract the requested information.',
mixed $fallback = null,
): mixed {
try {
return $this->extractor->extract($input, $dtoClass, $systemPrompt);
} catch (ExtractionFailedException $e) {
Log::warning('AI extraction failed, using fallback', [
'dto' => $dtoClass,
'error' => $e->getMessage(),
]);
return $fallback;
} catch (\JsonException $e) {
Log::error('AI returned invalid JSON despite structured output mode', [
'dto' => $dtoClass,
'error' => $e->getMessage(),
]);
return $fallback;
} catch (\Throwable $e) {
Log::error('Unexpected error during AI extraction', [
'dto' => $dtoClass,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return $fallback;
}
}
/**
* @template T
* @param class-string<T> $dtoClass
* @return T
*/
public function extractOrFail(
string $input,
string $dtoClass,
string $systemPrompt = 'Extract the requested information.',
): mixed {
$result = $this->tryExtract($input, $dtoClass, $systemPrompt);
if ($result === null) {
throw new ExtractionFailedException(
"Failed to extract {$dtoClass} after all retries."
);
}
return $result;
}
}
This gives you two interfaces: tryExtract returns a fallback on failure (useful when the extraction is optional), and extractOrFail throws when the extraction is critical. Both log the failure for monitoring.
Caching Extractions
If you're extracting structured data from content that doesn't change, cache the result. This saves money and latency:
use Illuminate\Support\Facades\Cache;
/**
* @template T
* @param class-string<T> $dtoClass
* @return T
*/
public function extractCached(
string $input,
string $dtoClass,
string $systemPrompt = 'Extract the requested information.',
int $ttlSeconds = 86400,
): mixed {
$cacheKey = 'ai_extraction:' . md5($dtoClass . $systemPrompt . $input);
return Cache::remember($cacheKey, $ttlSeconds, function () use ($input, $dtoClass, $systemPrompt) {
return $this->extractor->extract($input, $dtoClass, $systemPrompt);
});
}
The cache key includes the DTO class, system prompt, and input text, so changing any of these produces a cache miss and a fresh extraction. Set the TTL based on how often your source content changes.
Batch Processing with Queued Jobs
When you need to extract structured data from hundreds or thousands of items, dispatch it as queued jobs to manage rate limits and timeouts:
// app/Jobs/ExtractReviewAnalysis.php
namespace App\Jobs;
use App\DataTransferObjects\ReviewAnalysis;
use App\Models\Review;
use App\Services\Ai\SafeExtractor;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class ExtractReviewAnalysis implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(
public readonly Review $review,
) {}
/** @return array<int, object> */
public function middleware(): array
{
return [new RateLimited('ai-api')];
}
public function handle(SafeExtractor $extractor): void
{
$analysis = $extractor->tryExtract(
input: $this->review->body,
dtoClass: ReviewAnalysis::class,
systemPrompt: 'Analyze the product review thoroughly.',
);
if ($analysis) {
$this->review->update([
'sentiment' => $analysis->sentiment,
'confidence' => $analysis->confidence,
'topics' => $analysis->topics,
'ai_summary' => $analysis->summary,
]);
}
}
}
Define the rate limiter in your AppServiceProvider:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('ai-api', function (object $job) {
return Limit::perMinute(50);
});
Now you can dispatch thousands of extraction jobs without worrying about hitting API rate limits. The queue worker processes them at a steady 50 per minute, retries on failure, and your users see results appear progressively.
Testing Structured Extraction
Test your extraction pipeline without hitting the API by faking HTTP responses with the exact structure the API returns:
use Illuminate\Support\Facades\Http;
public function test_review_analysis_extraction(): void
{
Http::fake([
'api.openai.com/*' => Http::response([
'choices' => [
[
'message' => [
'content' => json_encode([
'sentiment' => 'mixed',
'confidence' => 82,
'topics' => ['performance', 'battery'],
'summary' => 'Fast laptop with poor battery life.',
]),
],
'finish_reason' => 'stop',
],
],
]),
]);
$extractor = app(StructuredExtractor::class);
$result = $extractor->extract(
input: 'Great speed but battery dies in 2 hours.',
dtoClass: ReviewAnalysis::class,
);
$this->assertInstanceOf(ReviewAnalysis::class, $result);
$this->assertEquals('mixed', $result->sentiment);
$this->assertEquals(82, $result->confidence);
$this->assertContains('battery', $result->topics);
}
public function test_extraction_handles_truncated_response(): void
{
Http::fake([
'api.openai.com/*' => Http::response([
'choices' => [
[
'message' => ['content' => '{"sentiment": "pos'],
'finish_reason' => 'length',
],
],
]),
]);
$extractor = app(StructuredExtractor::class);
$this->expectException(ExtractionFailedException::class);
$this->expectExceptionMessage('truncated');
$extractor->extract(
input: 'Some review text',
dtoClass: ReviewAnalysis::class,
);
}
Practical Tips
Keep schemas simple. The more complex your schema, the more likely the model is to make semantic errors even when the structure is correct. If you need deeply nested output, consider breaking it into multiple extraction calls.
Use enums aggressively. Constrain string fields to specific values whenever possible. A schema that accepts any string for a status field will eventually return something your code doesn't handle. An enum makes the set of possibilities explicit and finite.
Add descriptions to schema properties. The model reads these descriptions and uses them to understand what you expect. A confidence field with the description "Confidence score from 0 to 100 where 100 means absolute certainty" produces better values than a bare integer field.
Check the finish reason. If finish_reason is length, the response was truncated and the JSON is almost certainly incomplete. Always check this before attempting to parse.
Monitor extraction quality. Log a sample of extraction results and periodically review them for accuracy. Models can drift in behavior between versions, and what worked perfectly with one model version might produce subtly different results after a provider update.
Conclusion
Getting reliable structured data from LLMs is a solved problem — if you use the right tools. JSON Schema with strict mode gives you structural guarantees. Function calling gives you type selection. Laravel validation gives you semantic correctness. And a service layer with caching, retries, and fallbacks gives you production reliability.
The pattern is consistent: define your schema as close to your DTO as possible, let the API enforce the structure, validate the semantics in your application layer, and always have a plan for when the extraction fails. AI output is inherently probabilistic, but your application doesn't have to be. I've used this exact service layer pattern across multiple projects now, and once the wrapper is in place, the rest of your code never needs to know it's talking to an LLM.