laravel

Stars and mentions

Last 30 days, Oct 5 – Nov 3

GitHub stars591
mentions77
011223344Oct 5Oct 19Nov 3
UTC days · repositories laravel owns27 repositories

Package downloads

Cumulative recorded downloads · since 2025-10-19

npm192K
048K96K144K192KOct 5Oct 19Nov 3
Oct 17npm: 1/1 packages observed

Across 1 known packages in repositories laravel owns. Cumulative totals include recorded downloads before the selected range. Missing observations leave gaps; totals may be incomplete.

Top repositories

laravel’s repositories ranked by new stars, as on trending.

Loading

Contributes to

Repositories outside laravel’s account where laravel opened or reviewed pull requests in the past 365 days.

Who noticed laravel

Everyone who starred or forked laravel’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

77posts
Nov 40 posts
  1. Reddit · · r/laravel · u/3liusef · laravel/mcp

    Just built a package that turns what your Laravel app already declares (routes, Artisan commands, jobs, actions and models) into an MCP server for AI agents. Every time I connected an AI agent to one of my Laravel apps, I ended up writing the same thing twice. A Tool class per action, the validation rules copied in, the same rules again as JSON Schema, and a permission check sitting right next to a policy that already did that job. It worked, but it never felt like Laravel. So I built Laravel MCP Bridge, on top of the official laravel/mcp. composer require kayedspace/laravel-mcp-bridge The idea is simple: your app already knows what its inputs look like and who's allowed to do what, so let the agent use that. This is the whole integration for a route: class CourseController extends Controller { #[McpTool(description: 'Create a draft course.')] public function store(CourseStoreRequest $request) { // unchanged } } The agent gets a create_course tool, and its input schema comes from your FormRequest (required|string|max:120 becomes a required string of max 120 chars). When the agent calls it, the call goes through your actual route: your middleware authenticates it, your FormRequest validates it, your policy authorizes it. If a user can't do something in your app, their agent can't either. Things I cared about: - Nothing is exposed until you mark it. If you'd rather expose the whole API, MCP_BRIDGE_MODE=opt-out does that for routes, and #[McpIgnore] keeps any route out. - Commands, jobs and models stay opt-in in every mode. They have no middleware to inherit, so each one declares a token scope and a Gate ability, and a model needs a policy method for each operation. - Two servers, kept apart. Your API routes live on /mcp for agents acting as your users. Commands, jobs and models live on /mcp/ops for operators, with their own rate limit. - Agents retry and guess, so it plans for that. Destructive tools ask for confirmation with a single-use handle, writes take an idempotency key, and an argument the tool doesn't know is refused with the names it does accept. - Tools come grouped the way your app already is. Each tool gets a group from `#[McpGroup]`, from the groups your Scramble or Scribe docs already declare, or from the module it lives in. A server claims groups in its config, so you can send one controller's tools to `/mcp/ops` without moving anything else. - The day-to-day bits: an audit row per call, a kill switch that needs no deploy, checks in php artisan doctor, a lint for CI, and php artisan mcp-bridge:connect prints the setup for Claude Code, Codex, Gemini CLI, Copilot CLI, Cursor, VS Code and more. - Your users can bring their own agent. They issue a token from a small panel or your own screen, paste your /mcp URL into Claude, ChatGPT, Cursor or Codex, and the agent acts as them. Sanctum tokens, Passport OAuth and custom guards like JWT work too. If MCP is part of a paid plan, one accessMcp gate decides who gets in : If you already have hand-written laravel/mcp tools, they keep working, and you can list them on the package's server so agents get everything from one URL. It also reads Spatie Query Builder chains, route model binding and Scramble or Scribe attributes, so list endpoints come out with real filter and sort inputs. Status: this is the first release, 0.1.0, on PHP 8.3+ with Laravel 12 or 13. If you clone the repo, composer serve starts a demo app with a live /mcp endpoint you can point Claude Code at. I'd really love your feedback. A few places where another pair of eyes would mean a lot: 1. Does a route tool really get everything a normal request gets? If your app does something in middleware or the exception handler that you think a tool call might miss, please tell me. 2. Token scopes with Sanctum, Passport or a custom guard in a real app. 3. Queued job tools re-checking the Gate in the worker, on the queue setups you actually run. Issues, ideas and PRs are all welcome. If you find something security-related, please use a private advisory on the repo rather than a public issue. Thanks for reading, and thanks to the Laravel team for laravel/mcp, which made this possible Docs: laravel-mcp-bridge.kayed.dev Github: kayedspace/laravel-mcp-bridge

    2 comments
  2. 開発者向けSNS /dev/null をWSL2とLaravel Sailでローカル起動してみた dev-null.shとは /dev/null は、開発者向けのオープンソースSNSです。GitHubアカウントでログインし、開発者同士でフォローしたり、投稿(Commitと呼ばれています)や返信をしたりできます。 Laravel界隈で知られる Nuno Maduro、Brent Roose、Matthieu Napoli、Pauline Vos らがライブ配信で開発を始めたプロジェクトで、現在はコミュニティからのPull Requestも受け付けながら開発が進められています。 ソースコードはGitHubでMITライセンスのもと公開されており、Laravel 13、Inertia.js、React、PostgreSQLなどを使って構築されています。 開発開始から間もないにもかかわらず、かなりの勢いで機能が追加されているのも特徴です。今回は、このアプリケーションを実際にローカル環境で動かしてみます。 利用されている技術スタック 主な技術スタックは以下のとおりです(2026年11月3日時点)。 分類 技術 言語 PHP 8.5、TypeScript バックエンド Laravel 13 フロントエンド React 19、Inertia.js 3 CSS / UI Tailwind CSS 4、Radix UI ビルドツール Vite+ パッケージ管理 Composer、Bun 1.4 データベース PostgreSQL 17以上 DB拡張 pgvector、pg_trgm 認証 Laravel Socialite(GitHub OAuth) なおベースはLaravel Starter Kitのnunoカスタムが使われています データベースにはPostgreSQLを採用しており、ベクトル検索のためのpgvectorと、文字列検索向けのpg_trgm拡張が必要です。 動かしてみる 筆者の環境ではWSL2(Debian)を利用しています。今回はLaravel Sailを使ってDockerコンテナ上で動かすため、DockerとDocker Composeが利用できるLinux環境であれば、おおむね同じ手順で構築できるはずです。 リポジトリをclone git clone dev-null-sh/dev-null.sh cd dev-null.sh 公式READMEではcomposer setupで環境を構築できますが、このコマンドは依存パッケージのインストールからDBのマイグレーション、フロントエンドのビルドまで実行します。 今回はLaravel Sailを利用するため、まずはComposerの依存パッケージだけをインストールします。 ホスト側のPHPバージョンや拡張モジュールの要件チェックを無視してインストール composer install --ignore-platform-reqs --ignore-platform-reqsは、ホスト側のPHP環境がプロジェクトの要件を満たしていなくても依存パッケージをインストールするためのオプションです。実際のアプリケーションは後ほどSailのコンテナ内で実行します。なお、標準の依存パッケージにはlaravel/sailが含まれていないので、後ほど追加します。 ! ここでLaravel Vetというツールから次のメッセージが表示されました。 Vet has no trust entry in [vet.json] yet. Run [./vendor/bin/vet --init] to record what you trust today. Laravel VetはComposerの依存パッケージの変更を監査するためのツールです。これは脆弱性が検出されたという警告ではなく、信頼済みパッケージの初期登録がまだ行われていないという通知です。 laravel/sail を開発依存に追加 composer.json @@ -35,6 +35,7 @@ "laravel/pail": "^1.2.7", "laravel/pao": "^1.1.5", "laravel/pint": "^1.32.1", - "laravel/sail": "^1.69", "laravel/tinker": "^3.0.2", "laravel/vet": "^0.2.1", "mockery/mockery": "^1.6.15", これでcomposer.lockを更新し、Sailをインストールします。 Sailの依存関係だけを更新 composer update laravel/sail --ignore-platform-reqs --no-scripts なお、--no-scriptsを付けているのは、このプロジェクトのpost-update-cmdで別の依存パッケージの更新処理も実行されるためです。 Sailの設定ファイルを生成する PostgreSQLとPHP 8.5を使用するSail環境を生成 php artisan sail:install --with=pgsql --php=8.5 これでcompose.yamlなどの設定ファイルが生成されます。 この後、コンテナを起動します。 Sailのコンテナを起動 ./vendor/bin/sail up 特にポートを変更していなければ、http://localhost:8000でアクセスできます。 ただし、まだDBの初期化を行っていないため、アプリケーションにアクセスするとエラーになります。 おなじみのマイグレーション未実行エラー DB初期化 まず、マイグレーションとSeederを実行してDBを初期化します。 テーブルを作成し、初期データを投入 ./vendor/bin/sail artisan migrate:fresh --seed ところが、ここでエラーが発生しました。 pgvectorがみつからないエラー このアプリケーションではPostgreSQLのpgvector拡張を使用していますが、Sailが生成した標準のPostgreSQLイメージには含まれていません。 そこで、compose.yaml のPostgreSQLイメージをpgvector入りのものに差し替えます。 compose.yaml @@ -24,7 +24,7 @@ services: depends_on: - pgsql pgsql: - image: 'postgres:18-alpine' - image: 'pgvector/pgvector:pg18' ports: - '${FORWARD_DB_PORT:-5432}:5432' environment: イメージの変更を反映するため、Sailを再起動します。 コンテナを停止・削除(DBのデータボリュームは保持) ./vendor/bin/sail down 新しいイメージでコンテナを起動 ./vendor/bin/sail up -d 改めてマイグレーションを実行します。 DBを初期化し、Seederを実行 ./vendor/bin/sail artisan migrate:fresh --seed 今度は問題なく完了しました。 all done ! migrate:freshは既存のテーブルをすべて削除して再作成します。今回は新規の開発環境なので問題ありませんが、既存データのあるDBでは注意してください。 フロントエンド 続いて、フロントエンドの依存パッケージをインストールし、ビルドします。 ロックファイルに従って依存パッケージをインストール ./vendor/bin/sail bun install --frozen-lockfile 本番用アセットをビルド ./vendor/bin/sail bun run build これでビルドまで完了です。 さらに、ホットリロード(HMR)を利用するため、Vite+の開発サーバーを起動します。 Vite+の開発サーバーを起動 ./vendor/bin/sail bun run dev 無事に起動。最初から表示されている投稿はSeederで投入されたダミーデータ ログインしてみる dev-null.shのログイン通常はGitHub OAuthによる認証のみを利用しますが、開発モード(APP_ENV=local)ではGitHubの認証情報なしでローカルログインできます。任意のハンドル名を入力すればログインできるので、動作確認のためにGitHub OAuthアプリを登録する必要はありません。 開発モードでのローカルログイン Seederをざっくり日本語にしてみる 初期状態ではSeederによって英語のダミー投稿が投入されています。せっかくなので、投稿内容をざっくり日本語に変更してみます。AI任せです。 まあAIで 変更後、Seederを再実行します。 DBを初期化し、変更したSeederのデータを投入 ./vendor/bin/sail artisan migrate:fresh --seed 日本語のダミー投稿が並ぶようになりました Laravel Starter Kitをベースに、Inertia.jsとReactで構築されているため、こうした改変も比較的簡単にできます。Laravelに慣れている人なら、ソースコードを追いながらあれこれ手を加えてみるのも面白そうです。 あとがき 今回はローカル環境で起動し、Seederを改変するところまで試してみました。自分も普段からLaravel + Inertia.js + Reactでアプリケーションを開発しているので、かなり馴染みのある構成です。Laravel Starter Kitをベースにしていることもあり、コードを追うのも改変するのも比較的やりやすい印象でした。 中身を覗いてみると、Trendingのランキングなどもなかなか興味深い実装です。機械学習モデルに頼るのではなく、投稿への反応や経過時間、フォロー関係などをSQLでスコアリングしています。特にランキングやレコメンデーションの実装は、一から考えるとなかなか面倒な部分なので、実際に動いているコードが読めるのはありがたいところです。 筆者のアカウント dev-null.sh

  3. config() Inside Your PHP Domain Is a Hidden Dependency. Inject Typed Settings Instead - 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 --- Someone tidies up config/billing.php and renames trial_days to trial_length_days. They update the two call sites their editor found. The suite stays green, because no test asserts how long a trial lasts. The deploy goes out. The next customer who signs up gets a free trial that ends the second it starts. Nothing threw. The use case read config('billing.trial_days'), got null for a key that no longer exists, cast it with (int), and the PHP manual is blunt about what that does: null is always converted to zero. A zero-day trial is a perfectly valid integer. The rename is the trigger, but the bug is older. A domain class reached out to the framework for a value it never declared. The use case that only runs inside Laravel This is the shape you find in a lot of Laravel codebases. The domain lives under src/Domain, mapped as its own PSR-4 namespace in Composer's composer.json, but it still calls the global helper: <?php declare(strict_types=1); namespace Domain\Billing; use DateInterval; use DateTimeImmutable; final class StartTrial { public function __construct( private SubscriptionRepository $subscriptions, ) {} public function handle( string $accountId, DateTimeImmutable $now, ): Subscription { $days = (int) config('billing.trial_days'); $subscription = new Subscription( accountId: $accountId, currency: config('billing.currency'), trialEndsAt: $now->add( new DateInterval("P{$days}D"), ), ); $this->subscriptions->save($subscription); return $subscription; } } Three things are wrong with it, and only one of them is the rename. It only runs inside a booted app. The config() helper is app('config')->get($key, $default), and app() asks the global container to make('config'). In a plain PHPUnit test there is no booted application, so nothing is bound under config, and the container's build() throws a BindingResolutionException with the message Target class [config] does not exist. Your unit test for a date calculation now needs Laravel's TestCase and a full boot. The constructor lies. The signature says StartTrial needs a repository. It also needs two config keys, and the only way to learn that is to read the method body. A reviewer, a new teammate, or a coding agent wiring this class up gets no hint from the type system. A renamed key fails quietly. The Laravel 13 configuration docs say config() returns the default when the option does not exist, and the helper's default is null. Cast it and you get the zero-day trial from the opening. Skip the cast and null travels further before something else trips over it. !A small hexagon-shaped domain module with a thin hidden wire running out of its floor, under the ground, to a large framework machine The use case looks self-contained until you follow the wire it never declared. A readonly settings object, validated once The fix is to name the inputs. Billing needs a trial length and a currency, so give the domain a class that holds exactly those, with the rules the business actually has: <?php declare(strict_types=1); namespace Domain\Billing; use InvalidArgumentException; final readonly class BillingSettings { public function __construct( public int $trialDays, public string $currency, ) { if ($trialDays < 1 || $trialDays > 90) { throw new InvalidArgumentException( "trialDays must be 1..90, got {$trialDays}", ); } if (preg_match('/^[A-Z]{3}$/', $currency) !== 1) { throw new InvalidArgumentException( "currency must be 3 uppercase letters, " . "got '{$currency}'", ); } } } Readonly classes arrived in PHP 8.2: every declared property becomes readonly and dynamic properties are refused. Per the properties page, a readonly property is initialized once and any later write throws an Error. Once a BillingSettings exists, it is valid, and it stays that way for the life of the request. The range check is where the zero-day trial dies. Even if a bad cast slips through upstream, 0 never becomes a settings object. The use case now asks for what it uses: final class StartTrial { public function __construct( private SubscriptionRepository $subscriptions, private BillingSettings $settings, ) {} public function handle( string $accountId, DateTimeImmutable $now, ): Subscription { $days = $this->settings->trialDays; $subscription = new Subscription( accountId: $accountId, currency: $this->settings->currency, trialEndsAt: $now->add( new DateInterval("P{$days}D"), ), ); $this->subscriptions->save($subscription); return $subscription; } } For completeness, the two small types it touches: final readonly class Subscription { public function __construct( public string $accountId, public string $currency, public DateTimeImmutable $trialEndsAt, ) {} } interface SubscriptionRepository { public function save(Subscription $subscription): void; } No use Illuminate\... anywhere in src/Domain. The time is already passed in as $now, for the same reason: the clock is a dependency too. The service provider is the adapter Something still has to read the config file. That job belongs at the edge, in the framework layer, and Laravel's service providers are where bindings go. Start with the config file itself, because it has a trap: <?php // config/billing.php return [ 'trial_days' => (int) env('BILLING_TRIAL_DAYS', 14), 'currency' => env('BILLING_CURRENCY', 'EUR'), ]; The environment variable types table is clear that .env values are parsed as strings, apart from a few reserved words like true, false and null. Without the (int), BILLING_TRIAL_DAYS=30 reaches your code as the string "30". Keep env() here and nowhere else: the docs warn that once you run config:cache, the .env file is no longer loaded and env() only returns system-level variables. Now the provider: <?php declare(strict_types=1); namespace App\Providers; use Domain\Billing\BillingSettings; use Illuminate\Support\Facades\Config; use Illuminate\Support\ServiceProvider; final class BillingServiceProvider extends ServiceProvider { public function register(): void { $this->app->singleton( BillingSettings::class, fn () => new BillingSettings( trialDays: Config::integer('billing.trial_days'), currency: Config::string('billing.currency'), ), ); } public function boot(): void { // Fail at boot, not on the first signup. $this->app->make(BillingSettings::class); } } Config::integer() and Config::string() are the typed retrieval methods on the facade. In the config repository source, integer() checks is_int() and otherwise throws an InvalidArgumentException with the message pattern Configuration value for key [%s] must be an integer, %s given. Rename the key again and you get that exception, naming the key, instead of a quiet zero. The boot() line is optional and cheap: it builds one small object per boot. What it buys you is timing. A bad value now fails the first request or artisan command after the deploy, with a message that names the field, instead of failing hours later inside whichever signup happens to hit it. The providers docs say boot() runs after every provider has registered, so the config repository is there when you need it. make:provider adds the class to bootstrap/providers.php for you; if you write it by hand, add it there yourself. Because StartTrial only depends on concrete classes and an interface you bind elsewhere, the container resolves it with no extra wiring. !A sealed hexagon receiving one typed card through a narrow slot from an adapter machine standing at its edge Settings cross the boundary once, as a typed object, through an adapter that lives outside the domain. The test needs no framework The domain test extends PHPUnit's own TestCase, not Laravel's, and builds everything with new: <?php declare(strict_types=1); namespace Tests\Unit\Billing; use DateTimeImmutable; use Domain\Billing\BillingSettings; use Domain\Billing\StartTrial; use Domain\Billing\Subscription; use Domain\Billing\SubscriptionRepository; use InvalidArgumentException; use PHPUnit\Framework\TestCase; final class StartTrialTest extends TestCase { public function test_trial_ends_after_setting(): void { $repo = new class implements SubscriptionRepository { /** @var list<Subscription> */ public array $saved = []; public function save(Subscription $s): void { $this->saved[] = $s; } }; $useCase = new StartTrial( $repo, new BillingSettings(trialDays: 14, currency: 'EUR'), ); $sub = $useCase->handle( 'acc_1', new DateTimeImmutable('2026-11-02T10:00:00+00:00'), ); self::assertSame( '2026-11-16', $sub->trialEndsAt->format('Y-m-d'), ); self::assertSame('EUR', $sub->currency); self::assertCount(1, $repo->saved); } public function test_zero_day_trial_is_rejected(): void { $this->expectException(InvalidArgumentException::class); new BillingSettings(trialDays: 0, currency: 'EUR'); } } No RefreshDatabase, no Config::set(), no app boot. Want to check a 30-day trial? Pass 30. The test reads like the business rule, and the second test pins the zero-day trial from the opening so it cannot come back. What about #[Config] and giveConfig? Laravel has two built-in ways to inject config values, and both are better than calling the helper in a method body. The contextual attributes let you write #[Config('billing.trial_days')] int $trialDays in a constructor. That puts the dependency in the signature, where people can see it, with almost no boilerplate. The cost is that your domain class now imports Illuminate\Container\Attributes\Config, and the docs' own re-implementation of the attribute shows it resolving through config->get($key, $default), so any validation is whatever your constructor does on its own. giveConfig keeps the framework out of the class: $this->app->when(StartTrial::class)->needs('$trialDays')->giveConfig('billing.trial_days'). For one primitive in one class, that is a fine choice. Once three use cases need the same four settings and the same range rules, a value object reads better than four loose primitives bound in four places. If you are on Laravel and staying on Laravel, #[Config] in an application service is a reasonable trade. Inside src/Domain, keep it a plain object. Find every leak with one grep Now find the others. The first line catches calls to the global config(), env() and app() helpers under the domain, and skips methods like ->config( or $config(: grep -rnE '(^|[^[:alnum:]_>:$])(config|env|app)\(' src/Domain grep -rn 'Illuminate\\' src/Domain grep -rn 'Config::' src/Domain No output means clean. The second line catches the imported facade, the #[Config] attribute and anything else imported from the framework. The third is there because Laravel still registers default facade aliases, so \Config::get(...) works without a single Illuminate\ string in the file. To stop new ones in CI: if grep -rnE '(^|[^[:alnum:]_>:$])(config|env|app)\(' src/Domain \ || grep -rn 'Config::' src/Domain then echo "framework settings read inside src/Domain" >&2 exit 1 fi If you already run Pest, its architecture expectations accept global function names as well as namespaces, so the same rule fits in one test: arch('domain reads no framework settings') ->expect('Domain') ->not->toUse([ 'config', 'env', 'app', 'Config', 'Illuminate', ]); Work through the matches one bounded context at a time. Each match becomes one field on a settings object and one line in a provider, and its test stops needing the framework. --- If this was useful Settings are one of the quieter ways the framework leaks into the domain, along with the clock and the facade. Decoupled PHP is about keeping all of those at the edge, with ports, adapters and the tests that prove the boundary holds, so your use cases keep working when Laravel or Symfony moves under them. ![Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework](amazon.com) Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

    1 reaction
  4. Якщо ти не оновлював свій сайт на Laravel — у тебе можуть бути проблеми. Token-гард + MySQL і версія нижче 12.68/13.29 = чужий вхід без токена. Фікс: composer update laravel/framework. github.com/laravel/framework/security/advisories/…

    1 like
  5. Reddit · · r/orizora · u/erhanurgun · laravel/framework

    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
  6. 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
  7. 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
  8. 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.

  9. 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
  10. 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
Trending awards, every completed day and week
Gold
41st place finishes
Silver
62nd place finishes
Bronze
93rd place finishes
Awarded repositories
5Owned by laravel now

Repositories

Ordered by gold, then silver, then bronze.

  1. laravelBest: 1st by stars, 62 stars223
  2. docsBest: 1st by PR authors, 7 PR authors122
  3. octaneBest: 1st by stars, 700 stars111
  4. lumenBest: 2nd by stars, 499 stars012
  5. frameworkBest: 3rd by PR authors, 9 PR authors001

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.