Laravel 13: JSON:API Support, AI SDK & PHP Attribute Syntax

TL;DR: Laravel 13 ships first-party JsonApiResource for spec-compliant API responses (sparse fieldsets, includes, relationships — no third-party package required for the response shape), and a first-party AI SDK (laravel/ai) with a single, provider-agnostic interface for OpenAI, Anthropic, Gemini, and a dozen others. Both features lean on the same underlying shift: Laravel 13 uses PHP attributes for configuration that used to live in method overrides or array properties, and once you've seen the pattern in one place, you start recognizing it everywhere in the framework.
JSON:API Resources, Without a Package
If you've built a spec-compliant JSON:API response by hand before, you know the tedious part isn't the data — it's the structure: a type and id per resource, attributes nested under attributes, relationships as resource identifier objects, an included array for anything the client asked to embed, and the exact application/vnd.api+json content type. Laravel 13's JsonApiResource handles all of that for you:
php artisan make:resource PostResource --json-api<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;
class PostResource extends JsonApiResource
{
public $attributes = [
'title',
'body',
'created_at',
];
public $relationships = [
'author',
'comments',
];
}Return it exactly like any other resource — from a route, a controller, or the model's own toResource() convenience method:
Route::get('/api/posts/{post}', function (Post $post) {
return $post->toResource();
});That produces a genuinely spec-compliant response, with no extra configuration:
{
"data": {
"id": "1",
"type": "posts",
"attributes": {
"title": "Hello World",
"body": "This is my first post."
}
}
}The resource's type is derived from the class name automatically (PostResource → posts, BlogPostResource → blog-posts), and its id from the model's primary key — override toType()/toId() on the rare occasion the default doesn't fit, e.g. an AuthorResource wrapping a User model that should still say "type": "authors".
Relationships Only Load When a Client Asks
List relationship names in $relationships and Laravel resolves the Eloquent relation and its resource class automatically. They stay out of the response entirely unless the client requests them:
GET /api/posts/1?include=author,comments...which nests resource identifiers under relationships and puts the full resource objects in a top-level included array — the standard JSON:API pattern for avoiding duplicate data when the same related resource is referenced from multiple places:
{
"data": {
"id": "1",
"type": "posts",
"attributes": { "title": "Hello World" },
"relationships": {
"author": { "data": { "id": "1", "type": "users" } },
"comments": { "data": [{ "id": "1", "type": "comments" }] }
}
},
"included": [
{ "id": "1", "type": "users", "attributes": { "name": "Taylor Otwell" } },
{ "id": "1", "type": "comments", "attributes": { "body": "Great post!" } }
]
}Nested includes work with dot notation (?include=comments.author), and sparse fieldsets — letting a client ask for only specific attributes per resource type — work the same way, entirely from the query string:
GET /api/posts?fields[posts]=title,created_at&fields[users]=nameAdd toLinks()/toMeta() overrides when a resource needs a self link or extra metadata, and reach for Spatie's Laravel Query Builder if you also need to parse incoming filter/sort query parameters — Laravel's own JSON:API support covers the response shape, not query parsing.
The AI SDK: One Interface, a Dozen Providers
Laravel's AI SDK — previously a beta package — is now a first-party, production-stable part of the framework, installed like anything else:
composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrateThe quickest way in is a plain function call — no class required for a one-off prompt:
use function Laravel\Ai\agent;
$response = agent(
instructions: 'You are a helpful assistant.',
)->prompt('Tell me about Laravel');
return (string) $response;For anything you'll reuse, a named agent class is the real building block — implement Agent, pull in the Promptable trait, and define the system instructions once:
<?php
namespace App\Ai\Agents;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
use Stringable;
class SalesCoach implements Agent
{
use Promptable;
public function instructions(): Stringable|string
{
return 'You are a sales coach analyzing call transcripts.';
}
}$response = (new SalesCoach)->prompt('Analyze this transcript: ...');Provider Switching Without Touching Application Code
The whole point of a provider-agnostic SDK is that swapping OpenAI for Anthropic — or routing to a cheap model for simple work and an expensive one for hard work — is a config or parameter change, not a rewrite:
use Laravel\Ai\Enums\Lab;
$response = (new SalesCoach)->prompt(
'Analyze this transcript...',
provider: Lab::Anthropic,
model: 'claude-sonnet-5',
);Model and provider selection can also live as attributes directly on the agent class — declarative, and visible at a glance without reading the whole class body:
use Laravel\Ai\Attributes\{Model, Provider, MaxTokens, Temperature, UseSmartestModel};
use Laravel\Ai\Enums\Lab;
#[Provider(Lab::Anthropic)]
#[Model('claude-sonnet-5')]
#[MaxTokens(4096)]
#[Temperature(0.7)]
class SalesCoach implements Agent
{
use Promptable;
// ...
}
// Or skip picking a model entirely and let the provider decide:
#[UseSmartestModel]
class ComplexReasoner implements Agent
{
use Promptable;
}Structured Output, Not Just Free Text
Implementing HasStructuredOutput alongside Agent turns a free-text model response into a validated, typed array — genuinely useful the moment you want to store or branch on a model's answer rather than just display it:
<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\{Agent, HasStructuredOutput};
use Laravel\Ai\Promptable;
class SalesCoach implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): string
{
return 'You are a sales coach analyzing call transcripts.';
}
public function schema(JsonSchema $schema): array
{
return [
'feedback' => $schema->string()->required(),
'score' => $schema->integer()->min(1)->max(10)->required(),
];
}
}$response = (new SalesCoach)->prompt('Analyze this transcript...');
$response['score']; // int, guaranteed 1-10
$response['feedback']; // stringVector Search, Backed by Eloquent
The SDK also generates embeddings and stores them via a real Eloquent column type, so semantic search reads like any other query rather than a separate vector-database integration:
Schema::create('documents', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->vector('embedding', dimensions: 1536)->index(); // HNSW index
$table->timestamps();
});use App\Models\Document;
// Laravel generates the query embedding for you from the plain string
$documents = Document::query()
->whereVectorSimilarTo('embedding', 'best wineries in Napa Valley')
->limit(10)
->get();The Thread Connecting Both Features: Attributes as Configuration
Neither of these features introduced PHP attributes to Laravel — they've been creeping into the framework for a few major versions now (model casts, route binding, validation rules). What Laravel 13 does is commit to the pattern much more broadly: #[UseResource] to point a model at a non-conventional resource class, #[PreserveKeys] to stop a collection from renumbering its keys, a full set of queue-job attributes for retry/backoff/timeout behavior, and now the AI SDK's #[Provider]/#[Model]/#[MaxTokens] stack sitting right above the class it configures.
The practical upshot: when you're reading someone else's Laravel 13 code and see a stack of #[...] lines above a class declaration, that's not decoration — it's the class's actual configuration, readable without opening the class body at all. It's worth getting comfortable with, because it's clearly the direction the framework is heading, not a one-off feature confined to a single package.
Your Turn
Have you actually shipped anything with the AI SDK or the new JSON:API resources yet? I'd like to hear what tripped you up first — picking a provider, structuring the schema, or something in the attribute config? Drop it in the comments.
Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.
Was this post helpful?
Reviews & Ratings
Sign in to leave a review.



