This week in Laravel & Inertia: Markdown mail fix, Oct 26, 2026

Laravel 12.70.1 patches Markdown mail escaping, Inertia Laravel 3.5.2 keeps your Vary header, and AI SDK 0.11.3 adds conversation pages.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/this-week-in-laravel-inertia-2026-10-26.md
Laravel 12.70.1 patches Markdown mail escaping, Inertia Laravel 3.5.2 keeps your Vary header, and AI SDK 0.11.3 adds conversation pages.
Show the diff. Do not commit.
On this page · 9 sections
  1. The week at a glance
  2. Laravel 12.70.1: Markdown mail escaping fix
  3. Inertia Laravel 3.5.2: Vary, downloads and SSR
  4. Your Vary header survives Inertia
  5. Downloads and “0” no longer become redirects
  6. SSR tests render the right page
  7. laravel/ai 0.11.3: backports for the 0.x line
  8. A 100 MB limit on remote files
  9. Page through a stored conversation
  10. Everything else in 0.11.3
  11. Upgrade notes
  12. Recap
  13. FAQ
  14. Next steps
  15. Sources

A new user signs up with the name Taylor](https://evil.test). Your welcome email says “Hi Taylor”, and the link under that name now goes to a site you’ve never seen.

That one is fixed on Laravel 12 this week. So is a CDN serving your French page to German visitors, and a CSV export that turned into a redirect. Here’s everything that shipped in the Laravel and Inertia world since last Monday, in one short read.

TL;DR

  • Laravel 12.70.1 is a security fix for Markdown mail. With secured encoding on, user input could still close a Markdown link and point it somewhere else. Laravel 13 got the same fix in 13.36.0.
  • Inertia Laravel 3.5.2 adds X-Inertia to your Vary header instead of replacing it, stops turning streamed downloads and "0" bodies into redirects, and resets the cached SSR page between requests.
  • laravel/ai 0.11.3 backports the 100 MB remote file limit (ai.remote_files.max_size, in KB), conversation pagination, ownership and pending-approval checks, and OpenRouter reranking and audio to the 0.x line.

What you'll learn

  • Why Laravel 12 apps that send Markdown mail should update today
  • What Inertia Laravel 3.5.2 fixes for CDNs, downloads and SSR tests
  • How to page through a stored AI conversation on laravel/ai 0.x

Who it's for: Laravel + Vue developers on Laravel 12, on Inertia 3 behind a CDN, or on laravel/ai 0.x

Tested with: Laravel 12.70.0 → 12.70.1 and 13.38 · inertia-laravel 3.5.1 → 3.5.2 · laravel/ai 0.11.3 · PHP 8.4 · Pest 4 (11 tests / 30 assertions on 12.70.1; the mail and Inertia tests fail on 12.70.0 and 3.5.1)

Applies to: The SSR cache fix (#924), OpenRouter reranking and audio, and the token-count fixes were checked against their PRs, not run

The week at a glance#

PackageVersionReleased (UTC)Should you hurry?
laravel/framework (12.x line)12.70.1Oct 21Yes, if you send Markdown mail with user input
inertiajs/inertia-laravel3.5.2Oct 21Yes, if a CDN caches your pages or you return downloads
laravel/ai (0.x line)0.11.3Oct 19Yes, if you accept remote files on 0.x

Heads up: On Laravel 13 already?

The Markdown mail fix (#61755) shipped in Laravel 13.36.0 on October 13. If you’re on 13.36 or later, you have it. The newest 13.x release has its own post: Laravel 13.38: no more stale reads after a write.

Laravel 12.70.1: Markdown mail escaping fix#

Markdown mail has an opt-in mode called secured encoding. You turn it on with Markdown::withSecuredEncoding(), and every {{ $value }} in a Markdown mail view gets its Markdown characters escaped, so user input stays text.

It escaped [ but not ] or \. So a value could close the link it sat in and open a new one. 12.70.1 escapes both, the same change Laravel 13 got in 13.36.0.

Here’s a normal welcome mail with the user’s name inside a link:

resources/views/mail/welcome.blade.phpblade
<x-mail::message>
Hi [{{ $name }}](https://shop.test/account)

Thanks for signing up.
</x-mail::message>
Name the user typedLink on 12.70.0Link on 12.70.1
Taylorhttps://shop.test/accounthttps://shop.test/account
Taylor](https://evil.test)https://evil.testhttps://shop.test/account, text shown as typed
Taylor\](https://evil.test)https://shop.test/accounthttps://shop.test/account, text shown as typed

We rendered that mailable on both versions with secured encoding on. Only 12.70.1 kept the link where you put it.

bash
composer update laravel/framework
php artisan --version   # Laravel Framework 12.70.1
php artisan view:clear

Gotcha: The fix only helps if secured encoding is on

Secured encoding is off by default. With it off, a name like Taylor](https://evil.test) still turns into a link on every version, because Markdown mail treats your data as Markdown. If user input ends up in Markdown mail, turn it on once in AppServiceProvider::boot(). HTML in the input is still escaped either way.

app/Providers/AppServiceProvider.phpphp
<?php

namespace App\Providers;

use Illuminate\Mail\Markdown;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Markdown::withSecuredEncoding();
    }
}

Prove it in your own test suite

tests/Feature/WelcomeMailTest.phpphp
use App\Mail\Welcome;
use Illuminate\Mail\Markdown;

test('a name cannot rewrite the link in the welcome mail', function (string $name) {
    Markdown::withSecuredEncoding();

    expect((new Welcome($name))->render())
        ->toContain('href="https://shop.test/account"')
        ->not->toContain('href="https://evil.test');
})->with([
    'closing bracket' => 'Taylor](https://evil.test)',
    'escaped bracket' => 'Taylor\](https://evil.test)',
]);

On 12.70.0 the first case fails. On 12.70.1 and 13.36+ both pass.

Inertia Laravel 3.5.2: Vary, downloads and SSR#

Three bug fixes in the server adapter. No changes on the Vue side.

Your Vary header survives Inertia#

Inertia adds Vary: X-Inertia so a cache never serves the JSON page to a full page load, or the other way round. Until 3.5.2 it set the header, which wiped any Vary your app had already added.

That hurts on localized pages. You add Vary: Accept-Language, Inertia overwrites it, and your CDN caches one language for everyone. Now Inertia appends:

app/Http/Controllers/PricingController.phpphp
use Illuminate\Http\Request;
use Inertia\Inertia;

public function __invoke(Request $request)
{
    return Inertia::render('Pricing')
        ->toResponse($request)
        ->setVary('Accept-Language');
}
VersionVary header sent
3.5.1X-Inertia
3.5.2Accept-Language, X-Inertia

This matters when the header is set inside Inertia’s middleware: in the controller, or in a middleware that runs after HandleInertiaRequests. A middleware that wraps Inertia’s was never overwritten. X-Inertia is not added twice if you already set it.

Tip: Purge the CDN once

After you deploy 3.5.2, purge the cached pages that should vary by language or another header. Entries cached before the update were stored without that header in Vary.

Downloads and “0” no longer become redirects#

On an Inertia request, an empty 200 response is turned into a redirect back (the onEmptyResponse hook). The check used PHP’s empty(), which also matched two responses that aren’t empty:

  • a streamed or file download (response()->streamDownload(), response()->download(), Storage::download()), whose content is false
  • a body of "0"

Both came back as a 302. Now only a body that is exactly '' counts as empty.

3.5.1: a 302 back to /orders, then 3.5.2: the CSV:

routes/web.phpphp
Route::get('/orders/export', fn () => response()->streamDownload(
    fn () => print('id,total'), 'orders.csv'
));
// GET /orders/export with X-Inertia: true → 302 Found
routes/web.phpphp
Route::get('/orders/export', fn () => response()->streamDownload(
    fn () => print('id,total'), 'orders.csv'
));
// GET /orders/export with X-Inertia: true → 200, orders.csv

Controllers that return nothing still redirect back, as before.

SSR tests render the right page#

The SSR state cached the first server-rendered response so the head and body don’t call the SSR server twice. It never cleared that cache when the page changed. In feature or browser tests that make several requests in one test, every later page got the first page’s HTML. 3.5.2 resets it when the page changes. In production each request is fresh, so this mostly fixes confusing tests. More on SSR in Server-render a Laravel and Vue Inertia app.

bash
composer update inertiajs/inertia-laravel
composer show inertiajs/inertia-laravel | grep versions   # * v3.5.2

laravel/ai 0.11.3: backports for the 0.x line#

The 0.x line still gets fixes while 1.x moves on. If you haven’t moved to 1.x yet, this release brings a lot of what 1.x users already have.

A 100 MB limit on remote files#

Image::fromUrl(), Document::fromUrl() and the other remote files used to download the whole file with no limit, so a huge file filled worker memory. Now the download stops at 100 MB and throws an InvalidArgumentException that names the limit. A file whose Content-Length is too big fails before the body downloads.

The key is the same as on 1.3, ai.remote_files.max_size, in kilobytes. It isn’t in the published config, so add it yourself:

config/ai.phpphp
'remote_files' => [
    'max_size' => 25 * 1024, // kilobytes, so 25 MB
],

The full story, with the 1.x details, is in Laravel AI SDK 1.3: typed structured output and file limits.

Page through a stored conversation#

The conversation store could hand the model its latest messages, but without IDs, timestamps or tool state. To show a chat history you had to query the ConversationMessage model yourself.

0.11.3 adds three opt-in contracts, and the default database store implements all of them:

ContractMethodGives you
PaginatesConversationspaginateConversationMessages($id, perPage: 15)A cursor paginator of StoredMessage, newest first
VerifiesConversationOwnershipconversationBelongsTo($id, $type, $key)true if the conversation is that participant’s
ResolvesPendingApprovalspendingApprovalsFor($id)The tool calls a paused turn is waiting on, as PendingApproval

The paginator uses a cursor, not page numbers, because a chat grows while you read it. It plugs straight into Inertia’s infinite scroll:

app/Http/Controllers/ChatController.phpphp
use Illuminate\Http\Request;
use Inertia\Inertia;
use Laravel\Ai\Contracts\ConversationStore;

public function show(Request $request, string $conversation, ConversationStore $store)
{
    $user = $request->user();

    abort_unless($store->conversationBelongsTo($conversation, $user->getMorphClass(), $user->getKey()), 404);

    return Inertia::render('Chat/Show', [
        'messages' => Inertia::scroll(fn () => $store->paginateConversationMessages($conversation, perPage: 20)),
    ]);
}
resources/js/pages/Chat/Show.vuevue
<script setup lang="ts">
import { InfiniteScroll } from '@inertiajs/vue3'

defineProps<{
    messages: { data: { id: string; role: string; content: string }[] }
}>()
</script>

<template>
    <InfiniteScroll data="messages" reverse>
        <p v-for="message in messages.data" :key="message.id" :class="message.role">
            {{ message.content }}
        </p>
    </InfiniteScroll>
</template>

In our test, another user got a 404, and the owner got the newest messages first with a nextPage cursor in scrollProps. Each StoredMessage also has created_at, usage, meta, tool_calls, tool_results and attachments.

Gotcha: Custom conversation stores

The contracts are opt-in. If you bound your own ConversationStore, these methods don’t exist until you implement the contracts. Check with instanceof PaginatesConversations before you call them.

If you build tool approval flows, pendingApprovalsFor() gives a reloaded page the same calls the paused response had. The flow itself is in Approve an AI SDK tool before it runs.

Everything else in 0.11.3#

ChangePRWho it affects
Streamed answers keep their sources in meta.citations#991You stream with web search and reload the conversation later
Streamed text is joined per step, not per message ID#987Anthropic answers with citations were stored as one paragraph per fragment
OpenRouter reranking (default model cohere/rerank-v3.5)#999You rerank and want to use OpenRouter
OpenRouter audio input#998, #1126You send audio attachments through OpenRouter
Token counts: cached tokens left out on Groq and OpenAI-compatible, cumulative usage on Anthropic, Cohere Bedrock embeddings#909, #996, #914You track usage or cost
OpenAI image edits accept every storable image type#893You edit images on OpenAI
Gemini file import, xAI and OpenRouter document file names, MCP structured output slashes#821, #1001, #997Those providers or MCP tools
bash
composer update laravel/ai
composer show laravel/ai | grep versions   # * v0.11.3

Upgrade notes#

  • Laravel 12: update to 12.70.1, then turn on Markdown::withSecuredEncoding() if user input reaches Markdown mail. Run php artisan view:clear after the update.
  • Inertia Laravel: no code changes. Purge CDN pages that should vary by another header, and check any code that relied on downloads redirecting (it shouldn’t have).
  • laravel/ai 0.x: remote files over 100 MB now throw. Set ai.remote_files.max_size (kilobytes) if you need more. The new conversation contracts are opt-in.
  • Then run your tests. Nothing else breaks this week.

Recap#

  • Laravel 12.70.1 escapes ] and \ in secured Markdown mail, so user input can’t move your links. 13.x has had it since 13.36.0.
  • Inertia Laravel 3.5.2 appends to Vary, lets downloads and "0" through, and resets the cached SSR page.
  • laravel/ai 0.11.3 gives 0.x the 100 MB remote file limit, conversation pagination with an ownership check, and OpenRouter reranking and audio.

FAQ#

I never called Markdown::withSecuredEncoding(). Does 12.70.1 matter to me?

The fix is inside secured encoding, so on its own it changes nothing for you. Without secured encoding, user input in Markdown mail can still form links on every version. Update, and turn it on if user data reaches Markdown mail.

Which Laravel 13 version has the Markdown mail fix?

Laravel 13.36.0, released on October 13, 2026 (PR #61755). 12.70.1 is the backport of that PR.

Why did my Inertia CSV export redirect back instead of downloading?

Before inertia-laravel 3.5.2, a streamed or file download on an Inertia request counted as an empty response and was turned into a redirect back. Update to 3.5.2.

Should I move from laravel/ai 0.x to 1.x?

When you can. 0.x still gets backports like these, but typed structured output and newer features are 1.x only. Both support Laravel 12 and 13.

Is the remote file limit the same on 0.x and 1.x?

Yes. Same key, ai.remote_files.max_size, in kilobytes, with a default of 102400 (100 MB).

Next steps#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.