Laravel×外部APIエラー調査録:500エラーと NOT FOUND の裏にいた 環境変数
はじめに
Laravel で占い系の鑑定サイトを開発している中で、
「鑑定フォーム → 鑑定結果ページ」
というよくある画面遷移を実装していたところ、
- フォーム送信後に 鑑定結果ページではなく非会員トップページに飛ばされる
- ブラウザのネットワークを見ると 500 Internal Server Error
という現象にハマりました。
最終的な原因は、
開発環境から、本番環境の外部API(EXAMPLE_API_URL)を叩いていた
という、実務あるあるな「環境設定ミス」でした。
この記事では、
- どんな実装だったのか
- どうやって原因にたどり着いたのか
- 「NOT FOUND」の裏にあった落とし穴
- 再発防止で入れた工夫
をまとめます。
※ 実際のプロジェクト名・ドメイン・商品コードなどは、記事中ではサンプル名に置き換えています。
前提:外部APIから鑑定結果を取得する実装
占いの鑑定結果は、外部の API から取得する構成になっていました。
コントローラ側の流れ
ざっくりとした流れはこんな感じです。
- フォームから送信された値を元に
$resultRequestParamsを組み立てる -
ApiAccessComponent::GetResult()で外部APIにリクエスト - 戻り値
$resultをチェックし、エラーなら 500 を返す - 正常なら鑑定結果ページを表示する
エラー判定部分のコードは次のようになっていました。
$result = ApiAccessComponent::GetResult($itemcd, $resultRequestParams);
if (is_int($result)) {
// API呼び出しで例外が発生した場合
return response("internal server error. result json request error. line : " . __LINE__, 500, []);
}
if ($result["error_msg"] != "") {
// APIは返ってきたが、エラーメッセージが入っている場合も 500
return response("internal server error. result json request error. line : " . __LINE__, 500, []);
}
API呼び出し部分(コンポーネント)
外部APIへの HTTP リクエストはコンポーネントで行っています。
public static function GetResult($itemcd, $params, $contentKey = EXAMPLE_CONTENT_KEY)
{
try {
$response = HttpRequestComponent::GetBasicAuth(
EXAMPLE_API_URL . "/" . EXAMPLE_PLATFORM . "/" . $contentKey . "/" . $itemcd . "/result",
EXAMPLE_API_ID,
EXAMPLE_API_PW,
$params
);
return HttpRequestComponent::EncodeJson($response);
} catch (Exception $e) {
// 例外が発生した場合は int を返す
return 1;
}
}
ポイントはこんな感じです。
-
EXAMPLE_API_URL:外部APIのベースURL(.envで管理) -
EXAMPLE_PLATFORM:環境やプラットフォーム種別(例:stg/prod) -
$contentKey:コンテンツ指定用のキー -
$itemcd:鑑定メニューのコード(例:freexxx_guest) - 例外が発生した場合は
1を返し、呼び出し側でis_int($result)によって検知
症状:鑑定結果ページに行けず、トップに飛ばされる
実際に鑑定フォームから送信してみると、
- 期待:鑑定結果ページが表示される
- 現実:非会員トップページへリダイレクトされる
ブラウザの DevTools(Network タブ)を確認すると、
- 鑑定結果取得用のリクエストが HTTP 500 を返している
という状態でした。
アプリ全体の仕様として、
- 500 や特定のエラーが返った場合に共通処理で非会員トップへリダイレクトする
という仕組みになっていたため、
「鑑定結果ページに行きたいのにトップに戻される」
という見え方になっていました。
調査ステップ1:Laravelログを仕込む
まずは、外部APIから何が返ってきているのか を知るために、ログを仕込みました。
use Illuminate\Support\Facades\Log;
$result = ApiAccessComponent::GetResult($itemcd, $resultRequestParams);
Log::debug('GetResult response', [
'itemcd' => $itemcd,
'params' => $resultRequestParams,
'result' => $result,
]);
if (is_int($result)) {
Log::error('GetResult failed: result is int', [
'itemcd' => $itemcd,
'params' => $resultRequestParams,
'result' => $result,
]);
return response("internal server error. result json request error. line : " . __LINE__, 500, []);
}
if (!is_array($result) || !array_key_exists('error_msg', $result)) {
Log::error('GetResult invalid response format', ['result' => $result]);
return response("internal server error. invalid result json. line : " . __LINE__, 500, []);
}
if ($result["error_msg"] != "") {
Log::error('GetResult api error', ['result' => $result]);
return response("internal server error. result json request error. line : " . __LINE__, 500, []);
}
実際に出力されたログ(一部マスク済み)
storage/logs/laravel.log を見ると、こんなログが出ていました。
[2025-11-13 11:12:17] local.DEBUG: GetResult response {
"itemcd":"freexxx_guest",
"params":{
"full_name":"テスト太郎",
"birthday":"1990-01-01",
"target_full_name":"",
"target_birthday":"",
"custom_birthday":"2025-11-13",
"birthtime":"1200",
"target_birthtime":"00"
},
"result":{
"status":0,
"error_msg":"NOT FOUND"
}
}
[2025-11-13 11:12:17] local.ERROR: GetResult api error {
"result":{"status":0,"error_msg":"NOT FOUND"}
}
ここから分かったこと:
-
$itemcdは意図どおりfreexxx_guest - パラメータも想定通り
- 外部APIからのレスポンスが
status: 0, error_msg: "NOT FOUND"
という状態になっている、ということです。
調査ステップ2:レスポンスをブラウザにも出してみる
状況を確認するため、一時的に API エラー時のレスポンスを そのまま JSON で返す ようにしました。
if (!empty($result['error_msg'])) {
Log::error('GetResult api error', ['result' => $result]);
return response()->json([
'message' => 'API error',
'result' => $result,
], 500);
}
ブラウザでリクエストしてみると、こんな JSON が返ってきます。
{
"message": "API error",
"result": {
"status": 0,
"error_msg": "NOT FOUND"
}
}
ここまでで、
- Laravel 側の処理は「ログ通りに動いている」
- 問題は API 側のレスポンス、特に
error_msg: "NOT FOUND"にある
というところまでは切り分けられました。
「NOT FOUND」が意味していたもの
ここで少しハマったのが、
「API側には
freexxx_guestが存在しているはずなのに、なぜNOT FOUNDなのか?」
という点です。
API側の管理画面(テスト環境)で確認すると、
freexxx_guestという itemcd 自体は登録されている
なので最初は、
「itemcd が間違っているわけではなさそう」
と考えていました。
しかし、ログと環境変数をよくよく見直してみると、
根本的な勘違い に気づきます。
真犯人:EXAMPLE_API_URL が本番を向いていた
改めて .env の設定を確認したところ、
開発環境だと思って動かしていたコンテナ内の設定が、こうなっていました。
# 想定していた設定(開発・テスト用)
# EXAMPLE_API_URL=https://api-test.example.com
# 実際に入っていた設定(本番用)
EXAMPLE_API_URL=https://api.example.com
つまり、
- 自分は「テスト環境のAPIを叩いているつもり」
- 実際には 本番環境のAPI にリクエストを投げていた
という状態でした。
そして本番側のAPIを確認すると、
- 本番にはまだ
freexxx_guestの結果データが反映されていない
そのため、本番APIは
{
"status": 0,
"error_msg": "NOT FOUND"
}
というレスポンスを返していた、というわけです。
テスト環境にはある
でも、本番環境にはまだない
なのに開発環境から本番を叩いていた
という構図ですね。
修正:環境ごとに API URL を正しく設定
原因がわかったので、まずは .env を修正しました。
開発・テスト環境の .env
APP_ENV=local
APP_DEBUG=true
RSA_API_URL=https://api-test.example.com
RSA_PLATFORM=stg
本番環境の .env(例)
APP_ENV=production
APP_DEBUG=false
RSA_API_URL=https://api.example.com
RSA_PLATFORM=prod
ポイント:
- 開発用の
.envからは 本番APIのURLを排除 する - 本番用の
.envは本番サーバにだけ置き、Git などには載せない
設定を修正したあと再度リクエストすると、
- テスト環境のAPIから正常な鑑定結果が返ってくる
- 500 エラーが消え、無事に鑑定結果ページへ遷移できるようになりました 🎉
再発防止のためにやったこと
今回の件をきっかけに、いくつか再発防止の工夫も入れました。
1. 設定を config/services.php に集約
env() をコード内で直接呼ぶのではなく、
config/services.php に API 関連の設定をまとめました。
// config/services.php
return [
'rsa' => [
'url' => env('RSA_API_URL'),
'platform' => env('RSA_PLATFORM', 'stg'),
'id' => env('RSA_API_ID'),
'password' => env('RSA_API_PW'),
],
];
利用側ではこう書きます。
$url = sprintf(
'%s/%s/%s/%s/result',
config('services.rsa.url'),
RSA_PLATFORM,
$contentKey,
$itemcd
);
設定の入口が config/services.php にまとまることで、
- 「どこでURLが決まっているのか」
- 「今どの環境のAPIを叩いているのか」
が追いやすくなりました。
2. 実際に叩いている URL をログに出す
GetResult() の中で、
実際に投げている URL とパラメータをログに残すようにしました。
public static function GetResult($itemcd, $params, $contentKey = RSA_CONTENT_KEY)
{
try {
$url = RSA_API_URL . "/" . RSA_PLATFORM . "/" . $contentKey . "/" . $itemcd . "/result";
Log::debug('GetResult request url', [
'url' => $url,
'itemcd' => $itemcd,
'params' => $params,
]);
$response = HttpRequestComponent::GetBasicAuth(
$url,
RSA_API_ID,
RSA_API_PW,
$params
);
Log::debug('GetResult raw response', ['response' => $response]);
return HttpRequestComponent::EncodeJson($response);
} catch (Exception $e) {
Log::error('GetResult exception', [
'itemcd' => $itemcd,
'params' => $params,
'message' => $e->getMessage(),
]);
return 1;
}
}
これで、
- 本当にテストAPIを叩いているのか
- 間違って本番APIを叩いていないか
がログを見れば一目でわかるようになりました。
3. 「NOT FOUND」を全部 500 にしない
それまでは、error_msg に何か入っていれば 全部 500 にしていましたが、
ユーザー体験的にも開発者的にも扱いづらいので、分岐を見直しました。
$result = ApiAccessComponent::GetResult($itemcd, $resultRequestParams);
// ネットワークや例外系
if (is_int($result)) {
return response("internal server error. result json request error. line : " . __LINE__, 500, []);
}
// レスポンス形式チェック
if (!is_array($result) || !array_key_exists('status', $result)) {
Log::error('GetResult invalid format', ['result' => $result]);
return response("internal server error. invalid result json. line : " . __LINE__, 500, []);
}
// 「NOT FOUND」は専用画面へ
if (($result['status'] ?? 0) === 0 && ($result['error_msg'] ?? '') === 'NOT FOUND') {
Log::warning('GetResult NOT FOUND', [
'itemcd' => $itemcd,
'result' => $result,
]);
return view('result.not_found', [
'itemcd' => $itemcd,
'message' => 'この鑑定結果は現在準備中です。',
]);
}
// その他のAPIエラー
if (!empty($result['error_msg'])) {
Log::error('GetResult other api error', ['result' => $result]);
return response("internal server error. result json request error. line : " . __LINE__, 500, []);
}
// 正常系:鑑定結果ページへ
return view('result.show', [
'result' => $result,
]);
- インフラ的なエラー(接続不可・例外)は 500
- ビジネスロジック上の「データがない(NOT FOUND)」は専用ページでハンドリング
と切り分けることで、
- ユーザーには「準備中」「現在ご利用できません」と伝えられる
- 開発者はログを見て原因を追いやすい
という状態にできました。
学びとまとめ
今回の一件で学んだことは、ざっくり以下の3つです。
-
環境変数の向き先は想像以上に重要
- 「開発中のつもりで本番APIを叩いていた」は普通に起こる
- 本番URLは本番環境にだけ置く、という運用にしておくと安全
-
ログは惜しまず出したほうが早く原因にたどり着ける
- 実際のURL
- リクエストパラメータ
- 生レスポンス
この3つを一度出してみるだけで、原因の見当がつきやすくなる
-
APIのエラーを「全部 500」にしない
- ネットワークや例外系の 500 と
- ビジネスロジック上の「データがない(NOT FOUND)」は分ける
- 後者は専用画面やメッセージでユーザーに伝えるほうが UX 的にも◎
同じように、
「itemcd は存在しているはずなのに、APIが NOT FOUND を返してくる…」
という状況にハマっている方の、
「もしかして、
.envの向き先では?」
という気づきにつながればうれしいです。
Discussion