
この「画面からはつながらないけど、サーバー側では動いている」という状態が、CORS問題の最も厄介なところだ。LaravelがAPIを拒否したのか、Nginxがレスポンスを途中で書き換えたのか、Viteの開発サーバーが別コンテナで待ち受けているのか、原因が複数ある。
結論を先に言えば、CORSはLaravelの設定ファイルを一本直せば終わる問題ではない。ブラウザが認識するオリジン、Laravelのレスポンス、NginxやViteの設定、そしてDockerで公開したポートまでを一つの流れとして追う必要がある。
CORSは、Laravelの接続拒否ではない
CORS(オリジン間リソース共有)は、ブラウザが別オリジンから取得したレスポンスをJavaScriptで読み取ってもよいか判断する仕組みだ。ここでいうオリジンは、プロトコル、ホスト名、ポート番号を 조합したものである。
たとえば次の二つは、どちらも画面から見ると「同じLaravel」に見えるが、ブラウザにとっては別オリジンだ。
| URLの比較 | オリジンとして異なる理由 |
|---|---|
http://localhost:5173 と http://localhost:8000 | ポート番号が異なる |
http://localhost:8000 と http://127.0.0.1:8000 | ホスト名が異なる |
http://app.local と https://app.local | プロトコルが異なる |
https://app.example.com と https://api.example.com | サブドメインが異なる |
そのため、フロントエンドを http://localhost:5173 で起動し、APIを http://localhost:8000 で起動した時点で、すでにCORSを考える必要がある。これはDockerを使ったかどうかとは無関係の、ブラウザの仕様だ。
ブラウザが主に確認するレスポンスヘッダーは、次のあたりだ。
| ヘッダー | 役割 |
|---|---|
Access-Control-Allow-Origin | アクセスを許可するオリジン |
Access-Control-Allow-Methods | GET、POST、PUT、DELETEなどの許可メソッド |
Access-Control-Allow-Headers | AuthorizationやContent-Typeなどの許可ヘッダー |
Access-Control-Allow-Credentials | Cookieなどの認証情報を伴うリクエストを許可するかどうか |
通常のGETリクエストだけであれば、ブラウザはそのままレスポンスを受け取る。しかし、Authorizationを付けたり、条件によってはJSONを送信したりすると、ブラウザは先にOPTIONSという事前確認リクエストを送ることがある。この事前リクエストに対してLaravelやNginxが適切なレスポンスを返さなければ、実際のAPIが正常でも画面からは使えない。
CORSはサーバーへの接続許可ではなく、ブラウザがレスポンスを読んでもよいかを決めるルールだ。
なお、ERR_CONNECTION_REFUSEDや502 Bad GatewayはCORS以前の問題である。コンテナが停止している、ポートが公開されていない、プロキシ先が間違っているといった話だ。画面に出る文字列だけで判断せず、Networkパネルでどのリクエストが失敗しているかを見る必要がある。
まずブラウザが認識するURLを固定する
CORSを調べるときは、ソースコードに書いたAPIURLと、ブラウザが実際に要求したURLを分けて考える。Dockerでは、この二つがすぐに食い違うという罠がある。
たとえばDocker ComposeでフロントエンドとLaravelを別コンテナに分けた場合、サーバー側の設定ファイルには次のような名前が登場する。
frontendappapinginxhttp://app:9000http://api:8000
しかし、ブラウザがWebページを開くために使うURLは、通常はホストマシン上のlocalhostだ。コンテナ内部で通用するサービス名を、ブラウザがそのまま解決できるとは限らない。
フロントエンドのコードがhttp://api:8000/api/usersを直接指定していたとしても、ブラウザのアドレスバーにhttp://api:8000と入力できる環境でなければ接続できない。http://api:8000は、Dockerネットワーク内のサーバー間通信には使えても、ホスト側のブラウザにとってはアクセス先ではない。
まずは、次の五つを同じ紙に書き出すと早い。
1. ブラウザのアドレスバーに表示されているURL
2. fetchまたはAxiosに指定したURL
3. Dockerでホスト側に公開したポート
4. NginxやViteがプロキシ転送する先のURL
5. 最終的にブラウザが到達したリダイレクト先のURL
localhostと127.0.0.1も見た目は似ているが、ブラウザから見ると異なるオリジンになることがある。Laravel側ではhttp://localhost:8000を許可しているのに、フロントエンドがhttp://127.0.0.1:5173からアクセスしていれば、それは設定ミスになる。どちらも「同じPC」を指すからといって、ブラウザのオリジンまでは同じにならない。
開発環境では、Viteのproxyを使う方法もある。SPAから/api/...という相対URLでリクエストし、ViteがLaravelの内部URLへ転送する構成だ。ブラウザから見るとリクエスト先はViteになるため、フロントエンドとViteが同じオリジンならCORSを回避できる。ただし、これは開発用のプロキシであり、ビルド後の本番ファイルをNginxが直接配信する構成まで引きずる設定ではない。
本番で同じ構成が必要なら、Nginxが/api/...をLaravelへ転送する-reverse proxyにする。ようするに、開発中のViteと本番のNginxは別モノだ。npm run devで動いたからといって、distを配置した本番環境でも同じ動きになるとは限らない。
LaravelとSanctumの設定をAPIの契約として扱う
Laravel側の基本設定はconfig/cors.phpにある。ここで重要なのは、値を増やすことではなく、フロントエンドが送るリクエストと一致させることだ。
config/cors.phpでOriginをそろえる
LaravelのCORS設定には、少なくとも次の項目を関係させて考える。
allowed_originsallowed_methodsallowed_headerspathssupports_credentials
allowed_originsには、ブラウザが実際に送るOriginを基準に書く。開発用のSPAなら、http://localhost:5173のように、プロトコルからポートまで含めた形が基準になる。http://localhost:5173/のようなパス表記をオリジンとして扱わないことも大切だ。ブラウザがOriginとして送信する文字列と、Laravelが照合する文字列を一致させる。
複数の環境を扱う場合、開発用と本番用を安易に混在させない。
'allowed_origins' => [
'http://localhost:5173',
'https://app.example.com',
],これはイメージだが、実際のプロジェクトではローカル、ステージング、本番をすべて同じ配列へ放り込みがちだ。しかし、その中に不要なOriginを残すと、認証情報を使う構成では影響範囲が広がる。許可するオリジンは、仕様として必要なものだけを列挙する方が扱いやすい。
allowed_methodsには、APIが受け付けるHTTPメソッドを設定する。GETだけでいいAPIに、やたらとすべてのメソッドを許可する必要はない。フロントエンドがPATCHを使うなら許可リストにPATCHが必要だし、OPTIONSが返ることも考える。
pathsも重要になる。LaravelのCORS設定では、たとえばapi/*やsanctum/csrf-cookieなどのパスを対象にする。API本体が/api/...にあっても、CSRF用のエンドポイントが別のパスならそちらもCORSの対象に含める必要がある。
ブラウザがAuthorizationやContent-Typeを許可対象として事前確認してきた場合、サーバー側のAccess-Control-Allow-Headersに必要なヘッダーが含まれていなければならない。許可メソッドと許可ヘッダーは、フロントエンドの実装を見て決める。Laravel側の想像で決めない。
SanctumのCookie認証はCORSだけでは終わらない
SPA認証でLaravel Sanctumを使い、ブラウザからセッションCookieを送る設計なら、config/cors.phpのsupports_credentialsをtrueにする必要がある。設定ファイルを触るだけでCookieが送られるわけではない。フロントエンド側でも、認証情報をリクエストに含める指定が必要だ。
ブラウザのfetchならcredentialsを適切に設定し、AxiosならwithCredentialsをtrueにする。Cookieを使わないBearerトークン方式なら、認証の設計そのものが異なるため、supports_credentialsの必要性も変わってくる。トークンをAuthorizationヘッダーで送るのか、Cookieでセッションを維持するのかを先に決めるべきだ。
Sanctumを使う構成では、次の三点もCORSとは別の問題として現れる。
/sanctum/csrf-cookieへ正常に到達するか- Cookieの
SameSiteやSecureが実行環境と一致しているか - フロントエンドとAPIでOriginの認識が一致しているか
つまり、supports_credentials = trueにしただけで認証が終わると思わない方がいい。Cookie、CSRF、Origin、フロントエンドの送信設定がセットになって、初めてブラウザの認証通信として成立する。
Sanctumを入れているのにCORSで止まった場合、Laravelのsupports_credentialsだけを直そうとすると大体どこかで詰む。設定キャッシュとデバッグ出力を疑う
Docker環境でconfig/cors.phpを編集したのに反映されない場合、まず疑うべきは設定キャッシュだ。Laravelには設定をキャッシュする運用があるため、ソースファイルを直した後でphp artisan config:clearを実行し、本番相当の設定をキャッシュするならphp artisan config:cacheを実行する。コマンドはホストではなく、Laravelが動くアプリケーション用コンテナで実行する。
ファイルをボリュームマウントしている開発環境でも、キャッシュが残っていることがある。フロントエンドだけをスーパーリロードして「まだ直らない」と騒ぐ前に、Laravel側のキャッシュを疑う方が早い。
もう一つ、厄介なのがdump()やdd()、あるいはechoなどのデバッグ出力だ。APIの処理の途中でこれらが出力され、レスポンスヘッダーの確定より前に本文へ文字列が混ざると、ブラウザ側はCORSエラーとして受け取る場合がある。
Laravelのログに大量のデータを出したくなる気持ちは分かる。だが、APIレスポンスにデバッグ出力が残っている時点で、それはログではなくレスポンスだ。dump()やdd()を本番イメージから除去し、ブラウザのNetworkでレスポンス本文がJSONになっているか確認する。HTMLや配列の出力、メッセージが本文に混ざっているなら、ヘッダーを追加する前にその原因を片付ける。
NginxとViteでは設定する層が変わる
Docker上の構成で一番混乱しやすいのが、NginxとLaravelのどちらでCORSヘッダーを付けるかだ。設定ファイルが二つあると、つい両方へ同じヘッダーを追加したくなる。しかし、同じ名前のヘッダーを複数層で出すと、値が衝突してCORSエラーの原因になることがある。
開発時のViteは、ブラウザに近い場所で受ける
Vite開発サーバーを使う場合、ブラウザはまずViteへアクセスする。ViteがDockerコンテナ内でlocalhostだけを見て待機していると、コンテナの内部にはつながるが、ホスト側のブラウザからは見えないことがある。
Viteの設定では、ホストを0.0.0.0に開き、ポートを5173で待ち受け、Dockerではそのポートをホスト側へ公開する。設定イメージは次のようになる。
server: {
host: '0.0.0.0',
port: 5173
}これは正確な設定例というより、ViteがDocker内の自分自身のアドレスだけを見ないためのものだ。0.0.0.0はブラウザのOriginを表す設定ではない。ブラウザがhttp://localhost:5173でアクセスするなら、Docker側ではそのポートをホストへ公開する必要がある。
開発中にLaravelへ直接アクセスするなら、ポートマッピングを忘れていないか、公開用のポートとLaravel内部のポートを取り違えていないかを確認する。コンテナ内部のサービスが9000で待っていても、ブラウザが接続できるのはDockerで公開した側のポートだ。
Viteのproxyを使う場合も、油断はできない。server.proxyにLaravelのURLを書いただけでは、ブラウザが直接そのURLへアクセスできるわけではない。Viteが受け取った/api/...を、内部のLaravelへ転送して初めて成立する。
Viteのproxyは開発用の一時的な道である。npm run buildで生成したファイルをNginxが配信する構成なら、ビルド後のファイルにViteのproxy設定は残らない。本番用のNginxルーティングを別に用意する必要がある。
本番のNginxは、レスポンスの出口を見る
NginxがSPAのビルド成果物だけを直接配信し、LaravelをNginx経由で呼ばない構成では、config/cors.phpの設定がAPIレスポンスに反映されないことがある。ブラウザから見えているファイルがLaravelの処理を通っていないためだ。
この場合、CORSヘッダーはNginx側へ追加する。典型的な構成を分けると、次のようになる。
| 構成 | CORSを設定する主な場所 | 注意点 |
|---|---|---|
| Vite開発サーバー+Laravel | Viteのproxy、またはLaravel側 | Viteの待ち受けとポート公開を確認する |
| Nginx+Laravelのreverse proxy | 実際にAPIレスポンスを返す層 | 二重にヘッダーを出さない |
| Nginxが静的SPAのみ配信+Laravelが別経路 | NginxのAPI応答処理 | Laravelの設定が直接効かないことがある |
| SPAとAPIが同じOrigin | 基本的にはCORSは不要 | reverse proxyの転送先はあってもよい |
「NginxとLaravelの両方で同じAccess-Control-Allow-Originを追加すれば確実に直る」と考えるのは危険だ。どちらがレスポンスを最終的に返すのかを確認し、ヘッダーを付ける層を一つに絞る。
同じオリジンで公開できるなら、CORSに頭を悩ませるよりreverse proxyでAPIを同じURLへ隠す方が運用しやすい。ただしこれは、ブラウザから見たOriginを本当に同じにできる場合に限る。フロントエンドがhttps://app.example.com、APIがhttps://api.example.comなら、ドメインをまたぐ時点で別オリジンだ。Nginxの転送設定が同じサーバー内部にあっても、ブラウザのCORS判定までは同じにならない。
また、認証情報を伴うリクエストでAccess-Control-Allow-Origin: *を使うのは避けた方がよい。ワイルドカードではなく、許可する具体的なOriginを返し、Access-Control-Allow-Credentialsを適切に設定する。これは設定を増やす話ではなく、ブラウザが「誰からのリクエストなのか」を確定するための処理だ。
Networkを見ながら原因を一段ずつ削る
CORSエラーは、Consoleに表示された一行だけを修正しても直らないことがある。原因を早く見つけるなら、Networkパネルで失敗したリクエストそのものを開く。
1. 失敗したURLとOriginを確認する
最初に見るのは、Consoleのメッセージではなく、ブラウザが実際に送ったリクエストだ。
- リクエスト先のURL
- HTTPメソッド
- リクエストヘッダー
- ブラウザが送ったOrigin
- レスポンスのステータス
Access-Control-Allow-Originなどのレスポンスヘッダー
ここで、ソースコード上のbaseURLとNetwork上のURLが違っていれば、ブラウザのせいではなくURL解決の設定を直すべきだ。localhost、127.0.0.1、Dockerのサービス名のどこかでOriginがずれているケースは多い。
2. OPTIONSが飛んでいるかを見る
OPTIONSが飛んでいる場合と、飛んでいない場合では、疑う場所が変わる。
| Networkで見た状態 | まず疑う場所 |
|---|---|
OPTIONSが404や405 | CORSの対象パス、ルート、Nginxの転送 |
OPTIONSが401や403 | Sanctum、CSRF、認証ミドルウェア |
OPTIONSが200でもCORSエラー | 許可ヘッダー、Origin、credentials |
OPTIONS自体がない | ブラウザが別オリジンと認識していない可能性 |
実際のAPIリクエストより先にOPTIONSが失敗しているなら、config/cors.phpのpathsやallowed_methods、Nginxのlocationを確認する。Laravelまでリクエストが届いていなければ、Laravelの設定ファイルをいくら編集しても反応しない。
3. Access-Control-Allow-Originを照合する
Laravelが返した許可Originと、ブラウザが送ったOriginが文字列として一致しているかを確認する。許可リストにhttp://localhost:5173があるのに、ブラウザがhttp://127.0.0.1:5173から送っていれば、それは不一致だ。
Cookieを使う構成で、レスポンスがワイルドカードになっている場合も要注意だ。CORSの許可Originを具体的なURLへ変更し、認証情報を伴うリクエストとして成立させる。設定上はCookieを使っていないのに、昔の名残でsupports_credentialsが有効になっていることもある。
4. 許可メソッドと許可ヘッダーを比べる
ブラウザがAuthorizationを付けて事前確認を送っているなら、Access-Control-Allow-Headersにauthorizationが含まれているか確認する。JSONをContent-Type: application/jsonで送る場合も、ヘッダーの扱いをブラウザ任せにせず、Laravel側の許可設定と照合する。
「API本体は動いているのに、画面からだけPOSTできない」という状態では、GET用の設定が残っていることがある。SPA側のフォームがPOSTへ切り替わった、axiosのデフォルト設定が変わった、Nginxのルーティングが変わったといった変更履歴も役に立つ。
5. ステータスコードと本文を確認する
CORSエラーは、APIが401、403、500を返した事実を覆い隠すことがある。ブラウザが本文を読めず、CORSポリシー違反として表示するためだ。
だからといって、Access-Control-Allow-Origin: *を付ければよいわけではない。Laravelのエラーログ、Nginxのアクセスログ、PHPの実行ログを確認し、APIが本来返したかったエラーを先に直す。dump()やdd()の出力、例外メッセージ、スタックトレースがレスポンスへ混ざっているケースもある。
curlのcurl -iでヘッダーを確認するのは有効だが、curlが成功した時点でブラウザでも成功するとは限らない。ブラウザのCORS確認、Cookie送信、事前リクエスト、リダイレクト判定まではcurlが完全には再現しない。最終確認は、やはりブラウザのNetworkで行う。
6. 開発環境と本番環境を別々に確認する
Viteの開発サーバーで直った設定を、そのまま本番のNginxへ持っていくと大体ハマる。
- 開発中だけViteのproxyが効いていた
- 本番ではビルド後の静的ファイルをNginxが直接配信していた
localhost:5173向けだったOriginを本番URLへ変更していなかった- Dockerの公開ポートではなくコンテナ内部のポートをブラウザへ指定していた
「ローカルでは動く」という情報は、原因を一つに絞るには役立つが、完了条件にはならない。Dockerで実際に公開したURLと、ブラウザが使うOriginで、本番相当のSPAビルドを確認する。
コードとインフラの線引きを運用に残す
CORS設定は、コードで固定する部分と、環境やインフラで固定する部分を分けると管理しやすい。
コード側に置くべきものは、たとえば次のようなものだ。
- 許可するOrigin
- APIが受け付けるメソッド
- 必要なリクエストヘッダー
- SanctumのCookie認証を使う場合のcredentials設定
- 開発時のVite proxy
- APIのベースURLを解決する設定
環境やインフラ側で管理すべきものは、こちらになる。
- Dockerでホストへ公開するポート
- NginxがLaravelへ転送するパス
- 実際のAPIドメイン
- 本番のHTTPS設定
- どの層でCORSヘッダーを付加するか
- キャッシュクリアやデバッグ出力の運用
コードへhttp://localhost:5173を大量に埋め込むのも避けたい。Originを環境変数や設定ファイルへ分離し、開発、ステージング、本番で同じ実装を使えるようにする。ただし、ポートを省略して表記しただけではオリジンは一致しない。ブラウザが送る実際のURLまで指定する。
Nginxのヘッダー設定とLaravelのCORS設定を二重管理しないことも重要だ。どちらがレスポンスを返すのか分からないまま両方へ設定を増やすと、片方を直したつもりでもう片方が原因になる。力が強い設定を追加し続けるより、出口を一つに絞った方が長期的には安全だ。
開発環境のViteと本番のNginxを同じ設定だと思ってはいけない。allowed_originsはブラウザが実際に使うURLで確定し、ヘッダーを足す層は一つに絞る。結局のところ、laravel spa corsエラー dockerの解決は、Laravelのconfig/cors.phpだけを見ていては完結しない。ブラウザが送ったOriginとポートを確認し、事前リクエストがLaravelまで届いているか追う。Dockerで公開したURLを直したと思ったら、次はNginxの転送、Viteの待ち受け、SanctumのCookie設定と順々に削っていく。
この切り分けができていれば、CORSエラーは「何かの儀式が必要な Laravelの設定」ではなくなる。コードで保証する境界と、DockerやNginxなど運用で保証する境界を分け、実際にブラウザが使うURLを基準に調整する。それが、原因不明のCORSエラーを、再現可能な不具合へ変える一番現実的な手順だ。
Related reading: Docker・インフラ構築についてよくある質問 and Docker・インフラ構築をわかりやすく解説.