Structured output
Two ways to get JSON back from a text task:
schema()— the shape is enforced by the provider itself (native structured output). Recommended.jsonMode— the provider guarantees valid JSON syntax; the shape is controlled by the prompt. For cases where a strict shape isn’t needed, or for streaming.
schema() takes precedence when both are set.
Schema
Section titled “Schema”Implement schema(): ?\Closure on the task:
use Illuminate\Contracts\JsonSchema\JsonSchema;
public function schema(): ?\Closure{ return fn (JsonSchema $schema): array => [ 'summary' => $schema->string(), ];}The model can’t return a shape you didn’t ask for. AiResponse::$structured is the already-decoded array matching the schema — no json_decode() or markdown-fence stripping in postprocess():
public function postprocess(AiResponse $response): array{ return ['summary' => $response->structured['summary'] ?? ''];}Works with send() and queue() (the closure is wrapped in SerializableClosure automatically, so it survives the queue payload), but not with stream().
Field types and nesting
Section titled “Field types and nesting”JsonSchema supports the usual field types plus nested objects and optional fields:
public function schema(): ?\Closure{ return fn (JsonSchema $schema): array => [ 'action' => $schema->string()->enum(['reply', 'escalate_to_human']), 'confidence' => $schema->number(), 'urgent' => $schema->boolean(), 'contact' => $schema->object([ 'name' => $schema->string(), 'email' => $schema->string(), ])->nullable(), // the whole object, or null when there's nothing to report ];}Structured output is always a top-level object. A list result (e.g. keywords) has to be wrapped under a key and unwrapped in postprocess():
public function schema(): ?\Closure{ return fn (JsonSchema $schema): array => [ 'keywords' => $schema->array()->items($schema->string()), ];}
public function postprocess(AiResponse $resp): array{ return ['keywords' => $resp->structured['keywords'] ?? []];}Provider-specific options
Section titled “Provider-specific options”For schema-based tasks, pass raw provider request fields via options['provider_options'], keyed by driver name. Only the matching driver receives its entry:
return new AiPayload( modality: 'text', messages: [new UserMessage($this->text)], options: [ 'provider_options' => [ 'deepseek' => ['thinking' => ['type' => 'disabled']], // ignored by other drivers ], ],);Useful for provider-native knobs the package doesn’t wrap: DeepSeek thinking, Anthropic extended-thinking budgets, Gemini thinking_level. Gemini fields must use the Interactions API names (laravel/ai 1.0).
JSON mode
Section titled “JSON mode”return new AiPayload( modality: 'text', messages: [new UserMessage($this->text)], systemPrompt: 'Classify the text. Reply with {"category": "...", "confidence": 0.0-1.0}.', jsonMode: true,);The package translates jsonMode into the provider’s own parameter:
| Provider | Mechanism |
|---|---|
| OpenAI, xAI | text.format: {type: json_object} (Responses API) |
| Gemini | response_format with mime_type: application/json (Interactions API) |
| DeepSeek, Groq, Mistral, OpenRouter, OpenAI-compatible | response_format: {type: json_object} (Chat Completions) |
| Anthropic | no native JSON mode — rely on the system prompt |
Response metadata
Section titled “Response metadata”Besides content and structured, AiResponse carries fields that survive the queued round-trip (stored in ai_runs.response, restored for postprocess()):
$toolCalls— tools the model actually invoked, one entry perLaravel\Ai\Responses\Data\ToolCall::toArray(). Empty when no tool was called.$finishReason— the last step’s stop reason:stop,length,tool_calls,content_filter,error,unknown. Useful inisAcceptable()to tell a truncated response (length) apart from other failures.
$raw is currently always empty. Full list of fields — AiPayload & AiResponse.