Laravel Daily's

Structured Outputs in the Laravel AI SDK: Typed, Valid JSON From Any LLM

hero image

LLMs are excellent at producing language. Production applications need predictable data.

A support ticket classifier should not return a paragraph when your application expects category, priority, and score. It should return a machine-readable payload that your PHP code can validate, persist, and expose through an API.

The Laravel AI SDK provides structured outputs for this purpose. You define the output shape in PHP with JsonSchema. Your agent implements HasStructuredOutput. The SDK then translates that schema into the native format required by the selected provider.

The result is a StructuredAgentResponse that you can access like an array.

This is one reason Laravel remains a leading PHP web framework. It gives PHP developers a consistent way to move from an AI prompt to application-ready data without rebuilding provider integrations.

Why unstructured responses fail in production

A normal LLM response is usually plain text. Even when you ask for JSON, the model can return:

Here is the classification:

{
  "category": "technical",
  "priority": "high",
  "score": "92"
}

That response creates several problems.

  • The output may include prose around the JSON.
  • A required key may be missing.
  • An integer may arrive as a string.
  • An enum value may use an unexpected spelling.
  • The model may add fields your API does not support.
  • json_decode() may return null or an unexpected structure.

You can handle these cases manually. That means writing parsing, validation, coercion, and error handling around every AI request.

Structured outputs move the contract into the agent definition. The provider receives the schema as part of the request. The model is asked to produce data matching that schema. The SDK decodes the result for PHP consumption.

This does not remove the need for business validation. You should still authorize actions, enforce database rules, and handle provider failures. It does remove a large amount of repetitive response parsing.

Create a structured agent with Artisan

Install the SDK with Composer:

composer require laravel/ai

Publish its configuration and migrations:

php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

Configure the provider credentials in your .env file:

OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GEMINI_API_KEY=

Then create an agent with the structured option:

php artisan make:agent TicketClassifier --structured

The command creates an agent class with the required contract and schema method. The generated class belongs in your application’s app/Ai/Agents directory.

The --structured flag is a small detail. It is also a useful example of Laravel’s approach to PHP developer tools. The framework scaffolds the correct structure, so you can focus on the application behavior.

Define the output with JsonSchema

A structured agent implements HasStructuredOutput and defines a schema method.

Here is a small example:

<?php

namespace App\Ai\Agents;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;

class TicketClassifier implements Agent, HasStructuredOutput
{
    use Promptable;

    public function instructions(): string
    {
        return <<<'PROMPT'
            Classify customer support tickets.
            Use only the values allowed by the output schema.
            Score the classification confidence from 0 to 100.
        PROMPT;
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'category' => $schema->string()
                ->enum([
                    'billing',
                    'account',
                    'technical',
                    'shipping',
                    'other',
                ])
                ->required(),

            'priority' => $schema->string()
                ->enum([
                    'low',
                    'normal',
                    'high',
                    'urgent',
                ])
                ->required(),

            'score' => $schema->integer()
                ->min(0)
                ->max(100)
                ->required(),

            'summary' => $schema->string()
                ->required(),

            'tags' => $schema->array(
                $schema->string()
            )
                ->min(1)
                ->max(5)
                ->required(),
        ];
    }
}

The schema is written as a PHP array. Each value is a fluent schema definition.

Strings

Use string() for text fields:

'title' => $schema->string()->required(),

You can restrict strings with enum():

'priority' => $schema->string()
    ->enum(['low', 'normal', 'high', 'urgent'])
    ->required(),

The model must select one of the allowed values.

Integers

Use integer() when your application expects a whole number:

'score' => $schema->integer()
    ->min(0)
    ->max(100)
    ->required(),

The min() and max() constraints define the accepted numeric range.

Arrays

Pass the item schema to array():

'tags' => $schema->array(
    $schema->string()
)
    ->min(1)
    ->max(5)
    ->required(),

The output must contain an array of strings. The array must contain between one and five items.

You can also return an array of objects:

'actions' => $schema->array(
    $schema->object(fn ($schema) => [
        'name' => $schema->string()->required(),
        'reason' => $schema->string()->required(),
    ])
)->required(),

Objects

Use object() for nested data:

'metadata' => $schema->object(fn ($schema) => [
    'language' => $schema->string()->required(),
    'confidence' => $schema->string()
        ->enum(['low', 'medium', 'high'])
        ->required(),
])->required(),

Call required() on every field that must be present. You can also call it on the object or array property itself.

A fluent Laravel JsonSchema definition translated into OpenAI, Anthropic, and Gemini provider formats

One schema, different provider formats

Your agent does not need provider-specific schema code.

The Laravel AI SDK translates the same PHP schema into the format expected by each supported provider.

  • OpenAI receives a structured response format based on JSON Schema.
  • Anthropic uses its tool-use mechanism to describe the expected structured arguments.
  • Gemini receives a native response schema for structured generation.

The provider details stay inside the SDK. Your agent continues to use HasStructuredOutput, JsonSchema, and Promptable.

This makes provider changes less disruptive. You can change the provider or model without rewriting the application’s output contract. The exact capabilities still depend on the model and provider. Use a model that supports structured output in the feature set you need.

You can select a provider when prompting:

use Laravel\Ai\Enums\Lab;

$response = (new TicketClassifier)->prompt(
    'Classify this ticket: My payment was charged twice.',
    provider: Lab::Anthropic,
    model: 'claude-sonnet-5',
);

You can also configure the provider and model on the agent with PHP attributes. See the AI SDK agent documentation for provider, model, timeout, and token settings.

Read StructuredAgentResponse like an array

When a structured agent is prompted, the SDK returns a StructuredAgentResponse.

Access fields using normal array syntax:

$response = (new TicketClassifier)->prompt(
    'My account is locked after too many login attempts.'
);

$category = $response['category'];
$priority = $response['priority'];
$score = $response['score'];
$summary = $response['summary'];
$tags = $response['tags'];

The response is decoded into a structured PHP value. You do not need to extract a JSON block from prose or call json_decode() yourself.

The schema also communicates intent to static analysis and to the next developer reading the agent. In PHP, the response remains a runtime value rather than a generated DTO. Add Laravel validation or a dedicated data object when your domain requires an additional application-level contract.

Full example: classify support tickets

Suppose your application receives tickets through a REST endpoint. You want to classify each ticket before routing it to a team.

The agent from above provides the classification contract. Add a controller action:

<?php

namespace App\Http\Controllers;

use App\Ai\Agents\TicketClassifier;
use App\Models\SupportTicket;
use Illuminate\Http\Request;

class ClassifyTicketController
{
    public function __invoke(Request $request)
    {
        $validated = $request->validate([
            'message' => ['required', 'string', 'max:5000'],
        ]);

        $response = (new TicketClassifier)->prompt(
            $validated['message']
        );

        $payload = [
            'category' => $response['category'],
            'priority' => $response['priority'],
            'score' => $response['score'],
            'summary' => $response['summary'],
            'tags' => $response['tags'],
        ];

        SupportTicket::create([
            'message' => $validated['message'],
            'category' => $payload['category'],
            'priority' => $payload['priority'],
            'classification_score' => $payload['score'],
            'summary' => $payload['summary'],
            'tags' => $payload['tags'],
        ]);

        return response()->json($payload);
    }
}

Register the endpoint:

use App\Http\Controllers\ClassifyTicketController;
use Illuminate\Support\Facades\Route;

Route::post('/support-tickets/classify', ClassifyTicketController::class);

A request might look like this:

POST /support-tickets/classify
Content-Type: application/json

{
  "message": "I was charged twice for my monthly subscription."
}

The controller can return a stable payload:

{
  "category": "billing",
  "priority": "high",
  "score": 96,
  "summary": "The customer reports a duplicate subscription charge.",
  "tags": [
    "duplicate-charge",
    "subscription"
  ]
}

The same payload can be persisted, queued for another job, sent to a Vue frontend, or passed to another service.

A Laravel controller turning a customer support ticket into a typed JSON REST API response

Testing structured agents

Structured output also fits Laravel’s testing workflow.

The SDK can fake agent responses. For a structured agent, provide an array matching the schema:

TicketClassifier::fake([
    [
        'category' => 'technical',
        'priority' => 'high',
        'score' => 91,
        'summary' => 'The customer cannot access the dashboard.',
        'tags' => ['login', 'dashboard'],
    ],
]);

Then test the controller without calling an external provider:

$response = $this->postJson('/support-tickets/classify', [
    'message' => 'I cannot access my dashboard after signing in.',
]);

$response
    ->assertOk()
    ->assertJsonPath('category', 'technical')
    ->assertJsonPath('priority', 'high')
    ->assertJsonPath('score', 91);

You can also assert that the agent received the expected prompt. This keeps provider calls out of your test suite and makes classification behavior repeatable.

Build reliable AI features with Laravel

Structured outputs turn an LLM response into an application boundary.

The HasStructuredOutput contract defines the behavior. The JsonSchema builder defines the shape. The provider adapter handles translation. StructuredAgentResponse gives your PHP code array-style access.

That workflow is useful far beyond support tickets. You can extract invoice data, classify leads, normalize product catalogs, generate moderation decisions, or build REST endpoints powered by AI.

If you want to build rest api with php, structured outputs give your API a contract instead of a hopeful prompt. Combined with Laravel validation, Eloquent, queues, testing, and deployment tools, they provide a practical path from prototype to production.

Laravel’s AI tooling will continue to grow alongside the model ecosystem. The core principle remains stable: define the shape once, keep provider details behind a clean interface, and let your application work with data it can understand.

Previous
Optimistic UI in Inertia 3.x: Instant Feedback Without the Jank
Next
Real-Time Laravel + Vue + Inertia: Adding Live Updates with Reverb and Echo