Episode 05: Structured Outputs in Laravel AI Applications
Structured output is the difference between asking a model to write something and asking it to return data Laravel can safely use. When an AI feature affects routing, storage, automation, or product decisions, the response needs a contract: fields, types, allowed values, validation rules, and a fal

Posted on Oct 14 • Originally published at kbzaman.com
Structured output is the difference between asking a model to write something and asking it to return data Laravel can safely use.
When an AI feature affects routing, storage, automation, or product decisions, the response needs a contract: fields, types, allowed values, validation rules, and a fallback path.
Plain text is useful for explanations, drafts, summaries, and human review. But when Laravel needs to classify a ticket, extract invoice fields, choose a workflow, or store a result, prose becomes fragile.
Structured output is not trust: A schema makes model output easier to parse. It does not make the model correct. Laravel still owns validation, authorization, record lookup, persistence, retries, and the final decision.
A paragraph can look right to a user and still be useless to the application.
If a model replies with "this is probably urgent billing," Laravel has to guess which value should go into the queue: urgent, high, billing, payment_issue, or something else.
That guess becomes hidden business logic.
Structured output removes that guess from the parser and moves it into an explicit contract. The model must return known fields. Laravel validates those fields before using them.
Use plain text when a human will read and approve the result.
Use structured output when Laravel will route, store, calculate, compare, or trigger another workflow.
Use both when the UI needs an explanation but the backend needs validated fields.
Do not begin by asking, "What JSON should the model return?"
Begin with the product decision Laravel needs to make.
A support triage feature may need priority, category, confidence, and a reason. An invoice extraction feature may need invoice number, vendor name, due date, currency, and line items.
Useful structured outputs usually fall into a few categories:
A structured response should be as small as the decision requires. Extra fields increase cost, latency, validation work, and the chance that the model invents details.
A schema should describe the shape, types, allowed values, nullable behavior, and uncertainty rules.
The best schema is boring. It uses enums where the product has known choices and keeps free text short when the value will be stored or displayed.
<?php namespace App\Ai\Contracts; final class TicketTriageSchema { public const PRIORITIES = [ 'low', 'normal', 'high', 'urgent' ]; public const CATEGORIES = [ 'billing', 'bug', 'account', 'feature_request', 'other' ]; public static function rules (): array { return [ 'priority' => [ 'required', 'string', 'in:low,normal,high,urgent' ], 'category' => [ 'required', 'string', 'in:billing,bug,account,feature_request,other' ], 'confidence' => [ 'required', 'numeric', 'between:0,1' ], 'reason' => [ 'required', 'string', 'max:180' ], ]; } } Enter fullscreen mode Exit fullscreen mode This kind of schema gives Laravel something concrete to enforce.
It also gives the model a clearer target: return one of these values, explain briefly, and express uncertainty as a number the application can reason about.
Structured output prompts should be explicit about fields, allowed values, missing information, and what to do when the input is unclear.
Avoid asking for "valid JSON" only. That describes formatting, not business meaning.
$prompt = <<<PROMPT Classify this support ticket for a Laravel support queue. Allowed priorities: low, normal, high, urgent. Allowed categories: billing, bug, account, feature_request, other. Return JSON with exactly these fields: - priority - category - confidence - reason Rules: - confidence must be between 0 and 1. - If the ticket is unclear, use category "other" and confidence below 0.6. - Do not invent account state, payment status, or engineering cause. - reason must be one short sentence. Ticket: {$ticket->message} PROMPT; Enter fullscreen mode Exit fullscreen mode The prompt defines the response contract, but Laravel must still parse and validate the result.
The model may ignore instructions, return malformed JSON, choose an unsupported enum, or include a confident but wrong reason.
Treat model output like an external API response.
Parse it carefully, validate it, and map it into a typed object before the rest of the application uses it.
Do not pass raw model arrays deep into your domain code.
<?php namespace App\Services; use App\Ai\Contracts\TicketTriageSchema; use Illuminate\Support\Facades\Validator; use JsonException; final class RunTicketTriage { public function parse ( string $content ):?TicketTriageResult { try { $data = json_decode ( $content, true, flags: JSON_THROW_ON_ERROR ); } catch ( JsonException ) { return null; } $validator = Validator:: make ( $data, TicketTriageSchema:: rules ()); if ( $validator -> fails ()) { return null; } return new TicketTriageResult ( priority: $data [ 'priority' ], category: $data [ 'category' ], confidence: ( float ) $data [ 'confidence' ], reason: $data [ 'reason' ], ); } } Enter fullscreen mode Exit fullscreen mode A DTO is not only for cleanliness. It prevents the rest of the application from depending on model-specific response shapes.
If you change provider, schema, or parser later, the domain workflow can stay stable.
Confidence should not be treated as truth. It is a routing signal.
A low-confidence classification can still be useful if Laravel uses it to slow down automation, ask for review, or choose a safer fallback.
if ( $result === null ) { return TicketTriageDecision:: manualReview ( 'Invalid AI output' ); } if ( $result -> confidence < 0.65 ) { return TicketTriageDecision:: manualReview ( $result -> reason ); } if ( $result -> priority === 'urgent' ) { return TicketTriageDecision:: escalate ( $result -> category, $result -> reason ); } return TicketTriageDecision:: route ( $result -> category, $result -> priority ); Enter fullscreen mode Exit fullscreen mode This is the key boundary: the model returns a structured suggestion, and Laravel decides what the suggestion is allowed to do.
The workflow can be conservative when confidence is low or when the category touches sensitive business rules.
If output is malformed, a retry with a repair prompt may help.
If the model chose the wrong business category because the ticket is ambiguous, retrying the same prompt may only add cost.
Separate formatting failures from uncertainty failures.
The structured output lifecycle usually looks like this:
Retry policy should be small and observable.
Log why the first response failed, how many retries were attempted, and whether the final output passed validation.
Otherwise structured output failures become invisible cost leaks.
A structured response can look official. That is dangerous.
If the model returns { "action": "refund_customer" }, Laravel still must check whether the current user, tenant, policy, record state, and workflow allow that action.
A JSON field is not permission: Structured output may describe a requested action. It does not authorize that action. Permission still belongs to Laravel policies, services, and workflow rules.
When structured output drives a workflow, logs should explain the contract that ran and the validation result.
This makes debugging much easier when a prompt version changes, a provider starts returning different formatting, or a schema becomes too strict.
logger () -> info ( 'ai.ticket_triage.structured_output', [ 'ticket_id' => $ticket -> id, 'schema' => 'ticket-triage:v1', 'provider' => $response -> provider, 'model' => $response -> model, 'input_tokens' => $response -> usage?-> inputTokens, 'output_tokens' => $response -> usage?-> outputTokens, 'validation_passed' => $result!== null, 'fallback' => $result === null? 'manual_review': null, ]); Enter fullscreen mode Exit fullscreen mode Avoid logging sensitive prompt content by default.
In many applications, schema version, validation status, token usage, latency, and fallback path are enough for operational visibility.
Most structured output bugs come from pretending the schema is stronger than it is.
The schema helps the model and the parser communicate, but the application still needs normal defensive engineering.
Structured output turns prompt engineering into an application contract.
It gives Laravel a predictable shape to parse, validate, log, and route.
It also makes the boundary between probabilistic generation and deterministic application behavior much clearer.
In the next episode, we will focus on reliability patterns for AI responses: validation, retry, fallback, repair prompts, manual review, and how Laravel can make model behavior dependable enough for production workflows.
If you found this useful, follow me for the next article in the AI Engineering with Laravel series.