The commit that replaced our support ticket classifier deleted 312 lines and added 96. Most of the deletion was HTTP plumbing I had written twice, in 2024 and again in 2025: a token, a timeout, a retry, a json_decode, and a guard clause for the days the model wrapped its JSON in prose.
Laravel 13 shipped on March 17th, 2026. We upgraded our internal ops and finance app in the last week of March, and I have spent the eleven weeks since then living with the part of the release everyone talks about: the first-party AI SDK. The app is Laravel, dispatch, support and finance use it daily, and a small group of us keeps it running. The customer-facing platform is Symfony and has been on Symfony 8 since February.
Ten minutes in the guide, six hours in practice
The upgrade guide estimates ten minutes, and that estimate is not dishonest. Laravel 13 has no headline breaking change: the high impact section is three items long, two of which are “update your dependencies” and “update the installer”.
What we actually changed in composer.json: laravel/framework to ^13.0, laravel/boost to ^2.0, laravel/tinker to ^3.0, phpunit/phpunit to ^12.0. PHP 8.3 is the new floor and 8.5 the ceiling. The ops app has been on 8.3 since last year, so that line was free; the Symfony platform sits on 8.4 for Symfony 8 reasons.
Six hours, spread over two afternoons. One hour on dependencies, two on the test suite under PHPUnit 12, and three on a single Artisan command that started failing at 02:00 the first night. The culprit is a low impact note in the guide: Container::call now respects nullable class parameter defaults instead of resolving the class. Our remittance command had this signature, and had had it since 2023:
public function handle(?Carbon $date = null): int
{
$window = $date->startOfDay();
// ...
}
On Laravel 12 method injection handed that parameter a Carbon instance. On 13 it is null, which is what the signature asks for, and the job died with Call to a member function startOfDay() on null. The fix is one line and the new behaviour is correct. I record it because “no breaking changes” and “nothing in your app changes” are different claims.
One rename to know before you grep for it in anger: the CSRF middleware is now PreventRequestForgery, and it also checks the Sec-Fetch-Site header. I read that middleware expecting to whitelist half our internal tooling. You do not need to: the origin check is an extra way to pass, not a new gate, unless you opt in with preventRequestForgery(originOnly: true).
Support windows, which move managers more than features do: Laravel 12 gets bug fixes until August 13th, 2026, eight weeks from now, and security fixes until February 2027. Laravel 13 gets bug fixes into Q3 2027 and security fixes until March 17th, 2028.
What the AI SDK actually hands you
The SDK is a separate package, composer require laravel/ai, plus vendor:publish and a migration that creates agent_conversations and agent_conversation_messages. Credentials live in config/ai.php or as environment keys, one per provider.
The unit of work is an agent: a class, generated by php artisan make:agent, implementing LaravelAiContractsAgent and using the Promptable trait. It holds the instructions, and optionally the conversation history, the tools and an output schema. Provider and model are arguments on prompt, or attributes on the class, and that is the whole provider-agnostic story: provider: Lab::Anthropic instead of Lab::OpenAI, no other edit. Pass an array of providers and the SDK fails over between them, but only on a FailoverableException: a rate limit, an overloaded provider, exhausted credits. A bad request does not fail over, correctly.
Tools are a second class, with description(), handle(Request $request) and schema(). Structured output is the HasStructuredOutput contract plus a schema(JsonSchema $schema) method, and the response is then array-accessible. stream() hands a route server-sent events; queue() moves the call off the request with then and catch closures. Images, speech, transcription, reranking and embeddings sit on the same documentation page.
What the announcements skip: the package is not 1.0. Its default branch is 0.x. The newest tag on the day Laravel 13 shipped was v0.3.1, and the newest as I write is v0.8.1, from June 10th. Seven minor releases in twelve weeks, and minor releases of a 0.x package are allowed to change behaviour. They do.
The 312 lines we deleted
Support triage is our first and so far only production use. Roughly four thousand tickets a month arrive from sellers and courier partners, and each needs a category and an urgency before a human sees it. The shape of what we had, written against the plain HTTP client:
// app/Support/Ai/TicketTriage.php, Laravel 12 era
public function classify(Ticket $ticket): array
{
$payload = Http::withToken(config('services.ai.key'))
->timeout(30)
->retry(3, 500)
->post(config('services.ai.url').'/chat/completions', [
'model' => config('services.ai.model'),
'messages' => [
['role' => 'system', 'content' => $this->instructions()],
['role' => 'user', 'content' => $ticket->body],
],
'response_format' => ['type' => 'json_object'],
])
->throw()
->json('choices.0.message.content');
$decoded = json_decode($payload, true);
if (! is_array($decoded) || ! isset($decoded['category'])) {
throw new TriageFailed("Unparseable triage payload for ticket {$ticket->id}");
}
return $decoded;
}
And here is what replaced it, minus the instructions string:
namespace AppAiAgents;
use IlluminateContractsJsonSchemaJsonSchema;
use LaravelAiAttributesStrict;
use LaravelAiAttributesTimeout;
use LaravelAiContractsAgent;
use LaravelAiContractsHasStructuredOutput;
use LaravelAiPromptable;
#[Strict]
#[Timeout(30)]
class TicketTriage implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): string
{
return 'You classify inbound courier support tickets for a shipment platform. ...';
}
public function schema(JsonSchema $schema): array
{
return [
'category' => $schema->string()->enum([
'delivery_delay',
'wrong_address',
'damaged_parcel',
'cod_remittance',
'pickup_request',
'other',
])->required(),
'urgency' => $schema->integer()->min(1)->max(5)->required(),
'needs_human' => $schema->boolean()->required(),
];
}
}
The call site inside our existing queued job is two lines, and the response reads like an array:
$triage = TicketTriage::make()->prompt($ticket->body);
$ticket->update([
'category' => $triage['category'],
'urgency' => $triage['urgency'],
'needs_human' => $triage['needs_human'],
]);
Our numbers, from our own traffic. Six hundred tickets labelled by hand by two support leads in February are still the yardstick. The old wrapper agreed with those labels 86.5 percent of the time; the SDK version, same provider and model and instructions, agrees 87.1 percent, which is inside the noise of a 600 row sample. Usage averages about 1,240 prompt tokens and 90 completion tokens per ticket, roughly 38 pesos per thousand tickets at the rates we pay. Request p95 moved from 2.4 to 2.6 seconds, and I cannot honestly explain those 200 milliseconds.
So quality did not move and cost did not move. The code moved. Retry logic, JSON parsing, the “did it return the enum value or a sentence” defence, the hand-rolled mock in tests: gone, replaced by a schema the provider enforces and an Agent::fake() call. A real win, and a smaller one than the launch posts imply.
The afternoon 0.7.0 cost us
On May 20th I bumped the SDK from v0.6.8 to v0.7.0 as part of routine dependency work and did not read the release notes closely. The next day our triage report showed 1.9 percent of tickets landing in other with a nonsense urgency, after six weeks of zero malformed payloads.
The cause is in that release: OpenAI strict mode became opt-in, via a Strict attribute. Before 0.7.0 structured output was sent strict; after it, unless you declare #[Strict] on the agent class, the provider is asked for JSON without schema enforcement and is free to improvise. Our own log line was the giveaway, because the model started returning values our enum did not contain:
# storage/logs/laravel.log
[2026-05-21 09:14:52] production.WARNING: triage.enum_miss
{"ticket_id":918342,"category":"billing dispute (COD)","urgency":"high"}
One attribute fixed it. The lesson is why I am careful about the framing of this release: the AI SDK is a 0.x package wearing a framework’s logo. We pin it now, read its release notes the way we read an UPGRADE.md, and rerun the 600 ticket sample after every bump. Building that last step took a day and is the most useful thing we did all quarter.
What the SDK leaves on your desk
Four things, and they are the four that decide whether an AI feature is safe to run.
- Evals. There is nothing here.
Agent::fake()tests your code against canned responses, which is a different question. Our labelled sample and the script that scores it are ours. - Cost in money. The response exposes
$response->usagewithpromptTokens,completionTokensand the cache read and write counts. Tokens, not currency. There is no price table, so the conversion, the monthly budget and the alert are yours. - Prompt versioning. Instructions are a PHP method, so they version with your code, which beats a database row nobody reviews. Nothing records which version of the instructions produced a given stored output. We added a column.
- Retries across an outage. Failover switches provider on a rate limit or an overload. It does not retry the same provider later, and switching provider mid-incident quietly changes your output distribution, which is when you least want that.
The token plumbing is at least easy to wire up, because every operation fires an event. We listen for one and write a row:
use LaravelAiEventsAgentPrompted;
Event::listen(function (AgentPrompted $event) {
AiCall::create([
'agent' => $event->prompt->agent::class,
'model' => $event->response->meta->model,
'prompt_tokens' => $event->response->usage->promptTokens,
'completion_tokens' => $event->response->usage->completionTokens,
]);
});
Multiply by a rate you keep in config and you have a cost dashboard in an afternoon. I would still rather the framework told me what a call cost.
Attributes, passkeys, and the vector search we did not turn on
Laravel 13’s other visible theme is PHP attributes standing in for class properties, across controllers, queues, Eloquent, events, notifications, validation and testing. We adopted one slice. Queue attributes replaced properties on our fourteen job classes, because #[Tries(5)] and #[Backoff([1, 5, 10])] above the class name read better than public $tries buried under a constructor. We skipped #[Middleware] and #[Authorize] on controllers: the route file is the map of this application, and I do not want to open eleven controllers to learn which endpoints require subscribed. Taste, not a technical objection, and I expect to lose that argument eventually.
Passkeys are real, but they are not a framework feature, which surprised me. WebAuthn lives in Fortify, which added it in v1.37.0 on April 28th, six weeks after Laravel 13 shipped, wrapping a laravel/passkeys server package that is itself at v0.2.1, with a @laravel/passkeys npm client for the browser ceremonies. Your user model implements PasskeyUser and uses PasskeyAuthenticatable. We have not shipped it: half our dispatch floor signs in on shared machines, and a passkey on a shared device is worse than our current password policy.
Vector search is the feature I most wanted and least used. The query builder gained whereVectorSimilarTo, the schema builder gained vector columns and vectorIndex, and if you pass a plain string instead of an embedding array, Laravel generates the embedding through the AI SDK. The similarity threshold defaults to 0.6.
Schema::create('kb_documents', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->vector('embedding', dimensions: 1536);
$table->vectorIndex('embedding');
$table->timestamps();
});
$hits = KbDocument::query()
->whereVectorSimilarTo('embedding', 'parcel stuck in Cebu hub', minSimilarity: 0.45)
->limit(10)
->get();
It works. I ran it over 1,800 knowledge base articles on a throwaway Postgres box and the results beat the LIKE search our support team currently suffers. Then it stayed on the throwaway box, because vector search needs PostgreSQL with pgvector and our data lives in MySQL. A second database for search has an operational tail I have not agreed to.
Does any of this belong in a framework?
My position after three months: the provider-agnostic API belongs, the model opinions do not.
A consistent way to call a text model, with the schema, events, fakes and queue integration wired into the framework’s own primitives, is what Laravel has always been good at. That part I keep. What makes me uneasy is UseCheapestModel and UseSmartestModel, where the model behind the attribute is picked by the package and changes when the package updates. The docs say so plainly and the changelog shows it happening: one provider’s default was bumped in 0.6.0. An attribute whose meaning changes on composer update is fine in a prototype and wrong in a job that touches money. We name our model.
The framework also ships an upgrade path through an AI assistant: install Boost 2, run a slash command. I did not use it. Reading the diff taught me the Container::call change, and a green checkmark would not have.
Next is the dispatcher timeline, where a streamed summary would genuinely help, and finding out whether our eval script is steady enough to gate a dependency bump in CI instead of being something I remember to run. I still owe the support team an answer about that second database.
Sources
- Laravel 13 release notes: release date of March 17th, 2026, PHP 8.3 minimum, support window dates, and the summary of the AI SDK, attributes, queue routing and vector search work.
- Laravel 13 upgrade guide: the ten minute estimate, the dependency version bumps, the
Container::callnullable defaults change, and thePreventRequestForgeryrename. - laravel/framework CHANGELOG for 13.x: confirms v13.0.0 on 2026-03-17, the PHP 8.3 minimum PR, the attributes work, and which point releases existed in June.
- Laravel AI SDK documentation: installation, agent classes, prompting, structured output, tools, streaming, queueing, failover semantics, usage fields and the event list.
- github.com/laravel/ai: the package is still on a
0.xbranch, with the attribute, contract and tool classes used in the samples above. - laravel/ai releases: the tag dates behind v0.3.1 on launch day and v0.8.1 in June, and the 0.7.0 note that made OpenAI strict mode opt-in via the
Strictattribute. - Laravel search documentation:
whereVectorSimilarTo, vector columns and indexes, automatic embedding of string queries, and the PostgreSQL with pgvector requirement. - Fortify documentation and the Fortify changelog: passkey support, the
PasskeyUsercontract andPasskeyAuthenticatabletrait, and the v1.37.0 release date of April 28th, 2026. - Queue documentation: the
Tries,BackoffandTimeoutjob attributes we adopted, alongside the controllerMiddlewareandAuthorizeattributes we skipped.