フロントエンド・API連携

Docker環境のLaravelとSPA連携で発生したCORSエラーの解決手順

http://localhost:5173 で動くSPAから、http://localhost:8000/api/... にリクエストを送ったとき、ブラウザのコンソールにはCORS関連のエラーが出る。けれどもcurlで同じURLを叩けばJSONが返ってくるし、Dockerのコンテナも生きている。…

Docker環境のLaravelとSPA連携で発生したCORSエラーの解決手順

この「画面からはつながらないけど、サーバー側では動いている」という状態が、CORS問題の最も厄介なところだ。LaravelがAPIを拒否したのか、Nginxがレスポンスを途中で書き換えたのか、Viteの開発サーバーが別コンテナで待ち受けているのか、原因が複数ある。

結論を先に言えば、CORSはLaravelの設定ファイルを一本直せば終わる問題ではない。ブラウザが認識するオリジン、Laravelのレスポンス、NginxやViteの設定、そしてDockerで公開したポートまでを一つの流れとして追う必要がある。

CORSは、Laravelの接続拒否ではない

CORS(オリジン間リソース共有)は、ブラウザが別オリジンから取得したレスポンスをJavaScriptで読み取ってもよいか判断する仕組みだ。ここでいうオリジンは、プロトコル、ホスト名、ポート番号を 조합したものである。

たとえば次の二つは、どちらも画面から見ると「同じLaravel」に見えるが、ブラウザにとっては別オリジンだ。

URLの比較オリジンとして異なる理由
http://localhost:5173http://localhost:8000ポート番号が異なる
http://localhost:8000http://127.0.0.1:8000ホスト名が異なる
http://app.localhttps://app.localプロトコルが異なる
https://app.example.comhttps://api.example.comサブドメインが異なる

そのため、フロントエンドを http://localhost:5173 で起動し、APIを http://localhost:8000 で起動した時点で、すでにCORSを考える必要がある。これはDockerを使ったかどうかとは無関係の、ブラウザの仕様だ。

ブラウザが主に確認するレスポンスヘッダーは、次のあたりだ。

ヘッダー役割
Access-Control-Allow-Originアクセスを許可するオリジン
Access-Control-Allow-MethodsGETPOSTPUTDELETEなどの許可メソッド
Access-Control-Allow-HeadersAuthorizationContent-Typeなどの許可ヘッダー
Access-Control-Allow-CredentialsCookieなどの認証情報を伴うリクエストを許可するかどうか

通常のGETリクエストだけであれば、ブラウザはそのままレスポンスを受け取る。しかし、Authorizationを付けたり、条件によってはJSONを送信したりすると、ブラウザは先にOPTIONSという事前確認リクエストを送ることがある。この事前リクエストに対してLaravelやNginxが適切なレスポンスを返さなければ、実際のAPIが正常でも画面からは使えない。

CORSはサーバーへの接続許可ではなく、ブラウザがレスポンスを読んでもよいかを決めるルールだ。

なお、ERR_CONNECTION_REFUSED502 Bad GatewayはCORS以前の問題である。コンテナが停止している、ポートが公開されていない、プロキシ先が間違っているといった話だ。画面に出る文字列だけで判断せず、Networkパネルでどのリクエストが失敗しているかを見る必要がある。

まずブラウザが認識するURLを固定する

CORSを調べるときは、ソースコードに書いたAPIURLと、ブラウザが実際に要求したURLを分けて考える。Dockerでは、この二つがすぐに食い違うという罠がある。

たとえばDocker ComposeでフロントエンドとLaravelを別コンテナに分けた場合、サーバー側の設定ファイルには次のような名前が登場する。

  • frontend
  • app
  • api
  • nginx
  • http://app:9000
  • http://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

localhost127.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_origins
  • allowed_methods
  • allowed_headers
  • paths
  • supports_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の対象に含める必要がある。

ブラウザがAuthorizationContent-Typeを許可対象として事前確認してきた場合、サーバー側のAccess-Control-Allow-Headersに必要なヘッダーが含まれていなければならない。許可メソッドと許可ヘッダーは、フロントエンドの実装を見て決める。Laravel側の想像で決めない。

SanctumのCookie認証はCORSだけでは終わらない

SPA認証でLaravel Sanctumを使い、ブラウザからセッションCookieを送る設計なら、config/cors.phpsupports_credentialstrueにする必要がある。設定ファイルを触るだけでCookieが送られるわけではない。フロントエンド側でも、認証情報をリクエストに含める指定が必要だ。

ブラウザのfetchならcredentialsを適切に設定し、AxiosならwithCredentialsをtrueにする。Cookieを使わないBearerトークン方式なら、認証の設計そのものが異なるため、supports_credentialsの必要性も変わってくる。トークンをAuthorizationヘッダーで送るのか、Cookieでセッションを維持するのかを先に決めるべきだ。

Sanctumを使う構成では、次の三点もCORSとは別の問題として現れる。

  • /sanctum/csrf-cookieへ正常に到達するか
  • CookieのSameSiteSecureが実行環境と一致しているか
  • フロントエンドと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開発サーバー+LaravelViteの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解決の設定を直すべきだ。localhost127.0.0.1、Dockerのサービス名のどこかでOriginがずれているケースは多い。

2. OPTIONSが飛んでいるかを見る

OPTIONSが飛んでいる場合と、飛んでいない場合では、疑う場所が変わる。

Networkで見た状態まず疑う場所
OPTIONS404405CORSの対象パス、ルート、Nginxの転送
OPTIONS401403Sanctum、CSRF、認証ミドルウェア
OPTIONS200でもCORSエラー許可ヘッダー、Origin、credentials
OPTIONS自体がないブラウザが別オリジンと認識していない可能性

実際のAPIリクエストより先にOPTIONSが失敗しているなら、config/cors.phppathsallowed_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-Headersauthorizationが含まれているか確認する。JSONをContent-Type: application/jsonで送る場合も、ヘッダーの扱いをブラウザ任せにせず、Laravel側の許可設定と照合する。

「API本体は動いているのに、画面からだけPOSTできない」という状態では、GET用の設定が残っていることがある。SPA側のフォームがPOSTへ切り替わった、axiosのデフォルト設定が変わった、Nginxのルーティングが変わったといった変更履歴も役に立つ。

5. ステータスコードと本文を確認する

CORSエラーは、APIが401403500を返した事実を覆い隠すことがある。ブラウザが本文を読めず、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・インフラ構築をわかりやすく解説.

よくある質問

Laravelのconfig/cors.phpを修正してもCORSエラーが消えません。
設定変更後にキャッシュが残っている可能性があるため、php artisan config:clearを実行してください。また、デバッグ出力(dumpやdd)がレスポンスに混ざるとCORSエラーになるため、それらを除去してJSONのみが返る状態にしてください。
Docker環境でブラウザからAPIに接続できないのはなぜですか?
コンテナ内部のサービス名(appやapiなど)はブラウザから解決できないためです。ブラウザのアドレスバーに入力するURLと、Dockerでホスト側に公開したポート番号が正しく一致しているか確認してください。
Laravel Sanctumで認証情報を送るにはどうすればいいですか?
config/cors.phpのsupports_credentialsをtrueに設定し、フロントエンド側でもAxiosのwithCredentialsをtrueにするなどの認証情報を含める指定が必要です。ただし、これだけで解決するわけではなく、CSRFトークンの取得やCookieのSameSite設定なども適切に行う必要があります。
開発環境のViteプロキシ設定を本番環境でも使うべきですか?
いいえ、Viteのプロキシは開発用の一時的な手段です。本番環境ではNginxなどのWebサーバーでリバースプロキシを設定し、APIへの転送を行う必要があります。
CORSエラーを解消するためにAccess-Control-Allow-Originを*にしてもいいですか?
認証情報を伴うリクエストでは推奨されません。セキュリティとブラウザの仕様上、許可する具体的なオリジンを明示し、適切な設定を行うことが重要です。

参考情報