Якщо ти не оновлював свій сайт на Laravel — у тебе можуть бути проблеми. Token-гард + MySQL і версія нижче 12.68/13.29 = чужий вхід без токена. Фікс: composer update laravel/framework.
github.com/laravel/framework/security/advisories/…
1 like
Laravel'de CVE-2026-114509 numaralı kritik bir güvenlik açığı tespit edildi!
Bu açık sayesinde kimliği doğrulanmamış bir saldırgan, başka bir kullanıcının API token'ına sahip olmadan o kullanıcıymış gibi kimlik doğrulaması yapabiliyor!
Belirli MySQL/MariaDB yapılandırmalarında, TokenGuard tarafından korunan API endpoint'leri üzerinden başka bir kullanıcının hesabına yetkisiz erişim sağlanabiliyor...
Önem derecesi: Critical (CVSS v4.0: 9.2)
Bu güvenlik açığı Laravel 12.68.0 ve 13.29.0 sürümlerinde giderildi... Etkilenen sistemlerin güncellenmesi öneriliyor!
Detaylar için:
- nvd.nist.gov
- github.com/laravel/framework/security/advisories/…
1 point
Laravel 13 の APP_DEBUG=true は Cookie・本文・SQL を伏せずに出す。本番に残りやすい 4 つの設定を動かして確かめた
新しく作った Laravel 13 のアプリで、本番に残りやすい 4 つの設定を変えながら動かして確かめました。
- APP_DEBUG=true の 500 のエラーページには、Cookie・Authorization を含むリクエストヘッダー、password を含む本文、値を埋め込んだ SQL が伏せずに出ます。
- config/cors.php で allowed_origins を ['*'] のまま supports_credentials を true にすると、どのオリジンから来ても、そのオリジンを cookie 付きで許可します。
- trustProxies(at: '*') にすると、どのクライアントからでも X-Forwarded-For・-Host・-Proto を信じます。
- Telescope は APP_ENV=local のとき、ログインしていなくてもダッシュボードを開けます。
どれも開発中は困らない 1 行の設定なので、本番に残りやすいところです。本番の .env を作る人、またはレビューする人向けに、動かした結果と本番で確認する値をまとめました。
composer create-project laravel/laravel で作った直後の .env は APP_ENV=local、APP_DEBUG=true、LOG_LEVEL=debug という開発用の値です。PHP 全般の本番のエラー表示については 本番のエラー表示で情報を漏らさない7設定 が php.ini・nginx・Apache・WordPress の設定をまとめているので、この記事は Laravel 13 に絞って、実際に動かした結果を扱います。同じ設定を .env や config/*.php から見つける CLI と検証のワークフローは OTA-EITA/laravel-prod-guard に置いてあります。
対象と前提
- laravel/laravel v13.11.0(skeleton)、laravel/framework 13.38.0、laravel/telescope 5.26.0、fruitcake/php-cors v1.4.0
- PHP 8.4、php artisan serve
- 2026 年 10 月 29 日に、GitHub Actions の ubuntu-24.04 ランナー(使い捨て)で確認
リクエストに入れた Cookie・トークン・パスワードの値は、検証用に作った目印の文字列です。
本番の env で確認すること
先に、確かめた結果から決めた本番の値を載せます。理由は後ろの節にあります。
| 設定 | 本番の値 |
|---|---|
| APP_ENV | production |
| APP_DEBUG | false |
| APP_KEY | php artisan key:generate --show で作った値。.env.example には書かない |
| SESSION_SECURE_COOKIE | true(HTTPS で配信している場合) |
| LOG_LEVEL | info 以上 |
| config/cors.php | credentials を使うなら allowed_origins に自分のフロントエンドのオリジンだけを書く |
| trustProxies | ロードバランサーのアドレスを書く。* にするなら、アプリに直接つながる経路をふさぐ |
APP_DEBUG=true は、ヘッダー・本文・SQL を伏せずに出す
途中で例外を投げるルートを1つ足し、そこに Cookie・Authorization ヘッダー・パスワードを付けて POST しました。ルートの中では、バインド値つきの SQL を1本だけ流しています。
Route::post('/lab/fail', function () {
DB::select('select ? as probe', ['bound-value-7d1c']);
throw new RuntimeException('lab failure');
});
curl -X POST http://127.0.0.1:8000/lab/fail \
-H "Cookie: laravel_session=cookie-secret-5e2a" \
-H "Authorization: Bearer token-secret-91fb" \
--data-urlencode "email=lab@example.com" --data-urlencode "password=pass-secret-c3d8"
APP_ENV=production、APP_DEBUG=true で返ってきた 500 のページの一部です。
!Laravel 13 のエラーページ。Queries にバインド値を埋め込んだ SQL、Headers に Cookie と Authorization、Body に password がそのまま出ている
ソースを読むと、そのとおりの作りです(Exception.php L213-293)。
- ヘッダーは $request->headers->all() をそのまま並べる
- 本文は $request->all() をそのまま JSON にする
- SQL は、バインド値を ? に埋め込んで表示する
伏せる処理はありません。このエラーページは Laravel 11 の途中で framework に入ったものです(#51261、2024 年 5 月 27 日にマージ)。
APP_DEBUG=false にして同じリクエストを送ると、500 のページにはどの目印も出ませんでした。
何が問題になるのか
見えるのは、そのリクエストを送った本人の Cookie やパスワードです。他人のセッションが見えるわけではありません。問題になるのは次のようなものです。
- 内部の情報: スタックトレース、コードの断片、サーバー上のパス、テーブル名と SQL。攻撃の下調べにそのまま使えます。
- 途中で足されたヘッダー: ロードバランサーや API ゲートウェイが、内部向けの認証トークンやユーザー ID をヘッダーに足してアプリに渡している構成があります。クライアントは本来それを見られませんが、エラーページには並びます。
- SQL に入る他人のデータ: 例外の前に、ほかのユーザーのデータを条件に入れた SQL を流していれば、その値も出ます。
credentials を有効にした CORS は、どのオリジンも cookie 付きで許可する
Laravel 13 の skeleton には config/cors.php がなく、framework の既定値が使われます。既定は allowed_origins が ['*']、supports_credentials が false です(config/cors.php L22-32)。
php artisan config:publish cors で config/cors.php を出し、supports_credentials だけを true に書き換えて、api/* の下のルートに次のリクエストを送りました。
curl -s -o /dev/null -D - -H 'Origin: https://evil.example' http://127.0.0.1:8000/api/lab
| 設定 | Access-Control-Allow-Origin | Access-Control-Allow-Credentials |
|---|---|---|
| 既定 | * | なし |
| allowed_origins ['*'] + supports_credentials true | https://evil.example | true |
ブラウザは * と credentials の組み合わせを受け付けません。そのため、supports_credentials を true にしたとき、Laravel が使っている fruitcake/php-cors は、送られてきた Origin をそのまま返します(CorsService.php L204-219)。CORS としては、どのオリジンにも cookie 付きのレスポンスを読ませる設定です。
実際に cookie が付くかは、cookie の SameSite で決まります。Laravel のセッション cookie は既定で SameSite=Lax なので、仕様どおりなら、まったく別のサイトからの fetch には付きません。同じサイトの別オリジン(ほかのサブドメインなど)からのリクエストや、SESSION_SAME_SITE=none にしている場合は付きます。ここはブラウザでは試していません。
SPA のために「とりあえず credentials を有効にする」と、ここに当たります。
trustProxies('*') だと、クライアントが IP とホストを名乗れる
Laravel 11 以降は、bootstrap/app.php で信頼するプロキシを決めます。
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*');
})
アプリが認識するクライアントの情報を返すルートを足して、X-Forwarded-* を付けて呼びました。
Route::get('/lab/client', fn (Illuminate\Http\Request $r) => [
'ip' => $r->ip(), 'secure' => $r->isSecure(), 'host' => $r->getHost(),
]);
curl -s -H 'X-Forwarded-For: 203.0.113.9' -H 'X-Forwarded-Host: evil.example' \
-H 'X-Forwarded-Proto: https' http://127.0.0.1:8000/lab/client
| 設定 | $request->ip() | isSecure() | getHost() |
|---|---|---|---|
| 既定(何も書かない) | 127.0.0.1 | false | 127.0.0.1 |
| trustProxies(at: '*') | 203.0.113.9 | true | evil.example |
* を指定すると、信頼するプロキシが 0.0.0.0/0 と ::/0、つまりすべてのアドレスになります(TrustProxies.php L84-86、L121-124)。ロードバランサーの後ろにしか置かないなら問題ありません。ただ、アプリのポートに直接つなげる経路が残っていると、クライアントが IP アドレスとホスト名を自由に名乗れます。レート制限や監査ログの IP、メールのリンクに使う URL が、その値になります。
APP_ENV=local の Telescope は、ログインなしで開ける
Telescope のダッシュボードは、APP_ENV が local のときは認可のゲートを通りません(TelescopeApplicationServiceProvider.php L29-32)。
| APP_ENV | ログインせずに /telescope |
|---|---|
| local | 200 |
| production | 403 |
Telescope は、リクエストのヘッダーや本文、SQL、ジョブ、例外を記録して一覧で見せるツールです。APP_ENV=local のまま公開すると、ほかのユーザーのリクエストも含めて誰でも見られます。本人のデータに限られる APP_DEBUG のエラーページより、影響が大きいです。
Telescope は、Authorization と php-auth-pw のヘッダー、password・password_confirmation のパラメーターを既定で伏せます。一方、Cookie ヘッダーは伏せる対象に入っていません(Telescope.php L83-96)。記録されたリクエストから、ほかのユーザーのセッション cookie が見えることになります。エラーページは何も伏せず、Telescope は一部を伏せる、という違いもありました。
設定から見つける
ここまでの設定は、どれもファイルを読めば分かります。laravel-prod-guard は、.env、.env.example、composer.json、config/*.php、bootstrap/app.php、public/ を読むだけで、アプリのコードは実行しません。
git clone https://github.com/OTA-EITA/laravel-prod-guard.git
php laravel-prod-guard/bin/laravel-prod-guard path/to/app --env path/to/app/.env.production --fail-on medium
実行時の依存はないので、clone したディレクトリの bin/laravel-prod-guard をそのまま PHP 8.2 以上で動かせます。
新しく作ったアプリにそのまま使うと、次のようになります。
HIGH LPG001 .env:4 APP_DEBUG is true
MEDIUM LPG002 .env:2 APP_ENV is "local"
MEDIUM LPG009 config/session.php:172 SESSION_SECURE_COOKIE is not set
LOW LPG012 .env:21 LOG_LEVEL is debug
1 app(s), 4 finding(s): 1 high, 2 medium, 1 low
開発用の .env としてはどれも正しい値です。本番用の env ファイルを --env で渡して、これが残っていないかを CI で確かめる使い方を想定しています。
設定の値は、env ファイル → アプリが公開した config/*.php に書かれた既定値 → framework の既定値、の順に決めます。config/*.php は PHP として実行せず、'key' => env('NAME', default) の形を読み取ります。
見ている項目は全部で 17 個で、--list-checks で一覧が出ます。ここまでの 4 つのほかに、次のようなものを見ます。
- APP_KEY が空・長さ違い、または .env.example に書かれている
- .env が git で追跡されている
- Debugbar・Clockwork・Ignition が require-dev ではなく require にある
- セッション cookie の Secure・HttpOnly・SameSite=None
- APP_URL が http
- public/ の下の .env、.git、phpinfo() を呼ぶファイル
- DB のパスワードが空、またはよくある値
各項目には、根拠になる Laravel 13 のソースの行へのリンクを付けました。
確かめていないこと
- Telescope に記録されたほかのユーザーの cookie で、実際になりすませるか
- CORS の設定で、ブラウザから実際に cookie が付くか。SameSite の話はソースと仕様から書いています
- Horizon。ソースでは Telescope と同じく local のとき認可を通りません(Horizon.php L81-86)が、動かしてはいません
- php artisan serve 以外の、nginx や PHP-FPM の前段がある構成
参考
- laravel/laravel v13.11.0 .env.example
- laravel/framework v13.38.0 Exceptions/Renderer/Exception.php
- laravel/framework #51261 Improves errors
- fruitcake/php-cors v1.4.0 CorsService.php
- laravel/telescope v5.26.0 TelescopeApplicationServiceProvider.php
- laravel/horizon v5.50.1 Horizon.php
- OTA-EITA/laravel-prod-guard
1 like
Laravel's Built-In JSON:API Resources vs timacdonald/json-api vs laravel-json-api: Which One Your API Needs
> Originally published at hafiz.dev
---
On Laravel 13.38.0, a request for 20 posts with ?include=author,comments ran 41 queries through the framework's own JSON:API resources. The fix is merged into the 13.x branch. It just isn't in a release yet.
I found that while building the same small API three ways: with Laravel's built-in JsonApiResource, with timacdonald/json-api, and with laravel-json-api/laravel. Same three models (users, posts, comments), same 20 posts and 100 comments, on a clean Laravel 13.38.0 project with SQLite. Each request went through a small middleware that counts queries. All output below is real.
Two of the three look almost the same from the outside. The third is a different kind of tool. One question tells you which you need. (If you're still deciding how the API itself should behave, my production API practices post is the place to start.)
Check your version first
If you already use the built-in resources, three things depend on your Laravel version.
GHSA-5wcm-3v2w-8362. A moderate advisory, published on 19 October. A crafted request to a read-only endpoint could trigger a destructive operation on a related table through relationship inclusion. It affects 12.45.0 up to 12.68.0, and every 13.x release before 13.27.0. Fixed in 12.68.0 and 13.27.0.
Nested includes from 13.27 to 13.36. Release 13.27.0 also changed how resources load relationships (#61322). From then on, nested includes ran one query per related model until #62000 fixed it in 13.37.0. I installed each version and ran the same request, a user with 20 posts and 5 comments per post:
GET /api/builtin/users/1?include=posts.comments.author
13.26.0 4 queries
13.36.0 122 queries
13.38.0 4 queries
Collection includes, on every release so far. #62000 fixed single resources. Collections still load each item's includes one model at a time. This one predates the regression. Issue #61252 reported 101 queries for 100 records on 13.24. The fix is #62152, merged on 22 October, two days after 13.38.0 shipped:
13.26.0 13.36.0 13.38.0 13.x-dev
GET /posts?include=author 21 21 21 2
GET /posts?include=author,comments 41 41 41 3
Until the next tag, eager-load whatever the client asked for. The resource skips relations that are already loaded:
Route::get('/posts', function (Request $request) {
$includes = array_intersect(
explode(',', $request->query('include', '')),
['author', 'comments', 'comments.author'],
);
return PostResource::collection(Post::with($includes)->get());
});
GET /api/builtin/eager/posts?include=author 2 queries
GET /api/builtin/eager/posts?include=author,comments 3 queries
I diffed the response bodies against the version without with(), and they were identical. Keep the allow-list. Eloquent resolves each name passed to with() by calling it as a method on a fresh model instance, so a raw include string lets the client choose which method runs. (If you want the wider picture on spotting N+1 queries, my query optimization post walks through it.)
The built-in JsonApiResource
It arrived in Laravel 12.45.0 (#57571) and is documented in the API resources docs. php artisan make:resource PostResource --json-api generates a class with two properties, and filling them in is most of the work:
namespace App\Http\Resources;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;
class PostResource extends JsonApiResource
{
public $attributes = [
'title',
'body',
];
public $relationships = [
'author' => UserResource::class,
'comments',
];
}
Both relationships resolve on their own. The built-in finds CommentResource and UserResource from the related models, so the explicit class on author is optional. I kept it to show the syntax. A route returns it like any resource, and sparse fieldsets and includes work from the query string:
GET /api/builtin/posts/1?include=author&fields[posts]=title&fields[users]=name
{
"data": {
"id": "1",
"type": "posts",
"attributes": {
"title": "Post 1"
},
"relationships": {
"author": {
"data": {
"id": "1",
"type": "users"
}
}
}
},
"included": [
{
"id": "1",
"type": "users",
"attributes": {
"name": "Hafiz"
}
}
]
}
The response goes out as application/vnd.api+json. A paginator gives you first, last, prev and next links, with Laravel's usual pagination meta and a plain ?page=2 parameter. Nesting stops at five levels by default, and JsonApiResource::maxRelationshipDepth() changes that. toLinks() and toMeta() add per-resource links and meta.
That's the whole job. The built-in shapes responses. It doesn't read JSON:API requests or enforce the rest of the spec, and three gaps showed up straight away.
It ignores include paths it doesn't know. ?include=secrets returned 200 with the post and no error. The JSON:API spec says a server that can't identify a relationship path "MUST respond with 400 Bad Request".
Errors come out in Laravel's own format, not as JSON:API error objects. Here's a 422 from a POST route I wrote by hand:
{"message":"The data.attributes.title field is required. (and 1 more error)","errors":{"data.attributes.title":["The data.attributes.title field is required."],"data.attributes.body":["The data.attributes.body field is required."]}}
And writes are yours to build. That route validates data.type and data.attributes.* with ordinary rules, then returns the new resource with a 201:
Route::post('/posts', function (Request $request) {
$validated = $request->validate([
'data.type' => ['required', 'in:posts'],
'data.attributes.title' => ['required', 'string', 'max:120'],
'data.attributes.body' => ['required', 'string'],
]);
$post = $request->user()->posts()->create($validated['data']['attributes']);
return (new PostResource($post))->response()->setStatusCode(201);
});
It works. But relationships in the request body, error objects with JSON pointers, and content negotiation are all on you. For filters and sorting, the docs point you at Spatie's Laravel Query Builder, which supports Laravel 12 and 13 in its current 7.x release (it needs PHP 8.4).
timacdonald/json-api
Tim MacDonald's package has been around since its first release in November 2021, and the built-in uses the same property-based API:
use TiMacDonald\JsonApi\JsonApiResource;
class PostResource extends JsonApiResource
{
public $attributes = ['title', 'body'];
public $relationships = ['author' => UserResource::class, 'comments' => CommentResource::class];
}
I compared the package and the built-in on three requests (a single post with includes, the collection, and a user with sparse fieldsets). Once the keys were sorted, the documents were identical. Then I changed only the use line in my three package resources to the built-in Illuminate\Http\Resources\JsonApi\JsonApiResource, and the same three requests still returned identical documents.
The other direction needs care. The package guesses the resource class from the relationship key, not from the related model, so 'author' with no class failed with a 500, because there's no AuthorResource. Moving from the package to the built-in was the use line. Moving from the built-in to the package can mean adding explicit classes.
The difference is loading. The package doesn't eager-load by design, and its README says so plainly. It recommends Spatie's query builder for that job. Without it:
GET /api/tim/users/1?include=posts.comments.author 123 queries
GET /api/tim/posts?include=author 22 queries
GET /api/tim/posts?include=author,comments 43 queries
With the same with() allow-list from earlier, those collection requests dropped to 2 and 3 queries.
It's still a pre-release. The latest tag is 1.0.0-beta.12, from March 2026. The repository isn't archived, and its constraints cover Laravel 11, 12 and 13. That makes it the right choice on Laravel 11, or on a 12.x app older than 12.45.0. On anything newer I wouldn't start a new API on it, because the built-in gives you the same API from the framework itself, and the N+1 fix lands in the framework too. If you're already on it, my migration for these three resources was the use line.
laravel-json-api/laravel
This is a different kind of tool. It isn't a resource class, it's a full JSON:API server. Version 5.3.0 (July 2026) supports Laravel 12.61.1 and up and 13.x.
Setup is more work. I published the config, generated a server with php artisan jsonapi:server v1, registered it in config/jsonapi.php, generated three schemas and a request class, registered routes, and added its exception renderer in bootstrap/app.php. A schema describes fields instead of output:
class PostSchema extends Schema
{
public static string $model = Post::class;
protected int $maxDepth = 3;
public function fields(): array
{
return [
ID::make(),
Str::make('title')->sortable(),
Str::make('body'),
BelongsTo::make('author')->type('users'),
HasMany::make('comments'),
DateTime::make('createdAt')->sortable()->readOnly(),
DateTime::make('updatedAt')->sortable()->readOnly(),
];
}
public function filters(): array
{
return [
WhereIdIn::make($this),
Where::make('author', 'user_id'),
];
}
public function pagination(): ?Paginator
{
return PagePagination::make();
}
}
JsonApiRoute::server('v1')->prefix('v1')->resources(function ($server) {
$server->resource('posts', JsonApiController::class)
->relationships(function ($relations) {
$relations->hasOne('author')->readOnly();
$relations->hasMany('comments')->readOnly();
});
$server->resource('users', JsonApiController::class)->only('index', 'show');
});
That route block registered create, update and delete for posts, plus the related-resource and relationship endpoints (/posts/{post}/author, /posts/{post}/relationships/comments), with no controller code of mine.
Then it does the things the built-in leaves to you. Includes are eager-loaded with no help:
GET /api/v1/users/1?include=posts.comments.author 4 queries
GET /api/v1/posts?include=author 2 queries
GET /api/v1/posts?include=author,comments 3 queries
Include paths are validated. The default $maxDepth is 1, which is why the schema above raises it. Before I did, the nested request came back like this, and so does any path the schema doesn't declare:
{"jsonapi":{"version":"1.0"},"errors":[{"detail":"Include path posts.comments.author is not allowed.","source":{"parameter":"include"},"status":"400","title":"Invalid Query Parameter"}]}
?filter[author]=2, ?sort=-title and ?page[number]=2&page[size]=5 worked straight away. Writes are validated against the request class, and the errors point at the exact member of the request document:
422 Unprocessable Entity | The title field is required. | /data/attributes/title
422 Unprocessable Entity | The body field is required. | /data/attributes/body
422 Unprocessable Entity | The author field is required. | /data/relationships/author
A POST with a plain application/json body was refused with a 415. A proper document, with the author passed as a relationship, created the post and returned it with a 201.
Two costs to know about. Authorization runs through your policies by default. I switched it off for this test with authorizable() returning false on the server, but in a real app you'd write the policies (my policies vs gates guide covers those). And it brings its own conventions, like camelCase createdAt, its own controller, and a schema layer your team has to learn.
Which one your API needs
| | Built-in JsonApiResource | timacdonald/json-api | laravel-json-api/laravel |
|---|---|---|---|
| What it is | Resource classes | Resource classes | Full server |
| Minimum Laravel | 12.45.0 | 11 | 12.61.1 |
| Eager-loads includes | Single resources (13.37+), collections not yet | No, by design | Yes |
| Unknown include path | Ignored, 200 | Ignored, 200 | 400 error object |
| Filters, sorting | Spatie Query Builder | Spatie Query Builder | Built in |
| Writes and validation | Your code | Your code | Request classes, JSON pointers |
| Error format | Laravel's | Laravel's | JSON:API error objects |
| Status | First-party | 1.0.0-beta.12 | 5.3.0 |
The question that decides it is whether clients write through the API.
If they only read, and what you need is the JSON:API shape for a front end or a mobile app, use the built-in. You get includes, sparse fieldsets and links from three small classes, and the remaining N+1 is a framework bug with a fix already merged. Add the with() allow-list until it ships.
If clients create and update resources through it, or a third party will build against your API and expects the spec enforced (error objects, rejected include paths, filters and pagination by the book), use laravel-json-api. Its setup cost is real, but you'd spend more than that rebuilding its validation and errors by hand on top of the built-in.
And timacdonald/json-api is for apps that can't run the built-in yet.
FAQ
Does Laravel's built-in JSON:API resource support filtering and sorting?
No. It shapes responses and handles include and fields. The Laravel docs point to Spatie's Laravel Query Builder for filters and sorts, and its current 7.x release supports Laravel 12 and 13 on PHP 8.4.
Can the built-in JsonApiResource accept JSON:API documents in POST or PATCH?
Only through your own code. You validate data.attributes.* with normal rules and create the model yourself. Validation errors come back in Laravel's default format, not as JSON:API error objects.
Is timacdonald/json-api abandoned?
No. The repository isn't archived and the latest release, 1.0.0-beta.12, supports Laravel 11 to 13. It's still a pre-release, though, and on Laravel 12.45.0 or newer the built-in offers the same property-based API.
Why does laravel-json-api reject my include with a 400?
Either the path isn't a relationship on the schema, or it's deeper than the schema's $maxDepth, which defaults to 1. The JSON:API spec requires a 400 when a server can't support an include path, and the package follows it.
Which Laravel version fixes the JSON:API N+1 queries?
Nested includes on a single resource are fixed in 13.37.0. Includes on collections are fixed on the 13.x branch by #62152, which was merged after 13.38.0 and should ship in the next 13.x release. Check the release notes for #62152 before you remove a with() workaround.
Conclusion
Whichever you pick, run composer show laravel/framework before you ship it. Anything from 13.27.0 to 13.36.0 loads nested includes one model at a time, and every release up to 13.38.0 does the same for collection includes until #62152 is tagged.
1 reaction
CVE-2026-114509: Laravel framework TokenGuard API authentication can be bypassed with a crafted non-string credential on some MySQL or MariaDB setups, allowing account takeover. No exploitation confirmed by the vendor. Fixed in 12.68.0 and 13.29.0.
I've never liked that with Laravel having a read replica is complicated. You never knew if your changes on the primary are already there on the replica.
You can soon just ask the database if the data is there and optionally wait 🤯
framework#62198 t.co
26 likes · 2 reposts · 2,729 views
Laravel 419 Page Expired: Causes and Fixes
The Laravel 419 Page Expired screen shows up when you submit a form or send an AJAX request and Laravel cannot match the CSRF token in the request with the one in the user's session. The message says "expired", but often nothing expired: the token was missing, the session cookie never reached the server, or the session was never saved. On cPanel or behind Cloudflare, cookie and caching settings can make the problem harder to spot. Start with the failed request before changing server settings.
Quick answer: 419 means a CSRF token mismatch. Check three things first: the form has @csrf (or the AJAX request sends X-CSRF-TOKEN / X-XSRF-TOKEN), the browser actually stores and sends the session cookie (DevTools → Application → Cookies), and the session can be written (storage/framework/sessions writable for the file driver, a sessions table for the database driver). On live servers also check SESSION_DOMAIN, SESSION_SECURE_COOKIE, www vs non-www and page caches, and run php artisan config:clear after editing .env.
Why Laravel 419 Page Expired happens
Routes in the web middleware group pass through Illuminate\Foundation\Http\Middleware\ValidateCsrfToken. For any request except GET, HEAD and OPTIONS, it reads a token (the _token field, then the X-CSRF-TOKEN header, then the encrypted X-XSRF-TOKEN header) and compares it with the session's token.
When the token does not match the current session, the middleware throws Illuminate\Session\TokenMismatchException. Laravel's exception handler converts it into an HttpException with status 419, and the built-in error view shows "Page Expired". Laravel does not log 419s by default, so storage/logs/laravel.log is usually empty when users complain.
Most CSRF-related 419 errors fall into one of two situations:
| Situation | What it means | Typical causes |
|---|---|---|
| No token in the request | The form or JS never sent one | Missing @csrf, fetch/axios without a header, wrong axios base URL |
| Token sent, but no matching session | The server cannot find the session that issued the token | Session expired, cookie not stored or not sent, session not saved, cached page with an old token |
Debug it step by step in the browser
Check which part of the request failed before changing settings. Open the form page in Chrome or Edge and press F12.
1. Application → Cookies → your domain. You should see XSRF-TOKEN and the session cookie. Its name comes from APP_NAME: recent Laravel 12 installs with APP_NAME=Laravel use laravel-session, apps created on Laravel 11 or older use laravel_session, and SESSION_COOKIE overrides it. No session cookie → see causes 4 and 5.
2. Check the cookie's Domain and Secure columns. The domain must match the address bar (or be a parent like .example.com). A Secure cookie is not sent back to an http:// page.
3. Network → reload → click the document request → Response Headers. On a normal session-backed web page, look for the expected Set-Cookie headers. If the session cookie is missing and you see cf-cache-status: HIT or a LiteSpeed cache HIT header, inspect whether a cache served the page (cause 7).
4. Submit the form → click the POST request. The Cookie request header must contain the session cookie, and Payload must contain _token (or the request must have an X-CSRF-TOKEN / X-XSRF-TOKEN header for AJAX).
5. Compare the form action with the address bar. A page on https://www.example.com posting to https://example.com (or to http://) is a different cookie scope.
Then open storage/logs/laravel.log. A 419 is not logged, but a broken session store shows up as "Permission denied", "failed to open stream" or "Table 'sessions' doesn't exist".
Causes 1-3: missing token, AJAX headers, expired session
Cause 1: the form has no @csrf
Every POST, PUT, PATCH or DELETE form in Blade needs the directive:
<form method="POST" action="{{ route('profile.update') }}">
@csrf
@method('PUT')
<!-- fields -->
</form>
@csrf prints a hidden _token input. @method('PUT') only spoofs the method and does not replace it. Forms pasted from plain HTML or old projects are where it usually goes missing.
Cause 2: AJAX, fetch or axios without the token header
Add a CSRF meta tag inside the layout's <head>:
<meta name="csrf-token" content="{{ csrf_token() }}">
With jQuery, attach it to every request once:
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
fetch() does not automatically add a CSRF token header, so include one:
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/attendance/check-in', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': token
},
credentials: 'same-origin',
body: JSON.stringify({ employee_id: 15 })
});
credentials: 'same-origin' sends the session cookie. Accept: application/json makes Laravel return a JSON error instead of the HTML page.
Axios works differently. Laravel provides an encrypted XSRF-TOKEN cookie on applicable web responses. With the standard same-origin setup, Axios can use the cookie value in the X-XSRF-TOKEN request header. The default Laravel JavaScript setup relies on this convention rather than reading the meta tag. Two things break it: a baseURL on another host (such as http://127.0.0.1:8000 left in production), and putting the meta tag value into X-XSRF-TOKEN. That header must hold the encrypted cookie value; a plain token fails decryption. Send the meta value as X-CSRF-TOKEN.
Vue components calling Laravel routes follow the same rules. For a separate-domain SPA, follow Laravel Sanctum's documented cookie and CORS setup.
Cause 3: the session really expired
The token lives in the session, which ends after SESSION_LIFETIME minutes of inactivity. Laravel 12's config/session.php reads (int) env('SESSION_LIFETIME', 120), so the default is 120 minutes idle. This often shows up when someone submits a form that has been left open for several hours. The token also goes stale when:
- Someone logs in or out in another tab. The docs' login code calls $request->session()->regenerate() and logout calls invalidate() plus regenerateToken(), so older forms carry a dead token.
- The back button shows a cached copy of a form after logout and login.
- SESSION_EXPIRE_ON_CLOSE=true ends the session when the browser closes, and a restored tab submits a dead token.
A longer lifetime may be appropriate for some internal tools, but a sensitive app still needs an inactivity policy. For long forms, consider saving drafts. A controlled keep-alive is covered later, but avoid extending unattended sessions.
Causes 4-5: the session cookie or the session store is broken
If every POST fails, even right after loading the page, the session does not survive between requests.
Cause 4: the browser does not store or send the session cookie
Safe .env values for a single-domain site on HTTPS:
APP_URL=https://example.com
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=true
What goes wrong with each:
| Setting | Mistake | Result |
|---|---|---|
| SESSION_DOMAIN | Set to the old domain, to localhost, or to a domain the site is not on | Browser rejects the cookie, every POST is 419 |
| SESSION_SECURE_COOKIE=true | Site opened over http:// (LAN IP, staging subdomain without SSL) | Cookie is Secure-only and never sent back over HTTP |
| www vs non-www | Page on www.example.com, form posts to example.com (or the reverse) | With SESSION_DOMAIN=null the cookie belongs to one host only |
| APP_URL | Wrong host or http:// while code builds URLs from config('app.url') | Forms and JS post to a different origin than the page |
| SESSION_DRIVER=cookie | Large amounts of data put in the session | Browsers cap a single cookie at roughly 4 KB and drop larger ones |
SESSION_DOMAIN=null means "this exact host only", which most sites want. Use .example.com only to share the session across subdomains. For www vs non-www, pick one and redirect the other at server level.
Cause 5: the session driver cannot save sessions
Laravel 12 defaults to the database driver. Common failures on shared hosting:
- file: sessions go to storage/framework/sessions. If the folder is missing, not writable, or the account is over its disk quota, nothing is saved.
- database: needs a sessions table. Laravel 11 and 12 create it in the default 0001_01_01_000000_create_users_table.php migration, but upgraded apps or partial database imports may lack it.
- redis: many shared plans have no Redis, and Laravel needs the PhpRedis extension or the predis/predis package for it.
Depending on where it fails you get a 500 or a 419; laravel.log has the real message. Fixes:
# file driver: check the folder exists and is owned by your cPanel user
ls -la storage/framework/sessions
# database driver: check existing migrations first
php artisan migrate:status
# Only if no sessions-table migration exists:
php artisan make:session-table
# Inspect and back up before applying pending migrations:
php artisan migrate
php artisan session:table is the Laravel 10 name; Laravel 11 and 12 use make:session-table. Check phpMyAdmin and php artisan migrate:status first: do not generate a duplicate table migration, and inspect all pending migrations before running them on production. Without Redis, use file or database. Permissions and Terminal steps are in deploying Laravel on cPanel.
Causes 6-8: Cloudflare, proxies, page cache and cached config
Cause 6: proxy or load balancer causes an http/https mismatch
When a proxy ends TLS and talks to your server over plain HTTP, Laravel thinks the request is http:// and builds http:// form URLs. The https:// page then posts to http://, and you get a 419 (Secure cookie not sent), a redirect that turns the POST into a GET, or an "insecure form" warning.
For Cloudflare, prefer SSL mode Full (strict) with a valid origin certificate. That secures Cloudflare-to-origin traffic, but Laravel may still need trusted-proxy configuration behind a separate load balancer or TLS-terminating proxy. Avoid Flexible mode for this setup.
Behind a load balancer or reverse proxy that ends TLS, trust it in bootstrap/app.php (Laravel 11/12 syntax):
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: [
'10.0.0.0/8',
]);
})
Use the proxy's IPs or ranges. Laravel also allows trustProxies(at: '*'). Only consider it when untrusted clients cannot reach the application directly; otherwise, forged X-Forwarded-* headers may be accepted. Verify with a route returning request()->secure(): it should be true live. If Cloudflare shows a challenge page that blocks AJAX calls, that is a separate problem: see Cloudflare JS challenge breaking a Laravel SPA.
Cause 7: a page cache served an old token
A wrongly cached login page may deliver a token from another session or fail to deliver the session cookie. Cloudflare does not cache HTML by default. Custom cache eligibility rules can change this; cookie-bearing responses require particular care. But if a Cache Rule makes HTML eligible and overrides the Edge TTL, Cloudflare can strip Set-Cookie and cache the page, causing 419 errors.
- Exclude /login, /register, /password/* and other form pages from cache rules, or do not override the Edge TTL for HTML at all.
- On LiteSpeed hosting with LSCache rules in .htaccess, make sure dynamic Laravel pages are not cached. When unsure, remove the rules and retest.
- cf-cache-status: HIT on a session-specific form page is strong evidence of misconfigured caching. Confirm that the cached HTML contains session-specific form content.
Cause 8: config is cached with old values
After php artisan config:cache (or optimize), Laravel loads bootstrap/cache/config.php and ignores .env, so .env changes do nothing.
php artisan config:clear
# or clear config, routes, views and cache in one go
php artisan optimize:clear
Run config:cache again if your deployment uses cached configuration. Without terminal access, a competent administrator may remove the generated configuration cache file through File Manager, but should preserve the deployment backup and check permissions first.
Excluding routes from CSRF for webhooks
Payment and SMS webhooks post without a token because they cannot know it. Exclude only those routes in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'stripe/*',
'webhooks/sms',
]);
})
Laravel also allows webhook routes to be handled outside the web middleware group when appropriate. A browser returning from a payment service by a cross-site POST may not send a cookie with SESSION_SAME_SITE=lax. Keep browser return pages separate from authenticated server-to-server webhooks; verify payment using the provider's API or a signed callback rather than trusting browser parameters. Changing SameSite globally is not a safe first fix.
When not to do this: never put '*' in the list, never remove ValidateCsrfToken, and never exclude login or admin forms "to stop the 419". That removes cross-site request forgery protection. Fix the cause instead.
Show a friendly 419 page and keep sessions alive
Create resources/views/errors/419.blade.php and Laravel uses it for every 419 (swap in your own layout):
@extends('layouts.app')
@section('content')
<h1>Your session expired</h1>
<p>The form was not submitted. Go back, refresh the page and try again.</p>
<a href="{{ url()->previous() }}">Go back</a>
@endsection
Or redirect back with a message, using the docs' respond hook in bootstrap/app.php:
use Symfony\Component\HttpFoundation\Response;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->respond(function (Response $response) {
if ($response->getStatusCode() === 419) {
return back()->with([
'message' => 'The page expired, please try again.',
]);
}
return $response;
});
})
back() returns the user to the form without what they typed, so this suits short forms. For long forms (leave requests, payroll entry), add a keep-alive route in routes/web.php:
Route::get('/keep-alive', fn () => response()->json(['token' => csrf_token()]))
->middleware('auth');
Call it every 15 minutes on those pages:
setInterval(async () => {
const res = await fetch('/keep-alive', { credentials: 'same-origin', headers: { 'Accept': 'application/json' } });
if (!res.ok) return;
const { token } = await res.json();
document.querySelector('meta[name="csrf-token"]').content = token;
document.querySelectorAll('input[name="_token"]').forEach(i => i.value = token);
}, 15 * 60 * 1000);
Each successful call counts as activity, so an unattended tab may remain logged in indefinitely. Only enable this timer if the app's security policy permits activity-based session extension; otherwise save drafts and require reauthentication. A draft-saving approach is often safer. For shared-hosting setup steps, see the Laravel on cPanel deployment checklist.
FAQ
Why does Laravel show 419 Page Expired on login only on the live server?
Check in DevTools that the session cookie appears after loading the login page. If not, the usual causes are a wrong SESSION_DOMAIN, SESSION_SECURE_COOKIE=true on an http:// address, a cached login page, an unwritable storage/framework/sessions folder or a missing sessions table. Run php artisan config:clear after any .env change.
How do I fix CSRF token mismatch in Laravel AJAX requests?
Add the csrf-token meta tag to the layout and send its value as X-CSRF-TOKEN on every POST, PUT, PATCH or DELETE. Axios does this itself via the XSRF-TOKEN cookie on same-origin requests. With fetch, add the header and credentials: 'same-origin'.
What is the default session lifetime in Laravel 12?
120 minutes of inactivity, from SESSION_LIFETIME in config/session.php. After that the session and its token are gone, and the next submit returns 419.
Is it safe to disable CSRF protection to stop the 419 error?
No. Exclude only routes called by outside services, such as payment or SMS webhooks, with validateCsrfTokens(except: [...]), and verify them with the provider's signature. Disabling CSRF for normal forms lets other sites submit them on behalf of logged-in users.
Sources and documentation
- Laravel 12.x: CSRF Protection: laravel.com
- Laravel 12.x: HTTP Session: laravel.com
- Laravel 12.x: HTTP Requests (Configuring Trusted Proxies): laravel.com
- Laravel 12.x: Error Handling (custom error pages, respond): laravel.com
- Laravel 12.x: Configuration (configuration caching): laravel.com
- Laravel 12.x: Authentication (logout, regenerateToken): laravel.com
- Laravel framework 12.x source: Exceptions Handler (TokenMismatchException → 419): github.com/laravel/framework/blob/12.x/src/Illumi…
- Laravel framework 12.x source: VerifyCsrfToken middleware: github.com/laravel/framework/blob/12.x/src/Illumi…
- Laravel 12.x skeleton: config/session.php: github.com/laravel/laravel/blob/12.x/config/sessi…
- Cloudflare: Default cache behavior: developers.cloudflare.com
- Cloudflare: Set-Cookie and cache behavior: developers.cloudflare.com
---
Originally published at headsntail.com. Free tools for PHP, Laravel and cPanel work: headsntail.com.
1 reaction
Your Users Save and Still See Old Data: Laravel 13.38 Fixes Read Replica Lag, Except in 3 Places
> Originally published at hafiz.dev
---
I changed a post title to "Q4 pricing update", got the green "Saved" banner, and the heading right under it still said "Q3 pricing update". The debug line on the same page said the request had read from the primary. That was Laravel 13.38.0, with the new PreventStaleDatabaseReads middleware installed exactly the way the docs show it.
!After saving "Q4 pricing update", the banner confirms the save but the heading still shows "Q3 pricing update", on a request that read from the primary
The middleware is a good idea. Jack Bayliss wrote it after seeing his replicas lag 0 to 1 seconds on average and up to 15 seconds under some workloads. That's long enough for a user to edit a title, refresh, see the old one, and file a bug report. It shipped in v13.38.0 on 20 October.
To see what it covers, I set up a MySQL 8.4 primary and a replica in Docker, held the replica 5 seconds behind with delayed replication (SOURCE_DELAY=5), and pointed a clean Laravel 13.38.0 project at both. Five seconds is slow for a healthy replica. That's the point. It makes every gap reproducible on a laptop. All output below is real.
The documented setup needs one extra call before it protects the most common kind of page. And three things the middleware never touches still read stale data after you fix it. API routes, queued jobs, and the database cache store, which is where your rate limiter keeps its counts.
What sticky already did, and what 13.38 adds
Laravel has split reads and writes for years. You give the connection a read and a write server, and set sticky so a request that writes also reads its own writes:
'mysql' => [
'driver' => 'mysql',
'read' => ['port' => env('DB_READ_PORT', '3306')],
'write' => ['port' => env('DB_WRITE_PORT', '3306')],
'sticky' => true,
// host, database, credentials...
],
Both of my servers run on 127.0.0.1, so only the port differs. In production you'd override host instead.
But sticky only lasts for one request. Save a form, redirect, and the GET that follows is a new request on a clean connection. It reads the replica.
PreventStaleDatabaseReads carries the write across requests through the session. This is the core of it, from the framework source:
$connection->useWriteConnectionWhenReading(
$request->session()->get('_database_modified_at', 0) >= Date::now()->subSeconds((int) $seconds)->getTimestamp()
);
$response = $next($request);
if ($connection->hasModifiedRecords()) {
$request->session()->put('_database_modified_at', Date::now()->getTimestamp());
}
Three details in those lines come back later. It calls $request->session(), so it needs a session. It only touches the default connection. And it compares whole-second timestamps, so a 2 second window stays open for somewhere between 2 and 3 seconds.
The same release added usingWritePdo(). It runs a callback with every read on that connection sent to the primary, then restores the previous setting, even if the callback throws. Most of the fixes below use it.
The documented setup still served the old title
The docs tell you to append the middleware to the web group:
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
PreventStaleDatabaseReads::class,
]);
})
I did that, saved a new title, and reloaded the page every second with the same session cookie. The page prints its title and the @@server_id of whichever server answered a select in the route closure. Server 1 is the primary, server 2 the replica.
+0.0s PUT title="Q4 pricing update"
+0.1s GET title="Q3 pricing update" read from server_id 1 (primary)
+1.2s GET title="Q3 pricing update" read from server_id 1 (primary)
+2.3s GET title="Q3 pricing update" read from server_id 1 (primary)
+3.4s GET title="Q3 pricing update" read from server_id 2 (replica)
+4.5s GET title="Q3 pricing update" read from server_id 2 (replica)
+5.6s GET title="Q3 pricing update" read from server_id 2 (replica)
+6.7s GET title="Q4 pricing update" read from server_id 2 (replica)
The middleware did its job. For the first three reloads the query in the closure went to the primary. The title was stale anyway, because it came from $post, and $post came from route model binding.
Route model binding happens in SubstituteBindings, which is already in the web group. Appending puts the new middleware after it. Laravel does sort some middleware by a priority list, and SubstituteBindings is on that list. PreventStaleDatabaseReads isn't, so it keeps its appended position. By the time it switches the connection to the primary, the model has already been loaded from the replica.
So add it to the priority list:
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
PreventStaleDatabaseReads::class,
]);
$middleware->prependToPriorityList(
before: AuthenticatesRequests::class,
prepend: PreventStaleDatabaseReads::class,
);
})
AuthenticatesRequests is the contract the auth middleware implements, and it sits ahead of SubstituteBindings in Laravel's priority list. Going in before it means the user lookup that auth does reads the primary too. It still runs after StartSession, which it needs. This is the order Laravel resolved for GET /posts/{post} after the change:
EncryptCookies
AddQueuedCookiesToResponse
StartSession
ShareErrorsFromSession
PreventRequestForgery
PreventStaleDatabaseReads
SubstituteBindings
I tested the auth part too. A logged-in user renames themselves, then reloads a route behind auth that prints auth()->user()->name and the server that answered its own query. With the middleware only appended, as in the docs, the rename from "Hafiz R" to "Hafiz Riaz" didn't show:
+0.1s PUT name="Hafiz Riaz"
+0.1s GET Hafiz R (server_id 1)
+1.1s GET Hafiz R (server_id 1)
+2.2s GET Hafiz R (server_id 1)
+3.3s GET Hafiz R (server_id 1)
That's the name in your nav bar staying wrong after a profile save, on requests that read the primary for everything else. With the priority call in place, the earlier rename from "Hafiz" to "Hafiz R" showed on the first reload:
+0.1s PUT name="Hafiz R"
+0.1s GET Hafiz R (server_id 1)
+1.1s GET Hafiz R (server_id 1)
+2.2s GET Hafiz R (server_id 1)
+3.3s GET Hafiz R (server_id 1)
Back to the post title. Same save, same reloads:
+0.0s PUT title="Q4 pricing update"
+0.1s GET title="Q4 pricing update" read from server_id 1 (primary)
+1.2s GET title="Q4 pricing update" read from server_id 1 (primary)
+2.3s GET title="Q4 pricing update" read from server_id 1 (primary)
+3.4s GET title="Q3 pricing update" read from server_id 2 (replica)
+4.5s GET title="Q3 pricing update" read from server_id 2 (replica)
+5.6s GET title="Q3 pricing update" read from server_id 2 (replica)
+6.7s GET title="Q4 pricing update" read from server_id 2 (replica)
Better, and in one way worse. The user now sees the new title, then the old one again, then the new one. The default window closed after about 3 seconds, and my replica needed about 6.
> View the interactive component on hafiz.dev
The window has to outlast your replica lag. for() sets it in seconds:
$middleware->web(append: [
PreventStaleDatabaseReads::for(6),
]);
The priority entry can stay as the plain class name. I printed the resolved order again to check, and PreventStaleDatabaseReads:6 still landed before SubstituteBindings.
+0.0s PUT title="Q4 pricing update"
+0.1s GET title="Q4 pricing update" read from server_id 1 (primary)
+1.2s GET title="Q4 pricing update" read from server_id 1 (primary)
+2.3s GET title="Q4 pricing update" read from server_id 1 (primary)
+3.4s GET title="Q4 pricing update" read from server_id 1 (primary)
+4.5s GET title="Q4 pricing update" read from server_id 1 (primary)
+5.7s GET title="Q4 pricing update" read from server_id 1 (primary)
+6.8s GET title="Q4 pricing update" read from server_id 2 (replica)
!With the middleware moved ahead of route binding and a 6 second window, the heading shows the new title straight after the save
Pick the number from your worst observed lag, not your average. On MySQL, Seconds_Behind_Source in SHOW REPLICA STATUS is the figure to watch. Jack's 15 second peak would need for(15) or more. Also, any write on the default connection during a request reopens the window. If a middleware updates last_seen_at on every request, an active user stays on the primary for as long as they keep clicking.
Place 1: API routes
A stateless API route has no session. Put the middleware in the api group and every request fails (output trimmed):
{
"message": "Session store not set on request.",
"exception": "RuntimeException",
...
}
Jack flags this in the PR himself ("It's session based, so not ideal for API stuff"). Leave it off, and an API client that saves and reads straight back gets the old value for the whole lag. That part I expected. Sanctum was the surprise.
New Sanctum tokens fail until the replica catches up
Sanctum looks up a personal access token with a plain find(), which reads the replica. So a token issued a moment ago doesn't exist yet as far as the next request can tell:
+0.1s POST /api/tokens -> new token issued
+0.1s GET /api/me -> {"message":"Unauthenticated."} 401
+1.2s GET /api/me -> {"message":"Unauthenticated."} 401
+2.3s GET /api/me -> {"message":"Unauthenticated."} 401
+3.3s GET /api/me -> {"message":"Unauthenticated."} 401
+4.4s GET /api/me -> {"message":"Unauthenticated."} 401
+5.5s GET /api/me -> {"message":"Unauthenticated."} 401
+6.5s GET /api/me -> {"name":"Hafiz"} 200
A mobile app that logs in and immediately loads the profile screen hits exactly this. I fixed it with a token model that tries the replica first and only falls back to the primary on a miss:
namespace App\Models;
use Laravel\Sanctum\PersonalAccessToken as SanctumToken;
class PersonalAccessToken extends SanctumToken
{
public static function findToken($token)
{
return parent::findToken($token) ?? (new static)->getConnection()->usingWritePdo(
fn () => parent::findToken($token)?->load('tokenable')
);
}
}
// AppServiceProvider::boot()
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
My first version had no load('tokenable'). Login worked, but a signup that returned a token still got 401 for 6 seconds. On signup the user row is as new as the token, and Sanctum loads the user later, outside the callback, from the replica. Loading it inside the callback fixed both flows. (If you're still choosing an auth package, Passport vs Sanctum covers that decision.)
Sanctum gets one thing right here already. When it updates last_used_at, it puts the connection's "modified" flag back the way it found it, so an authenticated GET doesn't count as a write.
Read-your-writes for API clients
For the save-then-read case I wrote a version of the middleware keyed by the authenticated user instead of the session:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class PreventStaleApiReads
{
public function handle(Request $request, Closure $next, int $seconds = 2)
{
if (! $user = $request->user()) {
return $next($request);
}
$connection = DB::connection();
$key = 'recent-write:'.$user->getAuthIdentifier();
$connection->useWriteConnectionWhenReading(Cache::has($key));
$response = $next($request);
if ($connection->hasModifiedRecords()) {
Cache::put($key, true, $seconds);
}
return $response;
}
}
It needs the user, so it has to run after auth and before route binding:
$middleware->appendToPriorityList(
after: AuthenticatesRequests::class,
append: PreventStaleApiReads::class,
);
// routes/api.php
Route::middleware(['auth:sanctum', PreventStaleApiReads::class.':6'])->group(function () {
Route::get('/posts/{post}', /* ... */);
Route::put('/posts/{post}', /* ... */);
});
A token client with no cookies, saving and then reading every second:
+0.0s PUT {"title":"Saved by an API client"}
+0.1s GET {"title":"Saved by an API client","server_id":1}
+1.2s GET {"title":"Saved by an API client","server_id":1}
+2.3s GET {"title":"Saved by an API client","server_id":1}
+3.3s GET {"title":"Saved by an API client","server_id":1}
+4.4s GET {"title":"Saved by an API client","server_id":1}
+5.5s GET {"title":"Saved by an API client","server_id":1}
+6.7s GET {"title":"Saved by an API client","server_id":2}
The flag lives in the cache, so the cache must not read from the replica either. If it does, Cache::has() misses for the whole lag and the middleware does nothing. That's place 3.
Place 2: Queued jobs
A job runs in a worker process with its own connection. No session, no HTTP middleware, and none of the sticky state from the request that dispatched it. (It's the same reason tenant context goes missing in workers, one of the three multi-tenancy queue bugs I wrote about in June.)
Laravel already covers part of this. When a worker rebuilds a model from the job payload, restoreModel() calls useWritePdo(), so $this->post itself comes from the primary. Relations don't. The relations you eager-loaded before dispatching are restored with loadMissing(), which reads the replica. So does every query inside handle().
My test job logs the post title, the comment count through the restored relation, and a direct count query. The route adds a comment, then dispatches the job straight away with $post->load('comments'). The primary had 3 comments when the job ran:
SummarisePost {"title":"Saved through the API","comments_relation":2,"comments_query":2}
Fresh title. Both counts stale.
My first run didn't show this at all, and it took me a minute to see why. I was on the default database queue driver, and reserving a job runs an UPDATE on the jobs table. That marks the connection as modified, and with sticky on, everything the job reads afterwards goes to the primary. When I switched the queue to Redis, the stale reads appeared. Any driver that doesn't write to your replicated connection (Redis, SQS) behaves the same way. The 13.36 option Worker::$flushStickyConnections (#61915) doesn't change the database-driver case either, because the worker flushes before it reserves the next job, and the reserve marks the connection again.
The fix I'd use is a job middleware:
namespace App\Jobs\Middleware;
use Closure;
use Illuminate\Support\Facades\DB;
class ReadFromPrimary
{
public function handle(object $job, Closure $next): mixed
{
return DB::connection()->usingWritePdo(fn () => $next($job));
}
}
class SummarisePost implements ShouldQueue
{
use Queueable;
public function __construct(
#[WithoutRelations]
public Post $post,
) {
}
public function middleware(): array
{
return [new ReadFromPrimary];
}
// handle() unchanged
}
SummarisePost {"title":"Saved through the API","comments_relation":4,"comments_query":4}
Job middleware runs after the job has been unserialized, so without #[WithoutRelations] a relation stored in the payload is already loaded from the replica by then. Leaving the attribute off, with 5 comments on the primary, I got this:
SummarisePost {"title":"Saved through the API","comments_relation":4,"comments_query":5}
With it, nothing is restored early, and the relation loads lazily inside the middleware's callback.
Put ReadFromPrimary on the jobs you dispatch right after a write, not on every job. A report that runs ten minutes later doesn't need it, and routing every job to the primary defeats the point of having a replica. If a job can wait, ->delay(now()->addSeconds(10)) costs nothing at all. Workers have enough lifecycle rules of their own already (like the SIGTERM they get on every deploy), and if you're tuning them for volume, processing 10,000 queue jobs without breaking covers the worker side.
Place 3: The database cache store, and your rate limiter
A fresh Laravel 13 project ships with CACHE_STORE=database on the default connection. The database cache store reads through that connection with no write-PDO override, so on a replicated connection, cache reads hit the replica. Sessions don't have this problem, because DatabaseSessionHandler adds useWritePdo() to its queries. The cache store doesn't.
Rate limiting is where that hurts. I put throttle:3,1 on a stateless login route and sent a request every half second:
+0.1s attempt 1 -> 200
+0.7s attempt 2 -> 200
+1.3s attempt 3 -> 200
+1.9s attempt 4 -> 200
+2.5s attempt 5 -> 200
+3.1s attempt 6 -> 200
+3.7s attempt 7 -> 200
+4.3s attempt 8 -> 200
+4.9s attempt 9 -> 200
+5.5s attempt 10 -> 200
+6.1s attempt 11 -> 200
+6.7s attempt 12 -> 200
+7.3s attempt 13 -> 429
+7.9s attempt 14 -> 429
+8.4s attempt 15 -> 429
+9.0s attempt 16 -> 429
allowed: 12 of 16 (limit is 3 per minute)
The increments run inside a locked transaction, so they go to the primary and the counter itself is right. But tooManyAttempts() reads the count with a plain cache get, from the replica, which still shows the count from 5 seconds ago. The limiter only starts refusing once the replica has caught up with the third hit. So an attacker gets the limit plus whatever they can send during the lag. At two requests a second that was 12 instead of 3, and a script sends a lot more than two. PreventStaleDatabaseReads doesn't help, because an attacker just drops the session cookie.
Point the cache at a connection without a replica:
// config/database.php
'mysql_primary' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_WRITE_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
],
DB_CACHE_CONNECTION=mysql_primary
DB_CACHE_LOCK_CONNECTION=mysql_primary
In production that connection gets your primary's host. Same burst afterwards:
allowed: 3 of 16 (limit is 3 per minute)
A Redis cache avoids the problem as well. Either way, the PreventStaleApiReads flag from place 1 depends on this.
What I'd ship
Move the cache off the replicated connection first, even if you skip everything else in this post. A rate limiter that can be outrun is a security bug, not a UX one, and it's a two-line .env change.
Then add PreventStaleDatabaseReads with the priority call and a window longer than your worst lag. Without the priority call it protects the queries in your controllers and misses the model your route is named after.
For APIs, the token fallback is cheap. It costs one extra primary query, and only when the replica misses. The user-keyed middleware is worth it only if your clients read right after they write. If your PUT responses already return the saved resource, your clients may never need it.
For jobs, wrap the few that run straight after a write and leave the rest alone.
Every one of these moves reads onto the primary that the replica was supposed to take. That's the trade. If your lag is routinely more than a few seconds, a longer window hides a replica problem rather than fixing it.
FAQ
Does PreventStaleDatabaseReads stop other users from seeing stale data?
No. It only sends reads to the primary for the session that made the write. Another user viewing the same post during the lag still reads the replica. That's usually fine, because they never saw the new value to begin with.
Do I still need the sticky option?
Yes. They cover different requests. sticky sends reads to the primary later in the same request that wrote. The middleware covers the requests that come after. I had both on for every test in this post.
Does it cover a second database connection?
No. It only tracks and switches the default connection, and the docs say so. If tenant or reporting data lives on another connection, wrap those reads in that connection's usingWritePdo().
Why does a 2 second window last longer than 2 seconds?
The middleware stores and compares whole-second timestamps. A write at 10.0 and one at 10.9 are both stored as 10, and the window closes when the clock reaches 13. So it stays open between 2 and 3 seconds after the write. In my runs, a reload at 2.3 seconds still went to the primary with the default window.
Does the database queue driver protect jobs from stale reads?
By accident, when the jobs table lives on the same replicated connection and sticky is on. Reserving the job is a write, so the job reads the primary. Don't rely on it. Switch to Redis or SQS later and the stale reads show up with no code change.
Conclusion
PreventStaleDatabaseReads fixes the bug Jack described, as long as you add the priority call and size the window to your lag. Both are one-line fixes. Everything else in this post sits outside what a session-based middleware can reach, and none of it needed production traffic to show up. If you run read replicas, start one with SOURCE_DELAY=5 on your laptop and click through your own app before your users do.
2 reactions · 1 comment
Storage::put() in Your Use Case Is a Leak: Put Files Behind a Port in Laravel
- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
---
Your invoice upload has been "working" for weeks. Then someone asks for the PDF of invoice 4812 and the bucket has nothing at that key. No exception in the logs, no failed job. The use case called Storage::disk('s3')->put(...), and per the Laravel file storage docs, when a write fails put returns false unless the disk has 'throw' => true in its config. Nobody checked the return value, because nobody thinks of a file write as something that answers with a boolean.
That is one cost of calling the Storage facade from inside a use case. Your business rule now depends on a key in config/filesystems.php, and whether a failed write is loud or silent is decided by a config flag the use case never sees.
The fix is five small pieces: a DocumentStore interface the domain owns, a Flysystem-backed adapter, an in-memory adapter for fast tests, a service provider binding, and a contract test both adapters pass. Everything targets PHP 8.3+ and Laravel 13, which requires PHP 8.3 as its minimum.
What the facade call costs you
Here is the shape that shows up in a lot of Laravel codebases:
<?php
namespace App\Billing;
use Illuminate\Support\Facades\Storage;
final class AttachInvoicePdf
{
public function __invoke(string $invoiceId, string $pdf): string
{
$path = "invoices/{$invoiceId}.pdf";
if (Storage::disk('s3')->exists($path)) {
throw new \DomainException('Already attached');
}
Storage::disk('s3')->put($path, $pdf);
return $path;
}
}
Three things leak out of those few lines.
The disk name. 's3' is an infrastructure decision written into a billing rule. Giving invoices their own disk, or running them on local in staging, means editing the use case, even though nothing about billing changed.
The failure mode. put reports failure as false by default. Laravel's FilesystemAdapter catches UnableToWriteFile, rethrows it only when the disk's throw option is set, and passes it to the exception handler only when the disk's report option is set; both default to false in the framework source. The code above ignores the return value, so the write can fail without anyone hearing about it.
The test. To exercise this rule you need a booted Laravel application and Storage::fake('s3'). That fake is not an in-memory map: the Storage facade source builds a local driver rooted at storage/framework/testing/disks/{disk} and cleans that directory on each call. Your billing rule test now touches the real filesystem and the framework container to check one if.
The port: four methods the domain actually needs
Start from what the billing code needs, not from what Flysystem offers. It needs to store bytes under a key, read them back, ask whether something is there, and remove it. That is the whole port.
<?php
namespace App\Billing\Domain;
interface DocumentStore
{
/** @throws DocumentStoreFailed */
public function put(DocumentKey $key, string $bytes): void;
/** @throws DocumentNotFound|DocumentStoreFailed */
public function get(DocumentKey $key): string;
/** @throws DocumentStoreFailed */
public function exists(DocumentKey $key): bool;
/** Deleting a missing key is a no-op. */
public function delete(DocumentKey $key): void;
}
put returns void. Failure is an exception, always, regardless of which adapter sits behind it. That single decision removes the silent-false problem at the boundary.
The key is a value object, so path rules live in one place:
<?php
namespace App\Billing\Domain;
final readonly class DocumentKey
{
private function __construct(public string $value) {}
public static function of(string $value): self
{
if (
$value === ''
|| str_starts_with($value, '/')
|| str_contains($value, '..')
) {
throw new \InvalidArgumentException(
"Invalid document key: {$value}"
);
}
return new self($value);
}
public static function forInvoice(string $invoiceId): self
{
return self::of("invoices/{$invoiceId}.pdf");
}
}
An invoice id of ../../etc/passwd never reaches an adapter. The two exceptions are plain domain types:
<?php
namespace App\Billing\Domain;
final class DocumentNotFound extends \RuntimeException
{
public static function at(DocumentKey $key): self
{
return new self("No document at {$key->value}");
}
}
final class DocumentStoreFailed extends \RuntimeException {}
In a real project, give each class its own file so Composer's PSR-4 autoloader finds it. They share a block here to save scrolling.
With the port in place, the use case loses every trace of Laravel:
<?php
namespace App\Billing\Application;
use App\Billing\Domain\DocumentKey;
use App\Billing\Domain\DocumentStore;
final readonly class AttachInvoicePdf
{
private const MAX_BYTES = 5 * 1024 * 1024;
public function __construct(private DocumentStore $documents) {}
public function __invoke(
string $invoiceId,
string $pdf,
): DocumentKey {
if (!str_starts_with($pdf, '%PDF-')) {
throw new InvoiceRejected('Not a PDF');
}
if (strlen($pdf) > self::MAX_BYTES) {
throw new InvoiceRejected('PDF over 5 MB');
}
$key = DocumentKey::forInvoice($invoiceId);
if ($this->documents->exists($key)) {
throw new InvoiceRejected(
"Invoice {$invoiceId} already has a PDF"
);
}
$this->documents->put($key, $pdf);
return $key;
}
}
InvoiceRejected is a one-line final class InvoiceRejected extends \DomainException {}. The rules (PDF magic bytes, size cap, one PDF per invoice) read top to bottom with no disk names and no facade. The controller turns the upload into a string and calls the use case; that is where $request->file('pdf') belongs.
Two adapters: Flysystem for real, an array for tests
The Laravel docs open by saying the filesystem layer is built on Flysystem, and Laravel 13's composer.json requires league/flysystem ^3.25.1. So you already have it installed. The production adapter wraps Flysystem's FilesystemOperator and translates its exceptions. Every Flysystem exception implements the League\Flysystem\FilesystemException marker interface, per the exception handling docs, so one catch covers write, read, existence-check and delete failures.
<?php
namespace App\Billing\Infrastructure;
use App\Billing\Domain\DocumentKey;
use App\Billing\Domain\DocumentNotFound;
use App\Billing\Domain\DocumentStore;
use App\Billing\Domain\DocumentStoreFailed;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
final readonly class FlysystemDocumentStore implements DocumentStore
{
public function __construct(
private FilesystemOperator $fs,
) {}
public function put(DocumentKey $key, string $bytes): void
{
try {
$this->fs->write($key->value, $bytes);
} catch (FilesystemException $e) {
throw $this->failed('write', $key, $e);
}
}
public function get(DocumentKey $key): string
{
if (!$this->exists($key)) {
throw DocumentNotFound::at($key);
}
try {
return $this->fs->read($key->value);
} catch (FilesystemException $e) {
throw $this->failed('read', $key, $e);
}
}
public function exists(DocumentKey $key): bool
{
try {
return $this->fs->fileExists($key->value);
} catch (FilesystemException $e) {
throw $this->failed('check', $key, $e);
}
}
public function delete(DocumentKey $key): void
{
if (!$this->exists($key)) {
return;
}
try {
$this->fs->delete($key->value);
} catch (FilesystemException $e) {
throw $this->failed('delete', $key, $e);
}
}
private function failed(
string $op,
DocumentKey $key,
FilesystemException $e,
): DocumentStoreFailed {
return new DocumentStoreFailed(
"Document {$op} failed: {$key->value}",
previous: $e,
);
}
}
The exists guard in get and delete costs an extra request per call on a remote backend like S3. It buys a uniform contract: "not found" is a domain exception and deleting twice is safe, whatever the backend does by default. If the round trip matters at your volume, drop the guard and let the contract test tell you whether your backend already behaves that way.
The test adapter is an array:
<?php
namespace App\Billing\Infrastructure;
use App\Billing\Domain\DocumentKey;
use App\Billing\Domain\DocumentNotFound;
use App\Billing\Domain\DocumentStore;
final class InMemoryDocumentStore implements DocumentStore
{
/** @var array<string, string> */
private array $files = [];
public function put(DocumentKey $key, string $bytes): void
{
$this->files[$key->value] = $bytes;
}
public function get(DocumentKey $key): string
{
return $this->files[$key->value]
?? throw DocumentNotFound::at($key);
}
public function exists(DocumentKey $key): bool
{
return isset($this->files[$key->value]);
}
public function delete(DocumentKey $key): void
{
unset($this->files[$key->value]);
}
}
You could instead pass Flysystem's own InMemoryFilesystemAdapter (package league/flysystem-memory) into FlysystemDocumentStore. That is a fine choice too. The hand-rolled array has no dependency and lets the use-case tests run without any Flysystem code at all.
Now the use-case test needs neither the container nor a fake disk:
<?php
namespace Tests\Unit\Billing;
use App\Billing\Application\AttachInvoicePdf;
use App\Billing\Application\InvoiceRejected;
use App\Billing\Infrastructure\InMemoryDocumentStore;
use PHPUnit\Framework\TestCase;
final class AttachInvoicePdfTest extends TestCase
{
public function test_stores_pdf_under_invoice_key(): void
{
$docs = new InMemoryDocumentStore();
$attach = new AttachInvoicePdf($docs);
$key = $attach('inv-42', '%PDF-1.7 body');
self::assertSame('invoices/inv-42.pdf', $key->value);
self::assertSame('%PDF-1.7 body', $docs->get($key));
}
public function test_rejects_a_second_pdf(): void
{
$attach = new AttachInvoicePdf(
new InMemoryDocumentStore(),
);
$attach('inv-42', '%PDF-1.7 first');
$this->expectException(InvoiceRejected::class);
$attach('inv-42', '%PDF-1.7 second');
}
}
It extends PHPUnit's TestCase, not Laravel's. No storage/ directory is touched.
Wiring it in a service provider
The disk choice moves to config, where it belongs. Create config/documents.php:
<?php
return [
'disk' => env('DOCUMENTS_DISK', 'local'),
];
Then bind the port. Storage::disk() returns Laravel's FilesystemAdapter, and its getDriver() method returns the underlying League\Flysystem\FilesystemOperator (see the framework source). That raw operator throws on failure, whatever the disk's throw setting says, which is what the adapter's catch blocks expect.
<?php
namespace App\Providers;
use App\Billing\Domain\DocumentStore;
use App\Billing\Infrastructure\FlysystemDocumentStore;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\ServiceProvider;
final class DocumentStoreServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(DocumentStore::class, function () {
/** @var FilesystemAdapter $disk */
$disk = Storage::disk(config('documents.disk'));
return new FlysystemDocumentStore(
$disk->getDriver(),
);
});
}
}
Add the provider to bootstrap/providers.php. Going to S3 is now an environment change: install league/flysystem-aws-s3-v3 as the S3 driver section describes, fill in the AWS_* variables, and set DOCUMENTS_DISK=s3. The same s3 disk config also talks to S3-compatible services such as Cloudflare R2 or DigitalOcean Spaces through its endpoint option, per the same page. AttachInvoicePdf does not change.
The binding uses bind, not singleton, so a feature test that calls Storage::fake('local') before resolving the use case still gets the fake disk. Your HTTP-level tests keep working the way they did.
The contract test both adapters must pass
The in-memory adapter is only a safe stand-in if it behaves like the real one. Write the expectations once, in an abstract test, and run them against each adapter:
<?php
namespace Tests\Contract;
use App\Billing\Domain\DocumentKey;
use App\Billing\Domain\DocumentNotFound;
use App\Billing\Domain\DocumentStore;
use PHPUnit\Framework\TestCase;
abstract class DocumentStoreContract extends TestCase
{
abstract protected function store(): DocumentStore;
public function test_put_then_get_round_trips_bytes(): void
{
$store = $this->store();
$key = DocumentKey::of('invoices/a.pdf');
$bytes = "%PDF-1.7\x00\xff\n";
$store->put($key, $bytes);
self::assertTrue($store->exists($key));
self::assertSame($bytes, $store->get($key));
}
public function test_put_overwrites(): void
{
$store = $this->store();
$key = DocumentKey::of('invoices/b.pdf');
$store->put($key, 'old');
$store->put($key, 'new');
self::assertSame('new', $store->get($key));
}
public function test_missing_key_is_not_found(): void
{
$store = $this->store();
$key = DocumentKey::of('invoices/none.pdf');
self::assertFalse($store->exists($key));
$this->expectException(DocumentNotFound::class);
$store->get($key);
}
public function test_delete_is_idempotent(): void
{
$store = $this->store();
$key = DocumentKey::of('invoices/c.pdf');
$store->put($key, 'x');
$store->delete($key);
$store->delete($key);
self::assertFalse($store->exists($key));
}
}
The file is named DocumentStoreContract.php, so PHPUnit's default *Test.php discovery skips the abstract class and only runs the concrete ones. A stock Laravel phpunit.xml only scans tests/Unit and tests/Feature, so add a <testsuite name="Contract"> with <directory>tests/Contract</directory> to it, or these tests never run. The in-memory subclass is short:
<?php
namespace Tests\Contract;
use App\Billing\Domain\DocumentStore;
use App\Billing\Infrastructure\InMemoryDocumentStore;
final class InMemoryDocumentStoreTest extends DocumentStoreContract
{
protected function store(): DocumentStore
{
return new InMemoryDocumentStore();
}
}
The Flysystem one runs against a real directory using the local adapter that ships in the core league/flysystem package:
<?php
namespace Tests\Contract;
use App\Billing\Domain\DocumentStore;
use App\Billing\Infrastructure\FlysystemDocumentStore;
use Illuminate\Filesystem\Filesystem as Files;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
final class FlysystemDocumentStoreTest extends DocumentStoreContract
{
private string $root;
protected function setUp(): void
{
$this->root = sys_get_temp_dir()
. '/docs-' . bin2hex(random_bytes(4));
}
protected function tearDown(): void
{
(new Files())->deleteDirectory($this->root);
}
protected function store(): DocumentStore
{
return new FlysystemDocumentStore(new Filesystem(
new LocalFilesystemAdapter($this->root),
));
}
}
When you move to S3, add a third subclass whose store() builds a Filesystem over the S3 adapter pointed at a throwaway bucket, and run it in CI against a local S3-compatible server. If S3 reports "not found" or overwrites differently from your local disk, this is where you find out, before production does.
What this port leaves out, on purpose
The port takes the whole file as a string. For a 5 MB invoice cap that is fine. For video or large exports it is not: add a putStream(DocumentKey $key, $resource) method backed by Flysystem's writeStream, which the Filesystem API docs document alongside write, and add a contract case for it.
It also has no URLs. Temporary download links are a delivery concern, so they belong in a separate port (say, DocumentLinks) implemented with Storage::temporaryUrl(), not in the store the use case writes to. Keep the port as small as the rules that call it. When a new rule needs a new capability, add the method, add the contract case, and both adapters have to earn it.
---
If this was useful
The DocumentStore port is one small instance of the idea Decoupled PHP is built around: the domain names what it needs, Laravel or Symfony gets wired in at the edge, and swapping a vendor turns into a new adapter plus a passing contract test. The book applies the same move across a whole application, keeping the framework at the edge, with runnable examples.

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.
1 reaction
Laravel が @realmpyw に追いつき始めたw
framework#62078
5 likes · 1 repost · 561 views
Mentions
Якщо ти не оновлював свій сайт на Laravel — у тебе можуть бути проблеми. Token-гард + MySQL і версія нижче 12.68/13.29 = чужий вхід без токена. Фікс: composer update laravel/framework. github.com/laravel/framework/security/advisories/…
Laravel'de CVE-2026-114509 numaralı kritik bir güvenlik açığı tespit edildi! Bu açık sayesinde kimliği doğrulanmamış bir saldırgan, başka bir kullanıcının API token'ına sahip olmadan o kullanıcıymış gibi kimlik doğrulaması yapabiliyor! Belirli MySQL/MariaDB yapılandırmalarında, TokenGuard tarafından korunan API endpoint'leri üzerinden başka bir kullanıcının hesabına yetkisiz erişim sağlanabiliyor... Önem derecesi: Critical (CVSS v4.0: 9.2) Bu güvenlik açığı Laravel 12.68.0 ve 13.29.0 sürümlerinde giderildi... Etkilenen sistemlerin güncellenmesi öneriliyor! Detaylar için: - nvd.nist.gov - github.com/laravel/framework/security/advisories/…
Laravel 13 の APP_DEBUG=true は Cookie・本文・SQL を伏せずに出す。本番に残りやすい 4 つの設定を動かして確かめた 新しく作った Laravel 13 のアプリで、本番に残りやすい 4 つの設定を変えながら動かして確かめました。 - APP_DEBUG=true の 500 のエラーページには、Cookie・Authorization を含むリクエストヘッダー、password を含む本文、値を埋め込んだ SQL が伏せずに出ます。 - config/cors.php で allowed_origins を ['*'] のまま supports_credentials を true にすると、どのオリジンから来ても、そのオリジンを cookie 付きで許可します。 - trustProxies(at: '*') にすると、どのクライアントからでも X-Forwarded-For・-Host・-Proto を信じます。 - Telescope は APP_ENV=local のとき、ログインしていなくてもダッシュボードを開けます。 どれも開発中は困らない 1 行の設定なので、本番に残りやすいところです。本番の .env を作る人、またはレビューする人向けに、動かした結果と本番で確認する値をまとめました。 composer create-project laravel/laravel で作った直後の .env は APP_ENV=local、APP_DEBUG=true、LOG_LEVEL=debug という開発用の値です。PHP 全般の本番のエラー表示については 本番のエラー表示で情報を漏らさない7設定 が php.ini・nginx・Apache・WordPress の設定をまとめているので、この記事は Laravel 13 に絞って、実際に動かした結果を扱います。同じ設定を .env や config/*.php から見つける CLI と検証のワークフローは OTA-EITA/laravel-prod-guard に置いてあります。 対象と前提 - laravel/laravel v13.11.0(skeleton)、laravel/framework 13.38.0、laravel/telescope 5.26.0、fruitcake/php-cors v1.4.0 - PHP 8.4、php artisan serve - 2026 年 10 月 29 日に、GitHub Actions の ubuntu-24.04 ランナー(使い捨て)で確認 リクエストに入れた Cookie・トークン・パスワードの値は、検証用に作った目印の文字列です。 本番の env で確認すること 先に、確かめた結果から決めた本番の値を載せます。理由は後ろの節にあります。 | 設定 | 本番の値 | |---|---| | APP_ENV | production | | APP_DEBUG | false | | APP_KEY | php artisan key:generate --show で作った値。.env.example には書かない | | SESSION_SECURE_COOKIE | true(HTTPS で配信している場合) | | LOG_LEVEL | info 以上 | | config/cors.php | credentials を使うなら allowed_origins に自分のフロントエンドのオリジンだけを書く | | trustProxies | ロードバランサーのアドレスを書く。* にするなら、アプリに直接つながる経路をふさぐ | APP_DEBUG=true は、ヘッダー・本文・SQL を伏せずに出す 途中で例外を投げるルートを1つ足し、そこに Cookie・Authorization ヘッダー・パスワードを付けて POST しました。ルートの中では、バインド値つきの SQL を1本だけ流しています。 Route::post('/lab/fail', function () { DB::select('select ? as probe', ['bound-value-7d1c']); throw new RuntimeException('lab failure'); }); curl -X POST http://127.0.0.1:8000/lab/fail \ -H "Cookie: laravel_session=cookie-secret-5e2a" \ -H "Authorization: Bearer token-secret-91fb" \ --data-urlencode "email=lab@example.com" --data-urlencode "password=pass-secret-c3d8" APP_ENV=production、APP_DEBUG=true で返ってきた 500 のページの一部です。 !Laravel 13 のエラーページ。Queries にバインド値を埋め込んだ SQL、Headers に Cookie と Authorization、Body に password がそのまま出ている ソースを読むと、そのとおりの作りです(Exception.php L213-293)。 - ヘッダーは $request->headers->all() をそのまま並べる - 本文は $request->all() をそのまま JSON にする - SQL は、バインド値を ? に埋め込んで表示する 伏せる処理はありません。このエラーページは Laravel 11 の途中で framework に入ったものです(#51261、2024 年 5 月 27 日にマージ)。 APP_DEBUG=false にして同じリクエストを送ると、500 のページにはどの目印も出ませんでした。 何が問題になるのか 見えるのは、そのリクエストを送った本人の Cookie やパスワードです。他人のセッションが見えるわけではありません。問題になるのは次のようなものです。 - 内部の情報: スタックトレース、コードの断片、サーバー上のパス、テーブル名と SQL。攻撃の下調べにそのまま使えます。 - 途中で足されたヘッダー: ロードバランサーや API ゲートウェイが、内部向けの認証トークンやユーザー ID をヘッダーに足してアプリに渡している構成があります。クライアントは本来それを見られませんが、エラーページには並びます。 - SQL に入る他人のデータ: 例外の前に、ほかのユーザーのデータを条件に入れた SQL を流していれば、その値も出ます。 credentials を有効にした CORS は、どのオリジンも cookie 付きで許可する Laravel 13 の skeleton には config/cors.php がなく、framework の既定値が使われます。既定は allowed_origins が ['*']、supports_credentials が false です(config/cors.php L22-32)。 php artisan config:publish cors で config/cors.php を出し、supports_credentials だけを true に書き換えて、api/* の下のルートに次のリクエストを送りました。 curl -s -o /dev/null -D - -H 'Origin: https://evil.example' http://127.0.0.1:8000/api/lab | 設定 | Access-Control-Allow-Origin | Access-Control-Allow-Credentials | |---|---|---| | 既定 | * | なし | | allowed_origins ['*'] + supports_credentials true | https://evil.example | true | ブラウザは * と credentials の組み合わせを受け付けません。そのため、supports_credentials を true にしたとき、Laravel が使っている fruitcake/php-cors は、送られてきた Origin をそのまま返します(CorsService.php L204-219)。CORS としては、どのオリジンにも cookie 付きのレスポンスを読ませる設定です。 実際に cookie が付くかは、cookie の SameSite で決まります。Laravel のセッション cookie は既定で SameSite=Lax なので、仕様どおりなら、まったく別のサイトからの fetch には付きません。同じサイトの別オリジン(ほかのサブドメインなど)からのリクエストや、SESSION_SAME_SITE=none にしている場合は付きます。ここはブラウザでは試していません。 SPA のために「とりあえず credentials を有効にする」と、ここに当たります。 trustProxies('*') だと、クライアントが IP とホストを名乗れる Laravel 11 以降は、bootstrap/app.php で信頼するプロキシを決めます。 ->withMiddleware(function (Middleware $middleware): void { $middleware->trustProxies(at: '*'); }) アプリが認識するクライアントの情報を返すルートを足して、X-Forwarded-* を付けて呼びました。 Route::get('/lab/client', fn (Illuminate\Http\Request $r) => [ 'ip' => $r->ip(), 'secure' => $r->isSecure(), 'host' => $r->getHost(), ]); curl -s -H 'X-Forwarded-For: 203.0.113.9' -H 'X-Forwarded-Host: evil.example' \ -H 'X-Forwarded-Proto: https' http://127.0.0.1:8000/lab/client | 設定 | $request->ip() | isSecure() | getHost() | |---|---|---|---| | 既定(何も書かない) | 127.0.0.1 | false | 127.0.0.1 | | trustProxies(at: '*') | 203.0.113.9 | true | evil.example | * を指定すると、信頼するプロキシが 0.0.0.0/0 と ::/0、つまりすべてのアドレスになります(TrustProxies.php L84-86、L121-124)。ロードバランサーの後ろにしか置かないなら問題ありません。ただ、アプリのポートに直接つなげる経路が残っていると、クライアントが IP アドレスとホスト名を自由に名乗れます。レート制限や監査ログの IP、メールのリンクに使う URL が、その値になります。 APP_ENV=local の Telescope は、ログインなしで開ける Telescope のダッシュボードは、APP_ENV が local のときは認可のゲートを通りません(TelescopeApplicationServiceProvider.php L29-32)。 | APP_ENV | ログインせずに /telescope | |---|---| | local | 200 | | production | 403 | Telescope は、リクエストのヘッダーや本文、SQL、ジョブ、例外を記録して一覧で見せるツールです。APP_ENV=local のまま公開すると、ほかのユーザーのリクエストも含めて誰でも見られます。本人のデータに限られる APP_DEBUG のエラーページより、影響が大きいです。 Telescope は、Authorization と php-auth-pw のヘッダー、password・password_confirmation のパラメーターを既定で伏せます。一方、Cookie ヘッダーは伏せる対象に入っていません(Telescope.php L83-96)。記録されたリクエストから、ほかのユーザーのセッション cookie が見えることになります。エラーページは何も伏せず、Telescope は一部を伏せる、という違いもありました。 設定から見つける ここまでの設定は、どれもファイルを読めば分かります。laravel-prod-guard は、.env、.env.example、composer.json、config/*.php、bootstrap/app.php、public/ を読むだけで、アプリのコードは実行しません。 git clone https://github.com/OTA-EITA/laravel-prod-guard.git php laravel-prod-guard/bin/laravel-prod-guard path/to/app --env path/to/app/.env.production --fail-on medium 実行時の依存はないので、clone したディレクトリの bin/laravel-prod-guard をそのまま PHP 8.2 以上で動かせます。 新しく作ったアプリにそのまま使うと、次のようになります。 HIGH LPG001 .env:4 APP_DEBUG is true MEDIUM LPG002 .env:2 APP_ENV is "local" MEDIUM LPG009 config/session.php:172 SESSION_SECURE_COOKIE is not set LOW LPG012 .env:21 LOG_LEVEL is debug 1 app(s), 4 finding(s): 1 high, 2 medium, 1 low 開発用の .env としてはどれも正しい値です。本番用の env ファイルを --env で渡して、これが残っていないかを CI で確かめる使い方を想定しています。 設定の値は、env ファイル → アプリが公開した config/*.php に書かれた既定値 → framework の既定値、の順に決めます。config/*.php は PHP として実行せず、'key' => env('NAME', default) の形を読み取ります。 見ている項目は全部で 17 個で、--list-checks で一覧が出ます。ここまでの 4 つのほかに、次のようなものを見ます。 - APP_KEY が空・長さ違い、または .env.example に書かれている - .env が git で追跡されている - Debugbar・Clockwork・Ignition が require-dev ではなく require にある - セッション cookie の Secure・HttpOnly・SameSite=None - APP_URL が http - public/ の下の .env、.git、phpinfo() を呼ぶファイル - DB のパスワードが空、またはよくある値 各項目には、根拠になる Laravel 13 のソースの行へのリンクを付けました。 確かめていないこと - Telescope に記録されたほかのユーザーの cookie で、実際になりすませるか - CORS の設定で、ブラウザから実際に cookie が付くか。SameSite の話はソースと仕様から書いています - Horizon。ソースでは Telescope と同じく local のとき認可を通りません(Horizon.php L81-86)が、動かしてはいません - php artisan serve 以外の、nginx や PHP-FPM の前段がある構成 参考 - laravel/laravel v13.11.0 .env.example - laravel/framework v13.38.0 Exceptions/Renderer/Exception.php - laravel/framework #51261 Improves errors - fruitcake/php-cors v1.4.0 CorsService.php - laravel/telescope v5.26.0 TelescopeApplicationServiceProvider.php - laravel/horizon v5.50.1 Horizon.php - OTA-EITA/laravel-prod-guard
Laravel's Built-In JSON:API Resources vs timacdonald/json-api vs laravel-json-api: Which One Your API Needs > Originally published at hafiz.dev --- On Laravel 13.38.0, a request for 20 posts with ?include=author,comments ran 41 queries through the framework's own JSON:API resources. The fix is merged into the 13.x branch. It just isn't in a release yet. I found that while building the same small API three ways: with Laravel's built-in JsonApiResource, with timacdonald/json-api, and with laravel-json-api/laravel. Same three models (users, posts, comments), same 20 posts and 100 comments, on a clean Laravel 13.38.0 project with SQLite. Each request went through a small middleware that counts queries. All output below is real. Two of the three look almost the same from the outside. The third is a different kind of tool. One question tells you which you need. (If you're still deciding how the API itself should behave, my production API practices post is the place to start.) Check your version first If you already use the built-in resources, three things depend on your Laravel version. GHSA-5wcm-3v2w-8362. A moderate advisory, published on 19 October. A crafted request to a read-only endpoint could trigger a destructive operation on a related table through relationship inclusion. It affects 12.45.0 up to 12.68.0, and every 13.x release before 13.27.0. Fixed in 12.68.0 and 13.27.0. Nested includes from 13.27 to 13.36. Release 13.27.0 also changed how resources load relationships (#61322). From then on, nested includes ran one query per related model until #62000 fixed it in 13.37.0. I installed each version and ran the same request, a user with 20 posts and 5 comments per post: GET /api/builtin/users/1?include=posts.comments.author 13.26.0 4 queries 13.36.0 122 queries 13.38.0 4 queries Collection includes, on every release so far. #62000 fixed single resources. Collections still load each item's includes one model at a time. This one predates the regression. Issue #61252 reported 101 queries for 100 records on 13.24. The fix is #62152, merged on 22 October, two days after 13.38.0 shipped: 13.26.0 13.36.0 13.38.0 13.x-dev GET /posts?include=author 21 21 21 2 GET /posts?include=author,comments 41 41 41 3 Until the next tag, eager-load whatever the client asked for. The resource skips relations that are already loaded: Route::get('/posts', function (Request $request) { $includes = array_intersect( explode(',', $request->query('include', '')), ['author', 'comments', 'comments.author'], ); return PostResource::collection(Post::with($includes)->get()); }); GET /api/builtin/eager/posts?include=author 2 queries GET /api/builtin/eager/posts?include=author,comments 3 queries I diffed the response bodies against the version without with(), and they were identical. Keep the allow-list. Eloquent resolves each name passed to with() by calling it as a method on a fresh model instance, so a raw include string lets the client choose which method runs. (If you want the wider picture on spotting N+1 queries, my query optimization post walks through it.) The built-in JsonApiResource It arrived in Laravel 12.45.0 (#57571) and is documented in the API resources docs. php artisan make:resource PostResource --json-api generates a class with two properties, and filling them in is most of the work: namespace App\Http\Resources; use Illuminate\Http\Resources\JsonApi\JsonApiResource; class PostResource extends JsonApiResource { public $attributes = [ 'title', 'body', ]; public $relationships = [ 'author' => UserResource::class, 'comments', ]; } Both relationships resolve on their own. The built-in finds CommentResource and UserResource from the related models, so the explicit class on author is optional. I kept it to show the syntax. A route returns it like any resource, and sparse fieldsets and includes work from the query string: GET /api/builtin/posts/1?include=author&fields[posts]=title&fields[users]=name { "data": { "id": "1", "type": "posts", "attributes": { "title": "Post 1" }, "relationships": { "author": { "data": { "id": "1", "type": "users" } } } }, "included": [ { "id": "1", "type": "users", "attributes": { "name": "Hafiz" } } ] } The response goes out as application/vnd.api+json. A paginator gives you first, last, prev and next links, with Laravel's usual pagination meta and a plain ?page=2 parameter. Nesting stops at five levels by default, and JsonApiResource::maxRelationshipDepth() changes that. toLinks() and toMeta() add per-resource links and meta. That's the whole job. The built-in shapes responses. It doesn't read JSON:API requests or enforce the rest of the spec, and three gaps showed up straight away. It ignores include paths it doesn't know. ?include=secrets returned 200 with the post and no error. The JSON:API spec says a server that can't identify a relationship path "MUST respond with 400 Bad Request". Errors come out in Laravel's own format, not as JSON:API error objects. Here's a 422 from a POST route I wrote by hand: {"message":"The data.attributes.title field is required. (and 1 more error)","errors":{"data.attributes.title":["The data.attributes.title field is required."],"data.attributes.body":["The data.attributes.body field is required."]}} And writes are yours to build. That route validates data.type and data.attributes.* with ordinary rules, then returns the new resource with a 201: Route::post('/posts', function (Request $request) { $validated = $request->validate([ 'data.type' => ['required', 'in:posts'], 'data.attributes.title' => ['required', 'string', 'max:120'], 'data.attributes.body' => ['required', 'string'], ]); $post = $request->user()->posts()->create($validated['data']['attributes']); return (new PostResource($post))->response()->setStatusCode(201); }); It works. But relationships in the request body, error objects with JSON pointers, and content negotiation are all on you. For filters and sorting, the docs point you at Spatie's Laravel Query Builder, which supports Laravel 12 and 13 in its current 7.x release (it needs PHP 8.4). timacdonald/json-api Tim MacDonald's package has been around since its first release in November 2021, and the built-in uses the same property-based API: use TiMacDonald\JsonApi\JsonApiResource; class PostResource extends JsonApiResource { public $attributes = ['title', 'body']; public $relationships = ['author' => UserResource::class, 'comments' => CommentResource::class]; } I compared the package and the built-in on three requests (a single post with includes, the collection, and a user with sparse fieldsets). Once the keys were sorted, the documents were identical. Then I changed only the use line in my three package resources to the built-in Illuminate\Http\Resources\JsonApi\JsonApiResource, and the same three requests still returned identical documents. The other direction needs care. The package guesses the resource class from the relationship key, not from the related model, so 'author' with no class failed with a 500, because there's no AuthorResource. Moving from the package to the built-in was the use line. Moving from the built-in to the package can mean adding explicit classes. The difference is loading. The package doesn't eager-load by design, and its README says so plainly. It recommends Spatie's query builder for that job. Without it: GET /api/tim/users/1?include=posts.comments.author 123 queries GET /api/tim/posts?include=author 22 queries GET /api/tim/posts?include=author,comments 43 queries With the same with() allow-list from earlier, those collection requests dropped to 2 and 3 queries. It's still a pre-release. The latest tag is 1.0.0-beta.12, from March 2026. The repository isn't archived, and its constraints cover Laravel 11, 12 and 13. That makes it the right choice on Laravel 11, or on a 12.x app older than 12.45.0. On anything newer I wouldn't start a new API on it, because the built-in gives you the same API from the framework itself, and the N+1 fix lands in the framework too. If you're already on it, my migration for these three resources was the use line. laravel-json-api/laravel This is a different kind of tool. It isn't a resource class, it's a full JSON:API server. Version 5.3.0 (July 2026) supports Laravel 12.61.1 and up and 13.x. Setup is more work. I published the config, generated a server with php artisan jsonapi:server v1, registered it in config/jsonapi.php, generated three schemas and a request class, registered routes, and added its exception renderer in bootstrap/app.php. A schema describes fields instead of output: class PostSchema extends Schema { public static string $model = Post::class; protected int $maxDepth = 3; public function fields(): array { return [ ID::make(), Str::make('title')->sortable(), Str::make('body'), BelongsTo::make('author')->type('users'), HasMany::make('comments'), DateTime::make('createdAt')->sortable()->readOnly(), DateTime::make('updatedAt')->sortable()->readOnly(), ]; } public function filters(): array { return [ WhereIdIn::make($this), Where::make('author', 'user_id'), ]; } public function pagination(): ?Paginator { return PagePagination::make(); } } JsonApiRoute::server('v1')->prefix('v1')->resources(function ($server) { $server->resource('posts', JsonApiController::class) ->relationships(function ($relations) { $relations->hasOne('author')->readOnly(); $relations->hasMany('comments')->readOnly(); }); $server->resource('users', JsonApiController::class)->only('index', 'show'); }); That route block registered create, update and delete for posts, plus the related-resource and relationship endpoints (/posts/{post}/author, /posts/{post}/relationships/comments), with no controller code of mine. Then it does the things the built-in leaves to you. Includes are eager-loaded with no help: GET /api/v1/users/1?include=posts.comments.author 4 queries GET /api/v1/posts?include=author 2 queries GET /api/v1/posts?include=author,comments 3 queries Include paths are validated. The default $maxDepth is 1, which is why the schema above raises it. Before I did, the nested request came back like this, and so does any path the schema doesn't declare: {"jsonapi":{"version":"1.0"},"errors":[{"detail":"Include path posts.comments.author is not allowed.","source":{"parameter":"include"},"status":"400","title":"Invalid Query Parameter"}]} ?filter[author]=2, ?sort=-title and ?page[number]=2&page[size]=5 worked straight away. Writes are validated against the request class, and the errors point at the exact member of the request document: 422 Unprocessable Entity | The title field is required. | /data/attributes/title 422 Unprocessable Entity | The body field is required. | /data/attributes/body 422 Unprocessable Entity | The author field is required. | /data/relationships/author A POST with a plain application/json body was refused with a 415. A proper document, with the author passed as a relationship, created the post and returned it with a 201. Two costs to know about. Authorization runs through your policies by default. I switched it off for this test with authorizable() returning false on the server, but in a real app you'd write the policies (my policies vs gates guide covers those). And it brings its own conventions, like camelCase createdAt, its own controller, and a schema layer your team has to learn. Which one your API needs | | Built-in JsonApiResource | timacdonald/json-api | laravel-json-api/laravel | |---|---|---|---| | What it is | Resource classes | Resource classes | Full server | | Minimum Laravel | 12.45.0 | 11 | 12.61.1 | | Eager-loads includes | Single resources (13.37+), collections not yet | No, by design | Yes | | Unknown include path | Ignored, 200 | Ignored, 200 | 400 error object | | Filters, sorting | Spatie Query Builder | Spatie Query Builder | Built in | | Writes and validation | Your code | Your code | Request classes, JSON pointers | | Error format | Laravel's | Laravel's | JSON:API error objects | | Status | First-party | 1.0.0-beta.12 | 5.3.0 | The question that decides it is whether clients write through the API. If they only read, and what you need is the JSON:API shape for a front end or a mobile app, use the built-in. You get includes, sparse fieldsets and links from three small classes, and the remaining N+1 is a framework bug with a fix already merged. Add the with() allow-list until it ships. If clients create and update resources through it, or a third party will build against your API and expects the spec enforced (error objects, rejected include paths, filters and pagination by the book), use laravel-json-api. Its setup cost is real, but you'd spend more than that rebuilding its validation and errors by hand on top of the built-in. And timacdonald/json-api is for apps that can't run the built-in yet. FAQ Does Laravel's built-in JSON:API resource support filtering and sorting? No. It shapes responses and handles include and fields. The Laravel docs point to Spatie's Laravel Query Builder for filters and sorts, and its current 7.x release supports Laravel 12 and 13 on PHP 8.4. Can the built-in JsonApiResource accept JSON:API documents in POST or PATCH? Only through your own code. You validate data.attributes.* with normal rules and create the model yourself. Validation errors come back in Laravel's default format, not as JSON:API error objects. Is timacdonald/json-api abandoned? No. The repository isn't archived and the latest release, 1.0.0-beta.12, supports Laravel 11 to 13. It's still a pre-release, though, and on Laravel 12.45.0 or newer the built-in offers the same property-based API. Why does laravel-json-api reject my include with a 400? Either the path isn't a relationship on the schema, or it's deeper than the schema's $maxDepth, which defaults to 1. The JSON:API spec requires a 400 when a server can't support an include path, and the package follows it. Which Laravel version fixes the JSON:API N+1 queries? Nested includes on a single resource are fixed in 13.37.0. Includes on collections are fixed on the 13.x branch by #62152, which was merged after 13.38.0 and should ship in the next 13.x release. Check the release notes for #62152 before you remove a with() workaround. Conclusion Whichever you pick, run composer show laravel/framework before you ship it. Anything from 13.27.0 to 13.36.0 loads nested includes one model at a time, and every release up to 13.38.0 does the same for collection includes until #62152 is tagged.
CVE-2026-114509: Laravel framework TokenGuard API authentication can be bypassed with a crafted non-string credential on some MySQL or MariaDB setups, allowing account takeover. No exploitation confirmed by the vendor. Fixed in 12.68.0 and 13.29.0.
I've never liked that with Laravel having a read replica is complicated. You never knew if your changes on the primary are already there on the replica. You can soon just ask the database if the data is there and optionally wait 🤯 framework#62198 t.co
Laravel 419 Page Expired: Causes and Fixes The Laravel 419 Page Expired screen shows up when you submit a form or send an AJAX request and Laravel cannot match the CSRF token in the request with the one in the user's session. The message says "expired", but often nothing expired: the token was missing, the session cookie never reached the server, or the session was never saved. On cPanel or behind Cloudflare, cookie and caching settings can make the problem harder to spot. Start with the failed request before changing server settings. Quick answer: 419 means a CSRF token mismatch. Check three things first: the form has @csrf (or the AJAX request sends X-CSRF-TOKEN / X-XSRF-TOKEN), the browser actually stores and sends the session cookie (DevTools → Application → Cookies), and the session can be written (storage/framework/sessions writable for the file driver, a sessions table for the database driver). On live servers also check SESSION_DOMAIN, SESSION_SECURE_COOKIE, www vs non-www and page caches, and run php artisan config:clear after editing .env. Why Laravel 419 Page Expired happens Routes in the web middleware group pass through Illuminate\Foundation\Http\Middleware\ValidateCsrfToken. For any request except GET, HEAD and OPTIONS, it reads a token (the _token field, then the X-CSRF-TOKEN header, then the encrypted X-XSRF-TOKEN header) and compares it with the session's token. When the token does not match the current session, the middleware throws Illuminate\Session\TokenMismatchException. Laravel's exception handler converts it into an HttpException with status 419, and the built-in error view shows "Page Expired". Laravel does not log 419s by default, so storage/logs/laravel.log is usually empty when users complain. Most CSRF-related 419 errors fall into one of two situations: | Situation | What it means | Typical causes | |---|---|---| | No token in the request | The form or JS never sent one | Missing @csrf, fetch/axios without a header, wrong axios base URL | | Token sent, but no matching session | The server cannot find the session that issued the token | Session expired, cookie not stored or not sent, session not saved, cached page with an old token | Debug it step by step in the browser Check which part of the request failed before changing settings. Open the form page in Chrome or Edge and press F12. 1. Application → Cookies → your domain. You should see XSRF-TOKEN and the session cookie. Its name comes from APP_NAME: recent Laravel 12 installs with APP_NAME=Laravel use laravel-session, apps created on Laravel 11 or older use laravel_session, and SESSION_COOKIE overrides it. No session cookie → see causes 4 and 5. 2. Check the cookie's Domain and Secure columns. The domain must match the address bar (or be a parent like .example.com). A Secure cookie is not sent back to an http:// page. 3. Network → reload → click the document request → Response Headers. On a normal session-backed web page, look for the expected Set-Cookie headers. If the session cookie is missing and you see cf-cache-status: HIT or a LiteSpeed cache HIT header, inspect whether a cache served the page (cause 7). 4. Submit the form → click the POST request. The Cookie request header must contain the session cookie, and Payload must contain _token (or the request must have an X-CSRF-TOKEN / X-XSRF-TOKEN header for AJAX). 5. Compare the form action with the address bar. A page on https://www.example.com posting to https://example.com (or to http://) is a different cookie scope. Then open storage/logs/laravel.log. A 419 is not logged, but a broken session store shows up as "Permission denied", "failed to open stream" or "Table 'sessions' doesn't exist". Causes 1-3: missing token, AJAX headers, expired session Cause 1: the form has no @csrf Every POST, PUT, PATCH or DELETE form in Blade needs the directive: <form method="POST" action="{{ route('profile.update') }}"> @csrf @method('PUT') <!-- fields --> </form> @csrf prints a hidden _token input. @method('PUT') only spoofs the method and does not replace it. Forms pasted from plain HTML or old projects are where it usually goes missing. Cause 2: AJAX, fetch or axios without the token header Add a CSRF meta tag inside the layout's <head>: <meta name="csrf-token" content="{{ csrf_token() }}"> With jQuery, attach it to every request once: $.ajaxSetup({ headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') } }); fetch() does not automatically add a CSRF token header, so include one: const token = document.querySelector('meta[name="csrf-token"]').content; fetch('/attendance/check-in', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': token }, credentials: 'same-origin', body: JSON.stringify({ employee_id: 15 }) }); credentials: 'same-origin' sends the session cookie. Accept: application/json makes Laravel return a JSON error instead of the HTML page. Axios works differently. Laravel provides an encrypted XSRF-TOKEN cookie on applicable web responses. With the standard same-origin setup, Axios can use the cookie value in the X-XSRF-TOKEN request header. The default Laravel JavaScript setup relies on this convention rather than reading the meta tag. Two things break it: a baseURL on another host (such as http://127.0.0.1:8000 left in production), and putting the meta tag value into X-XSRF-TOKEN. That header must hold the encrypted cookie value; a plain token fails decryption. Send the meta value as X-CSRF-TOKEN. Vue components calling Laravel routes follow the same rules. For a separate-domain SPA, follow Laravel Sanctum's documented cookie and CORS setup. Cause 3: the session really expired The token lives in the session, which ends after SESSION_LIFETIME minutes of inactivity. Laravel 12's config/session.php reads (int) env('SESSION_LIFETIME', 120), so the default is 120 minutes idle. This often shows up when someone submits a form that has been left open for several hours. The token also goes stale when: - Someone logs in or out in another tab. The docs' login code calls $request->session()->regenerate() and logout calls invalidate() plus regenerateToken(), so older forms carry a dead token. - The back button shows a cached copy of a form after logout and login. - SESSION_EXPIRE_ON_CLOSE=true ends the session when the browser closes, and a restored tab submits a dead token. A longer lifetime may be appropriate for some internal tools, but a sensitive app still needs an inactivity policy. For long forms, consider saving drafts. A controlled keep-alive is covered later, but avoid extending unattended sessions. Causes 4-5: the session cookie or the session store is broken If every POST fails, even right after loading the page, the session does not survive between requests. Cause 4: the browser does not store or send the session cookie Safe .env values for a single-domain site on HTTPS: APP_URL=https://example.com SESSION_DRIVER=file SESSION_LIFETIME=120 SESSION_DOMAIN=null SESSION_SECURE_COOKIE=true What goes wrong with each: | Setting | Mistake | Result | |---|---|---| | SESSION_DOMAIN | Set to the old domain, to localhost, or to a domain the site is not on | Browser rejects the cookie, every POST is 419 | | SESSION_SECURE_COOKIE=true | Site opened over http:// (LAN IP, staging subdomain without SSL) | Cookie is Secure-only and never sent back over HTTP | | www vs non-www | Page on www.example.com, form posts to example.com (or the reverse) | With SESSION_DOMAIN=null the cookie belongs to one host only | | APP_URL | Wrong host or http:// while code builds URLs from config('app.url') | Forms and JS post to a different origin than the page | | SESSION_DRIVER=cookie | Large amounts of data put in the session | Browsers cap a single cookie at roughly 4 KB and drop larger ones | SESSION_DOMAIN=null means "this exact host only", which most sites want. Use .example.com only to share the session across subdomains. For www vs non-www, pick one and redirect the other at server level. Cause 5: the session driver cannot save sessions Laravel 12 defaults to the database driver. Common failures on shared hosting: - file: sessions go to storage/framework/sessions. If the folder is missing, not writable, or the account is over its disk quota, nothing is saved. - database: needs a sessions table. Laravel 11 and 12 create it in the default 0001_01_01_000000_create_users_table.php migration, but upgraded apps or partial database imports may lack it. - redis: many shared plans have no Redis, and Laravel needs the PhpRedis extension or the predis/predis package for it. Depending on where it fails you get a 500 or a 419; laravel.log has the real message. Fixes: # file driver: check the folder exists and is owned by your cPanel user ls -la storage/framework/sessions # database driver: check existing migrations first php artisan migrate:status # Only if no sessions-table migration exists: php artisan make:session-table # Inspect and back up before applying pending migrations: php artisan migrate php artisan session:table is the Laravel 10 name; Laravel 11 and 12 use make:session-table. Check phpMyAdmin and php artisan migrate:status first: do not generate a duplicate table migration, and inspect all pending migrations before running them on production. Without Redis, use file or database. Permissions and Terminal steps are in deploying Laravel on cPanel. Causes 6-8: Cloudflare, proxies, page cache and cached config Cause 6: proxy or load balancer causes an http/https mismatch When a proxy ends TLS and talks to your server over plain HTTP, Laravel thinks the request is http:// and builds http:// form URLs. The https:// page then posts to http://, and you get a 419 (Secure cookie not sent), a redirect that turns the POST into a GET, or an "insecure form" warning. For Cloudflare, prefer SSL mode Full (strict) with a valid origin certificate. That secures Cloudflare-to-origin traffic, but Laravel may still need trusted-proxy configuration behind a separate load balancer or TLS-terminating proxy. Avoid Flexible mode for this setup. Behind a load balancer or reverse proxy that ends TLS, trust it in bootstrap/app.php (Laravel 11/12 syntax): ->withMiddleware(function (Middleware $middleware): void { $middleware->trustProxies(at: [ '10.0.0.0/8', ]); }) Use the proxy's IPs or ranges. Laravel also allows trustProxies(at: '*'). Only consider it when untrusted clients cannot reach the application directly; otherwise, forged X-Forwarded-* headers may be accepted. Verify with a route returning request()->secure(): it should be true live. If Cloudflare shows a challenge page that blocks AJAX calls, that is a separate problem: see Cloudflare JS challenge breaking a Laravel SPA. Cause 7: a page cache served an old token A wrongly cached login page may deliver a token from another session or fail to deliver the session cookie. Cloudflare does not cache HTML by default. Custom cache eligibility rules can change this; cookie-bearing responses require particular care. But if a Cache Rule makes HTML eligible and overrides the Edge TTL, Cloudflare can strip Set-Cookie and cache the page, causing 419 errors. - Exclude /login, /register, /password/* and other form pages from cache rules, or do not override the Edge TTL for HTML at all. - On LiteSpeed hosting with LSCache rules in .htaccess, make sure dynamic Laravel pages are not cached. When unsure, remove the rules and retest. - cf-cache-status: HIT on a session-specific form page is strong evidence of misconfigured caching. Confirm that the cached HTML contains session-specific form content. Cause 8: config is cached with old values After php artisan config:cache (or optimize), Laravel loads bootstrap/cache/config.php and ignores .env, so .env changes do nothing. php artisan config:clear # or clear config, routes, views and cache in one go php artisan optimize:clear Run config:cache again if your deployment uses cached configuration. Without terminal access, a competent administrator may remove the generated configuration cache file through File Manager, but should preserve the deployment backup and check permissions first. Excluding routes from CSRF for webhooks Payment and SMS webhooks post without a token because they cannot know it. Exclude only those routes in bootstrap/app.php: ->withMiddleware(function (Middleware $middleware): void { $middleware->validateCsrfTokens(except: [ 'stripe/*', 'webhooks/sms', ]); }) Laravel also allows webhook routes to be handled outside the web middleware group when appropriate. A browser returning from a payment service by a cross-site POST may not send a cookie with SESSION_SAME_SITE=lax. Keep browser return pages separate from authenticated server-to-server webhooks; verify payment using the provider's API or a signed callback rather than trusting browser parameters. Changing SameSite globally is not a safe first fix. When not to do this: never put '*' in the list, never remove ValidateCsrfToken, and never exclude login or admin forms "to stop the 419". That removes cross-site request forgery protection. Fix the cause instead. Show a friendly 419 page and keep sessions alive Create resources/views/errors/419.blade.php and Laravel uses it for every 419 (swap in your own layout): @extends('layouts.app') @section('content') <h1>Your session expired</h1> <p>The form was not submitted. Go back, refresh the page and try again.</p> <a href="{{ url()->previous() }}">Go back</a> @endsection Or redirect back with a message, using the docs' respond hook in bootstrap/app.php: use Symfony\Component\HttpFoundation\Response; ->withExceptions(function (Exceptions $exceptions): void { $exceptions->respond(function (Response $response) { if ($response->getStatusCode() === 419) { return back()->with([ 'message' => 'The page expired, please try again.', ]); } return $response; }); }) back() returns the user to the form without what they typed, so this suits short forms. For long forms (leave requests, payroll entry), add a keep-alive route in routes/web.php: Route::get('/keep-alive', fn () => response()->json(['token' => csrf_token()])) ->middleware('auth'); Call it every 15 minutes on those pages: setInterval(async () => { const res = await fetch('/keep-alive', { credentials: 'same-origin', headers: { 'Accept': 'application/json' } }); if (!res.ok) return; const { token } = await res.json(); document.querySelector('meta[name="csrf-token"]').content = token; document.querySelectorAll('input[name="_token"]').forEach(i => i.value = token); }, 15 * 60 * 1000); Each successful call counts as activity, so an unattended tab may remain logged in indefinitely. Only enable this timer if the app's security policy permits activity-based session extension; otherwise save drafts and require reauthentication. A draft-saving approach is often safer. For shared-hosting setup steps, see the Laravel on cPanel deployment checklist. FAQ Why does Laravel show 419 Page Expired on login only on the live server? Check in DevTools that the session cookie appears after loading the login page. If not, the usual causes are a wrong SESSION_DOMAIN, SESSION_SECURE_COOKIE=true on an http:// address, a cached login page, an unwritable storage/framework/sessions folder or a missing sessions table. Run php artisan config:clear after any .env change. How do I fix CSRF token mismatch in Laravel AJAX requests? Add the csrf-token meta tag to the layout and send its value as X-CSRF-TOKEN on every POST, PUT, PATCH or DELETE. Axios does this itself via the XSRF-TOKEN cookie on same-origin requests. With fetch, add the header and credentials: 'same-origin'. What is the default session lifetime in Laravel 12? 120 minutes of inactivity, from SESSION_LIFETIME in config/session.php. After that the session and its token are gone, and the next submit returns 419. Is it safe to disable CSRF protection to stop the 419 error? No. Exclude only routes called by outside services, such as payment or SMS webhooks, with validateCsrfTokens(except: [...]), and verify them with the provider's signature. Disabling CSRF for normal forms lets other sites submit them on behalf of logged-in users. Sources and documentation - Laravel 12.x: CSRF Protection: laravel.com - Laravel 12.x: HTTP Session: laravel.com - Laravel 12.x: HTTP Requests (Configuring Trusted Proxies): laravel.com - Laravel 12.x: Error Handling (custom error pages, respond): laravel.com - Laravel 12.x: Configuration (configuration caching): laravel.com - Laravel 12.x: Authentication (logout, regenerateToken): laravel.com - Laravel framework 12.x source: Exceptions Handler (TokenMismatchException → 419): github.com/laravel/framework/blob/12.x/src/Illumi… - Laravel framework 12.x source: VerifyCsrfToken middleware: github.com/laravel/framework/blob/12.x/src/Illumi… - Laravel 12.x skeleton: config/session.php: github.com/laravel/laravel/blob/12.x/config/sessi… - Cloudflare: Default cache behavior: developers.cloudflare.com - Cloudflare: Set-Cookie and cache behavior: developers.cloudflare.com --- Originally published at headsntail.com. Free tools for PHP, Laravel and cPanel work: headsntail.com.
Your Users Save and Still See Old Data: Laravel 13.38 Fixes Read Replica Lag, Except in 3 Places > Originally published at hafiz.dev --- I changed a post title to "Q4 pricing update", got the green "Saved" banner, and the heading right under it still said "Q3 pricing update". The debug line on the same page said the request had read from the primary. That was Laravel 13.38.0, with the new PreventStaleDatabaseReads middleware installed exactly the way the docs show it. !After saving "Q4 pricing update", the banner confirms the save but the heading still shows "Q3 pricing update", on a request that read from the primary The middleware is a good idea. Jack Bayliss wrote it after seeing his replicas lag 0 to 1 seconds on average and up to 15 seconds under some workloads. That's long enough for a user to edit a title, refresh, see the old one, and file a bug report. It shipped in v13.38.0 on 20 October. To see what it covers, I set up a MySQL 8.4 primary and a replica in Docker, held the replica 5 seconds behind with delayed replication (SOURCE_DELAY=5), and pointed a clean Laravel 13.38.0 project at both. Five seconds is slow for a healthy replica. That's the point. It makes every gap reproducible on a laptop. All output below is real. The documented setup needs one extra call before it protects the most common kind of page. And three things the middleware never touches still read stale data after you fix it. API routes, queued jobs, and the database cache store, which is where your rate limiter keeps its counts. What sticky already did, and what 13.38 adds Laravel has split reads and writes for years. You give the connection a read and a write server, and set sticky so a request that writes also reads its own writes: 'mysql' => [ 'driver' => 'mysql', 'read' => ['port' => env('DB_READ_PORT', '3306')], 'write' => ['port' => env('DB_WRITE_PORT', '3306')], 'sticky' => true, // host, database, credentials... ], Both of my servers run on 127.0.0.1, so only the port differs. In production you'd override host instead. But sticky only lasts for one request. Save a form, redirect, and the GET that follows is a new request on a clean connection. It reads the replica. PreventStaleDatabaseReads carries the write across requests through the session. This is the core of it, from the framework source: $connection->useWriteConnectionWhenReading( $request->session()->get('_database_modified_at', 0) >= Date::now()->subSeconds((int) $seconds)->getTimestamp() ); $response = $next($request); if ($connection->hasModifiedRecords()) { $request->session()->put('_database_modified_at', Date::now()->getTimestamp()); } Three details in those lines come back later. It calls $request->session(), so it needs a session. It only touches the default connection. And it compares whole-second timestamps, so a 2 second window stays open for somewhere between 2 and 3 seconds. The same release added usingWritePdo(). It runs a callback with every read on that connection sent to the primary, then restores the previous setting, even if the callback throws. Most of the fixes below use it. The documented setup still served the old title The docs tell you to append the middleware to the web group: ->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ PreventStaleDatabaseReads::class, ]); }) I did that, saved a new title, and reloaded the page every second with the same session cookie. The page prints its title and the @@server_id of whichever server answered a select in the route closure. Server 1 is the primary, server 2 the replica. +0.0s PUT title="Q4 pricing update" +0.1s GET title="Q3 pricing update" read from server_id 1 (primary) +1.2s GET title="Q3 pricing update" read from server_id 1 (primary) +2.3s GET title="Q3 pricing update" read from server_id 1 (primary) +3.4s GET title="Q3 pricing update" read from server_id 2 (replica) +4.5s GET title="Q3 pricing update" read from server_id 2 (replica) +5.6s GET title="Q3 pricing update" read from server_id 2 (replica) +6.7s GET title="Q4 pricing update" read from server_id 2 (replica) The middleware did its job. For the first three reloads the query in the closure went to the primary. The title was stale anyway, because it came from $post, and $post came from route model binding. Route model binding happens in SubstituteBindings, which is already in the web group. Appending puts the new middleware after it. Laravel does sort some middleware by a priority list, and SubstituteBindings is on that list. PreventStaleDatabaseReads isn't, so it keeps its appended position. By the time it switches the connection to the primary, the model has already been loaded from the replica. So add it to the priority list: ->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ PreventStaleDatabaseReads::class, ]); $middleware->prependToPriorityList( before: AuthenticatesRequests::class, prepend: PreventStaleDatabaseReads::class, ); }) AuthenticatesRequests is the contract the auth middleware implements, and it sits ahead of SubstituteBindings in Laravel's priority list. Going in before it means the user lookup that auth does reads the primary too. It still runs after StartSession, which it needs. This is the order Laravel resolved for GET /posts/{post} after the change: EncryptCookies AddQueuedCookiesToResponse StartSession ShareErrorsFromSession PreventRequestForgery PreventStaleDatabaseReads SubstituteBindings I tested the auth part too. A logged-in user renames themselves, then reloads a route behind auth that prints auth()->user()->name and the server that answered its own query. With the middleware only appended, as in the docs, the rename from "Hafiz R" to "Hafiz Riaz" didn't show: +0.1s PUT name="Hafiz Riaz" +0.1s GET Hafiz R (server_id 1) +1.1s GET Hafiz R (server_id 1) +2.2s GET Hafiz R (server_id 1) +3.3s GET Hafiz R (server_id 1) That's the name in your nav bar staying wrong after a profile save, on requests that read the primary for everything else. With the priority call in place, the earlier rename from "Hafiz" to "Hafiz R" showed on the first reload: +0.1s PUT name="Hafiz R" +0.1s GET Hafiz R (server_id 1) +1.1s GET Hafiz R (server_id 1) +2.2s GET Hafiz R (server_id 1) +3.3s GET Hafiz R (server_id 1) Back to the post title. Same save, same reloads: +0.0s PUT title="Q4 pricing update" +0.1s GET title="Q4 pricing update" read from server_id 1 (primary) +1.2s GET title="Q4 pricing update" read from server_id 1 (primary) +2.3s GET title="Q4 pricing update" read from server_id 1 (primary) +3.4s GET title="Q3 pricing update" read from server_id 2 (replica) +4.5s GET title="Q3 pricing update" read from server_id 2 (replica) +5.6s GET title="Q3 pricing update" read from server_id 2 (replica) +6.7s GET title="Q4 pricing update" read from server_id 2 (replica) Better, and in one way worse. The user now sees the new title, then the old one again, then the new one. The default window closed after about 3 seconds, and my replica needed about 6. > View the interactive component on hafiz.dev The window has to outlast your replica lag. for() sets it in seconds: $middleware->web(append: [ PreventStaleDatabaseReads::for(6), ]); The priority entry can stay as the plain class name. I printed the resolved order again to check, and PreventStaleDatabaseReads:6 still landed before SubstituteBindings. +0.0s PUT title="Q4 pricing update" +0.1s GET title="Q4 pricing update" read from server_id 1 (primary) +1.2s GET title="Q4 pricing update" read from server_id 1 (primary) +2.3s GET title="Q4 pricing update" read from server_id 1 (primary) +3.4s GET title="Q4 pricing update" read from server_id 1 (primary) +4.5s GET title="Q4 pricing update" read from server_id 1 (primary) +5.7s GET title="Q4 pricing update" read from server_id 1 (primary) +6.8s GET title="Q4 pricing update" read from server_id 2 (replica) !With the middleware moved ahead of route binding and a 6 second window, the heading shows the new title straight after the save Pick the number from your worst observed lag, not your average. On MySQL, Seconds_Behind_Source in SHOW REPLICA STATUS is the figure to watch. Jack's 15 second peak would need for(15) or more. Also, any write on the default connection during a request reopens the window. If a middleware updates last_seen_at on every request, an active user stays on the primary for as long as they keep clicking. Place 1: API routes A stateless API route has no session. Put the middleware in the api group and every request fails (output trimmed): { "message": "Session store not set on request.", "exception": "RuntimeException", ... } Jack flags this in the PR himself ("It's session based, so not ideal for API stuff"). Leave it off, and an API client that saves and reads straight back gets the old value for the whole lag. That part I expected. Sanctum was the surprise. New Sanctum tokens fail until the replica catches up Sanctum looks up a personal access token with a plain find(), which reads the replica. So a token issued a moment ago doesn't exist yet as far as the next request can tell: +0.1s POST /api/tokens -> new token issued +0.1s GET /api/me -> {"message":"Unauthenticated."} 401 +1.2s GET /api/me -> {"message":"Unauthenticated."} 401 +2.3s GET /api/me -> {"message":"Unauthenticated."} 401 +3.3s GET /api/me -> {"message":"Unauthenticated."} 401 +4.4s GET /api/me -> {"message":"Unauthenticated."} 401 +5.5s GET /api/me -> {"message":"Unauthenticated."} 401 +6.5s GET /api/me -> {"name":"Hafiz"} 200 A mobile app that logs in and immediately loads the profile screen hits exactly this. I fixed it with a token model that tries the replica first and only falls back to the primary on a miss: namespace App\Models; use Laravel\Sanctum\PersonalAccessToken as SanctumToken; class PersonalAccessToken extends SanctumToken { public static function findToken($token) { return parent::findToken($token) ?? (new static)->getConnection()->usingWritePdo( fn () => parent::findToken($token)?->load('tokenable') ); } } // AppServiceProvider::boot() Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); My first version had no load('tokenable'). Login worked, but a signup that returned a token still got 401 for 6 seconds. On signup the user row is as new as the token, and Sanctum loads the user later, outside the callback, from the replica. Loading it inside the callback fixed both flows. (If you're still choosing an auth package, Passport vs Sanctum covers that decision.) Sanctum gets one thing right here already. When it updates last_used_at, it puts the connection's "modified" flag back the way it found it, so an authenticated GET doesn't count as a write. Read-your-writes for API clients For the save-then-read case I wrote a version of the middleware keyed by the authenticated user instead of the session: namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; class PreventStaleApiReads { public function handle(Request $request, Closure $next, int $seconds = 2) { if (! $user = $request->user()) { return $next($request); } $connection = DB::connection(); $key = 'recent-write:'.$user->getAuthIdentifier(); $connection->useWriteConnectionWhenReading(Cache::has($key)); $response = $next($request); if ($connection->hasModifiedRecords()) { Cache::put($key, true, $seconds); } return $response; } } It needs the user, so it has to run after auth and before route binding: $middleware->appendToPriorityList( after: AuthenticatesRequests::class, append: PreventStaleApiReads::class, ); // routes/api.php Route::middleware(['auth:sanctum', PreventStaleApiReads::class.':6'])->group(function () { Route::get('/posts/{post}', /* ... */); Route::put('/posts/{post}', /* ... */); }); A token client with no cookies, saving and then reading every second: +0.0s PUT {"title":"Saved by an API client"} +0.1s GET {"title":"Saved by an API client","server_id":1} +1.2s GET {"title":"Saved by an API client","server_id":1} +2.3s GET {"title":"Saved by an API client","server_id":1} +3.3s GET {"title":"Saved by an API client","server_id":1} +4.4s GET {"title":"Saved by an API client","server_id":1} +5.5s GET {"title":"Saved by an API client","server_id":1} +6.7s GET {"title":"Saved by an API client","server_id":2} The flag lives in the cache, so the cache must not read from the replica either. If it does, Cache::has() misses for the whole lag and the middleware does nothing. That's place 3. Place 2: Queued jobs A job runs in a worker process with its own connection. No session, no HTTP middleware, and none of the sticky state from the request that dispatched it. (It's the same reason tenant context goes missing in workers, one of the three multi-tenancy queue bugs I wrote about in June.) Laravel already covers part of this. When a worker rebuilds a model from the job payload, restoreModel() calls useWritePdo(), so $this->post itself comes from the primary. Relations don't. The relations you eager-loaded before dispatching are restored with loadMissing(), which reads the replica. So does every query inside handle(). My test job logs the post title, the comment count through the restored relation, and a direct count query. The route adds a comment, then dispatches the job straight away with $post->load('comments'). The primary had 3 comments when the job ran: SummarisePost {"title":"Saved through the API","comments_relation":2,"comments_query":2} Fresh title. Both counts stale. My first run didn't show this at all, and it took me a minute to see why. I was on the default database queue driver, and reserving a job runs an UPDATE on the jobs table. That marks the connection as modified, and with sticky on, everything the job reads afterwards goes to the primary. When I switched the queue to Redis, the stale reads appeared. Any driver that doesn't write to your replicated connection (Redis, SQS) behaves the same way. The 13.36 option Worker::$flushStickyConnections (#61915) doesn't change the database-driver case either, because the worker flushes before it reserves the next job, and the reserve marks the connection again. The fix I'd use is a job middleware: namespace App\Jobs\Middleware; use Closure; use Illuminate\Support\Facades\DB; class ReadFromPrimary { public function handle(object $job, Closure $next): mixed { return DB::connection()->usingWritePdo(fn () => $next($job)); } } class SummarisePost implements ShouldQueue { use Queueable; public function __construct( #[WithoutRelations] public Post $post, ) { } public function middleware(): array { return [new ReadFromPrimary]; } // handle() unchanged } SummarisePost {"title":"Saved through the API","comments_relation":4,"comments_query":4} Job middleware runs after the job has been unserialized, so without #[WithoutRelations] a relation stored in the payload is already loaded from the replica by then. Leaving the attribute off, with 5 comments on the primary, I got this: SummarisePost {"title":"Saved through the API","comments_relation":4,"comments_query":5} With it, nothing is restored early, and the relation loads lazily inside the middleware's callback. Put ReadFromPrimary on the jobs you dispatch right after a write, not on every job. A report that runs ten minutes later doesn't need it, and routing every job to the primary defeats the point of having a replica. If a job can wait, ->delay(now()->addSeconds(10)) costs nothing at all. Workers have enough lifecycle rules of their own already (like the SIGTERM they get on every deploy), and if you're tuning them for volume, processing 10,000 queue jobs without breaking covers the worker side. Place 3: The database cache store, and your rate limiter A fresh Laravel 13 project ships with CACHE_STORE=database on the default connection. The database cache store reads through that connection with no write-PDO override, so on a replicated connection, cache reads hit the replica. Sessions don't have this problem, because DatabaseSessionHandler adds useWritePdo() to its queries. The cache store doesn't. Rate limiting is where that hurts. I put throttle:3,1 on a stateless login route and sent a request every half second: +0.1s attempt 1 -> 200 +0.7s attempt 2 -> 200 +1.3s attempt 3 -> 200 +1.9s attempt 4 -> 200 +2.5s attempt 5 -> 200 +3.1s attempt 6 -> 200 +3.7s attempt 7 -> 200 +4.3s attempt 8 -> 200 +4.9s attempt 9 -> 200 +5.5s attempt 10 -> 200 +6.1s attempt 11 -> 200 +6.7s attempt 12 -> 200 +7.3s attempt 13 -> 429 +7.9s attempt 14 -> 429 +8.4s attempt 15 -> 429 +9.0s attempt 16 -> 429 allowed: 12 of 16 (limit is 3 per minute) The increments run inside a locked transaction, so they go to the primary and the counter itself is right. But tooManyAttempts() reads the count with a plain cache get, from the replica, which still shows the count from 5 seconds ago. The limiter only starts refusing once the replica has caught up with the third hit. So an attacker gets the limit plus whatever they can send during the lag. At two requests a second that was 12 instead of 3, and a script sends a lot more than two. PreventStaleDatabaseReads doesn't help, because an attacker just drops the session cookie. Point the cache at a connection without a replica: // config/database.php 'mysql_primary' => [ 'driver' => 'mysql', 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_WRITE_PORT', '3306'), 'database' => env('DB_DATABASE', 'laravel'), 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', ], DB_CACHE_CONNECTION=mysql_primary DB_CACHE_LOCK_CONNECTION=mysql_primary In production that connection gets your primary's host. Same burst afterwards: allowed: 3 of 16 (limit is 3 per minute) A Redis cache avoids the problem as well. Either way, the PreventStaleApiReads flag from place 1 depends on this. What I'd ship Move the cache off the replicated connection first, even if you skip everything else in this post. A rate limiter that can be outrun is a security bug, not a UX one, and it's a two-line .env change. Then add PreventStaleDatabaseReads with the priority call and a window longer than your worst lag. Without the priority call it protects the queries in your controllers and misses the model your route is named after. For APIs, the token fallback is cheap. It costs one extra primary query, and only when the replica misses. The user-keyed middleware is worth it only if your clients read right after they write. If your PUT responses already return the saved resource, your clients may never need it. For jobs, wrap the few that run straight after a write and leave the rest alone. Every one of these moves reads onto the primary that the replica was supposed to take. That's the trade. If your lag is routinely more than a few seconds, a longer window hides a replica problem rather than fixing it. FAQ Does PreventStaleDatabaseReads stop other users from seeing stale data? No. It only sends reads to the primary for the session that made the write. Another user viewing the same post during the lag still reads the replica. That's usually fine, because they never saw the new value to begin with. Do I still need the sticky option? Yes. They cover different requests. sticky sends reads to the primary later in the same request that wrote. The middleware covers the requests that come after. I had both on for every test in this post. Does it cover a second database connection? No. It only tracks and switches the default connection, and the docs say so. If tenant or reporting data lives on another connection, wrap those reads in that connection's usingWritePdo(). Why does a 2 second window last longer than 2 seconds? The middleware stores and compares whole-second timestamps. A write at 10.0 and one at 10.9 are both stored as 10, and the window closes when the clock reaches 13. So it stays open between 2 and 3 seconds after the write. In my runs, a reload at 2.3 seconds still went to the primary with the default window. Does the database queue driver protect jobs from stale reads? By accident, when the jobs table lives on the same replicated connection and sticky is on. Reserving the job is a write, so the job reads the primary. Don't rely on it. Switch to Redis or SQS later and the stale reads show up with no code change. Conclusion PreventStaleDatabaseReads fixes the bug Jack described, as long as you add the priority call and size the window to your lag. Both are one-line fixes. Everything else in this post sits outside what a session-based middleware can reach, and none of it needed production traffic to show up. If you run read replicas, start one with SOURCE_DELAY=5 on your laptop and click through your own app before your users do.
Storage::put() in Your Use Case Is a Leak: Put Files Behind a Port in Laravel - Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework - Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- Your invoice upload has been "working" for weeks. Then someone asks for the PDF of invoice 4812 and the bucket has nothing at that key. No exception in the logs, no failed job. The use case called Storage::disk('s3')->put(...), and per the Laravel file storage docs, when a write fails put returns false unless the disk has 'throw' => true in its config. Nobody checked the return value, because nobody thinks of a file write as something that answers with a boolean. That is one cost of calling the Storage facade from inside a use case. Your business rule now depends on a key in config/filesystems.php, and whether a failed write is loud or silent is decided by a config flag the use case never sees. The fix is five small pieces: a DocumentStore interface the domain owns, a Flysystem-backed adapter, an in-memory adapter for fast tests, a service provider binding, and a contract test both adapters pass. Everything targets PHP 8.3+ and Laravel 13, which requires PHP 8.3 as its minimum. What the facade call costs you Here is the shape that shows up in a lot of Laravel codebases: <?php namespace App\Billing; use Illuminate\Support\Facades\Storage; final class AttachInvoicePdf { public function __invoke(string $invoiceId, string $pdf): string { $path = "invoices/{$invoiceId}.pdf"; if (Storage::disk('s3')->exists($path)) { throw new \DomainException('Already attached'); } Storage::disk('s3')->put($path, $pdf); return $path; } } Three things leak out of those few lines. The disk name. 's3' is an infrastructure decision written into a billing rule. Giving invoices their own disk, or running them on local in staging, means editing the use case, even though nothing about billing changed. The failure mode. put reports failure as false by default. Laravel's FilesystemAdapter catches UnableToWriteFile, rethrows it only when the disk's throw option is set, and passes it to the exception handler only when the disk's report option is set; both default to false in the framework source. The code above ignores the return value, so the write can fail without anyone hearing about it. The test. To exercise this rule you need a booted Laravel application and Storage::fake('s3'). That fake is not an in-memory map: the Storage facade source builds a local driver rooted at storage/framework/testing/disks/{disk} and cleans that directory on each call. Your billing rule test now touches the real filesystem and the framework container to check one if. The port: four methods the domain actually needs Start from what the billing code needs, not from what Flysystem offers. It needs to store bytes under a key, read them back, ask whether something is there, and remove it. That is the whole port. <?php namespace App\Billing\Domain; interface DocumentStore { /** @throws DocumentStoreFailed */ public function put(DocumentKey $key, string $bytes): void; /** @throws DocumentNotFound|DocumentStoreFailed */ public function get(DocumentKey $key): string; /** @throws DocumentStoreFailed */ public function exists(DocumentKey $key): bool; /** Deleting a missing key is a no-op. */ public function delete(DocumentKey $key): void; } put returns void. Failure is an exception, always, regardless of which adapter sits behind it. That single decision removes the silent-false problem at the boundary. The key is a value object, so path rules live in one place: <?php namespace App\Billing\Domain; final readonly class DocumentKey { private function __construct(public string $value) {} public static function of(string $value): self { if ( $value === '' || str_starts_with($value, '/') || str_contains($value, '..') ) { throw new \InvalidArgumentException( "Invalid document key: {$value}" ); } return new self($value); } public static function forInvoice(string $invoiceId): self { return self::of("invoices/{$invoiceId}.pdf"); } } An invoice id of ../../etc/passwd never reaches an adapter. The two exceptions are plain domain types: <?php namespace App\Billing\Domain; final class DocumentNotFound extends \RuntimeException { public static function at(DocumentKey $key): self { return new self("No document at {$key->value}"); } } final class DocumentStoreFailed extends \RuntimeException {} In a real project, give each class its own file so Composer's PSR-4 autoloader finds it. They share a block here to save scrolling. With the port in place, the use case loses every trace of Laravel: <?php namespace App\Billing\Application; use App\Billing\Domain\DocumentKey; use App\Billing\Domain\DocumentStore; final readonly class AttachInvoicePdf { private const MAX_BYTES = 5 * 1024 * 1024; public function __construct(private DocumentStore $documents) {} public function __invoke( string $invoiceId, string $pdf, ): DocumentKey { if (!str_starts_with($pdf, '%PDF-')) { throw new InvoiceRejected('Not a PDF'); } if (strlen($pdf) > self::MAX_BYTES) { throw new InvoiceRejected('PDF over 5 MB'); } $key = DocumentKey::forInvoice($invoiceId); if ($this->documents->exists($key)) { throw new InvoiceRejected( "Invoice {$invoiceId} already has a PDF" ); } $this->documents->put($key, $pdf); return $key; } } InvoiceRejected is a one-line final class InvoiceRejected extends \DomainException {}. The rules (PDF magic bytes, size cap, one PDF per invoice) read top to bottom with no disk names and no facade. The controller turns the upload into a string and calls the use case; that is where $request->file('pdf') belongs. Two adapters: Flysystem for real, an array for tests The Laravel docs open by saying the filesystem layer is built on Flysystem, and Laravel 13's composer.json requires league/flysystem ^3.25.1. So you already have it installed. The production adapter wraps Flysystem's FilesystemOperator and translates its exceptions. Every Flysystem exception implements the League\Flysystem\FilesystemException marker interface, per the exception handling docs, so one catch covers write, read, existence-check and delete failures. <?php namespace App\Billing\Infrastructure; use App\Billing\Domain\DocumentKey; use App\Billing\Domain\DocumentNotFound; use App\Billing\Domain\DocumentStore; use App\Billing\Domain\DocumentStoreFailed; use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemOperator; final readonly class FlysystemDocumentStore implements DocumentStore { public function __construct( private FilesystemOperator $fs, ) {} public function put(DocumentKey $key, string $bytes): void { try { $this->fs->write($key->value, $bytes); } catch (FilesystemException $e) { throw $this->failed('write', $key, $e); } } public function get(DocumentKey $key): string { if (!$this->exists($key)) { throw DocumentNotFound::at($key); } try { return $this->fs->read($key->value); } catch (FilesystemException $e) { throw $this->failed('read', $key, $e); } } public function exists(DocumentKey $key): bool { try { return $this->fs->fileExists($key->value); } catch (FilesystemException $e) { throw $this->failed('check', $key, $e); } } public function delete(DocumentKey $key): void { if (!$this->exists($key)) { return; } try { $this->fs->delete($key->value); } catch (FilesystemException $e) { throw $this->failed('delete', $key, $e); } } private function failed( string $op, DocumentKey $key, FilesystemException $e, ): DocumentStoreFailed { return new DocumentStoreFailed( "Document {$op} failed: {$key->value}", previous: $e, ); } } The exists guard in get and delete costs an extra request per call on a remote backend like S3. It buys a uniform contract: "not found" is a domain exception and deleting twice is safe, whatever the backend does by default. If the round trip matters at your volume, drop the guard and let the contract test tell you whether your backend already behaves that way. The test adapter is an array: <?php namespace App\Billing\Infrastructure; use App\Billing\Domain\DocumentKey; use App\Billing\Domain\DocumentNotFound; use App\Billing\Domain\DocumentStore; final class InMemoryDocumentStore implements DocumentStore { /** @var array<string, string> */ private array $files = []; public function put(DocumentKey $key, string $bytes): void { $this->files[$key->value] = $bytes; } public function get(DocumentKey $key): string { return $this->files[$key->value] ?? throw DocumentNotFound::at($key); } public function exists(DocumentKey $key): bool { return isset($this->files[$key->value]); } public function delete(DocumentKey $key): void { unset($this->files[$key->value]); } } You could instead pass Flysystem's own InMemoryFilesystemAdapter (package league/flysystem-memory) into FlysystemDocumentStore. That is a fine choice too. The hand-rolled array has no dependency and lets the use-case tests run without any Flysystem code at all. Now the use-case test needs neither the container nor a fake disk: <?php namespace Tests\Unit\Billing; use App\Billing\Application\AttachInvoicePdf; use App\Billing\Application\InvoiceRejected; use App\Billing\Infrastructure\InMemoryDocumentStore; use PHPUnit\Framework\TestCase; final class AttachInvoicePdfTest extends TestCase { public function test_stores_pdf_under_invoice_key(): void { $docs = new InMemoryDocumentStore(); $attach = new AttachInvoicePdf($docs); $key = $attach('inv-42', '%PDF-1.7 body'); self::assertSame('invoices/inv-42.pdf', $key->value); self::assertSame('%PDF-1.7 body', $docs->get($key)); } public function test_rejects_a_second_pdf(): void { $attach = new AttachInvoicePdf( new InMemoryDocumentStore(), ); $attach('inv-42', '%PDF-1.7 first'); $this->expectException(InvoiceRejected::class); $attach('inv-42', '%PDF-1.7 second'); } } It extends PHPUnit's TestCase, not Laravel's. No storage/ directory is touched. Wiring it in a service provider The disk choice moves to config, where it belongs. Create config/documents.php: <?php return [ 'disk' => env('DOCUMENTS_DISK', 'local'), ]; Then bind the port. Storage::disk() returns Laravel's FilesystemAdapter, and its getDriver() method returns the underlying League\Flysystem\FilesystemOperator (see the framework source). That raw operator throws on failure, whatever the disk's throw setting says, which is what the adapter's catch blocks expect. <?php namespace App\Providers; use App\Billing\Domain\DocumentStore; use App\Billing\Infrastructure\FlysystemDocumentStore; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Facades\Storage; use Illuminate\Support\ServiceProvider; final class DocumentStoreServiceProvider extends ServiceProvider { public function register(): void { $this->app->bind(DocumentStore::class, function () { /** @var FilesystemAdapter $disk */ $disk = Storage::disk(config('documents.disk')); return new FlysystemDocumentStore( $disk->getDriver(), ); }); } } Add the provider to bootstrap/providers.php. Going to S3 is now an environment change: install league/flysystem-aws-s3-v3 as the S3 driver section describes, fill in the AWS_* variables, and set DOCUMENTS_DISK=s3. The same s3 disk config also talks to S3-compatible services such as Cloudflare R2 or DigitalOcean Spaces through its endpoint option, per the same page. AttachInvoicePdf does not change. The binding uses bind, not singleton, so a feature test that calls Storage::fake('local') before resolving the use case still gets the fake disk. Your HTTP-level tests keep working the way they did. The contract test both adapters must pass The in-memory adapter is only a safe stand-in if it behaves like the real one. Write the expectations once, in an abstract test, and run them against each adapter: <?php namespace Tests\Contract; use App\Billing\Domain\DocumentKey; use App\Billing\Domain\DocumentNotFound; use App\Billing\Domain\DocumentStore; use PHPUnit\Framework\TestCase; abstract class DocumentStoreContract extends TestCase { abstract protected function store(): DocumentStore; public function test_put_then_get_round_trips_bytes(): void { $store = $this->store(); $key = DocumentKey::of('invoices/a.pdf'); $bytes = "%PDF-1.7\x00\xff\n"; $store->put($key, $bytes); self::assertTrue($store->exists($key)); self::assertSame($bytes, $store->get($key)); } public function test_put_overwrites(): void { $store = $this->store(); $key = DocumentKey::of('invoices/b.pdf'); $store->put($key, 'old'); $store->put($key, 'new'); self::assertSame('new', $store->get($key)); } public function test_missing_key_is_not_found(): void { $store = $this->store(); $key = DocumentKey::of('invoices/none.pdf'); self::assertFalse($store->exists($key)); $this->expectException(DocumentNotFound::class); $store->get($key); } public function test_delete_is_idempotent(): void { $store = $this->store(); $key = DocumentKey::of('invoices/c.pdf'); $store->put($key, 'x'); $store->delete($key); $store->delete($key); self::assertFalse($store->exists($key)); } } The file is named DocumentStoreContract.php, so PHPUnit's default *Test.php discovery skips the abstract class and only runs the concrete ones. A stock Laravel phpunit.xml only scans tests/Unit and tests/Feature, so add a <testsuite name="Contract"> with <directory>tests/Contract</directory> to it, or these tests never run. The in-memory subclass is short: <?php namespace Tests\Contract; use App\Billing\Domain\DocumentStore; use App\Billing\Infrastructure\InMemoryDocumentStore; final class InMemoryDocumentStoreTest extends DocumentStoreContract { protected function store(): DocumentStore { return new InMemoryDocumentStore(); } } The Flysystem one runs against a real directory using the local adapter that ships in the core league/flysystem package: <?php namespace Tests\Contract; use App\Billing\Domain\DocumentStore; use App\Billing\Infrastructure\FlysystemDocumentStore; use Illuminate\Filesystem\Filesystem as Files; use League\Flysystem\Filesystem; use League\Flysystem\Local\LocalFilesystemAdapter; final class FlysystemDocumentStoreTest extends DocumentStoreContract { private string $root; protected function setUp(): void { $this->root = sys_get_temp_dir() . '/docs-' . bin2hex(random_bytes(4)); } protected function tearDown(): void { (new Files())->deleteDirectory($this->root); } protected function store(): DocumentStore { return new FlysystemDocumentStore(new Filesystem( new LocalFilesystemAdapter($this->root), )); } } When you move to S3, add a third subclass whose store() builds a Filesystem over the S3 adapter pointed at a throwaway bucket, and run it in CI against a local S3-compatible server. If S3 reports "not found" or overwrites differently from your local disk, this is where you find out, before production does. What this port leaves out, on purpose The port takes the whole file as a string. For a 5 MB invoice cap that is fine. For video or large exports it is not: add a putStream(DocumentKey $key, $resource) method backed by Flysystem's writeStream, which the Filesystem API docs document alongside write, and add a contract case for it. It also has no URLs. Temporary download links are a delivery concern, so they belong in a separate port (say, DocumentLinks) implemented with Storage::temporaryUrl(), not in the store the use case writes to. Keep the port as small as the rules that call it. When a new rule needs a new capability, add the method, add the contract case, and both adapters have to earn it. --- If this was useful The DocumentStore port is one small instance of the idea Decoupled PHP is built around: the domain names what it needs, Laravel or Symfony gets wired in at the edge, and swapping a vendor turns into a new adapter plus a passing contract test. The book applies the same move across a whole application, keeping the framework at the edge, with runnable examples.  Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.
Laravel が @realmpyw に追いつき始めたw framework#62078