Laravel『Invalid JSON Response』の原因と対処法

Laravel『Invalid JSON Response』の原因と対処法

「Invalid JSON Response」は、Laravel(またはフロント側)が“JSONを期待しているのに、JSONとして解釈できないレスポンスが返ってきた”ときに起きる。原因はLaravel側の例外でHTMLエラーページが返っている、BOMや余計な出力(echo/var_dump)が混ざっている、文字コード不正、JSONにできない値(INF/NaN、循環参照、巨大整数の扱い)、ヘッダが不正、gzip/プロキシで本文が壊れている、などが多い。まずは「実際に返ってきたレスポンス本文」と「HTTPステータス」「Content-Type」を確認し、JSON以外が混ざる経路を潰す。

症状と発生条件(典型例)

発生条件の典型:
・Axios/fetchで res.json() を呼んだらパースに失敗する
・APIクライアントで「Unexpected token < in JSON」などが出る(HTMLが返っている合図)
・Laravel側で JsonResponse の生成時に例外(InvalidArgumentException / JsonException)になる
よくある見え方(例)。

Unexpected token < in JSON at position 0
Invalid JSON response
The response is not a valid JSON response.
Malformed UTF-8 characters, possibly incorrectly encoded

「<」から始まる場合はHTML(エラーページ/ログイン画面)が返っている可能性が高い。

まず確認:HTTPステータス / Content-Type / 本文の先頭

最初に“本当にJSONが返っているか”を確定する。

curl -i https://example.com/api/endpoint

見るポイント:
・Status: 200/401/403/419/500 など
・Content-Type: application/json になっているか
・本文の先頭が「{」または「[」か(「<」ならHTML)
・Locationヘッダがあるならリダイレクト(ログイン画面へ飛ばされている等)

原因1:例外が起きてHTMLエラーページが返っている(JSON期待なのにHTML)

APIルートで例外が起きると、環境やヘッダ次第でHTMLが返ってJSONパースで落ちる。
発生条件の典型:
・500エラー(SQL/Null/権限など)でHTMLのスタックトレース(またはエラーページ)が返る
・Acceptヘッダが application/json になっていない
対処:
・APIリクエストは Accept: application/json を付ける

curl -i -H "Accept: application/json" https://example.com/api/endpoint

・Laravel側でAPIの例外応答をJSONに寄せる(expectsJsonを前提にする)

// app/Exceptions/Handler.php(例)
public function render($request, Throwable $e)
{
    if ($request->expectsJson()) {
        return response()->json([
            'message' => $e->getMessage(),
        ], method_exists($e, 'getStatusCode') ? $e->getStatusCode() : 500);
    }
    return parent::render($request, $e);
}

根本は「例外の原因を潰す」だが、まずJSONで返るようにすると原因特定が速い。

原因2:未認証/CSRFでログイン画面(HTML)にリダイレクトされている

APIを叩いたつもりが、実際は web ミドルウェアや auth によってログイン画面へ飛ばされ、HTMLが返ってJSONパースが壊れる。
典型ステータス:
・401(未認証)
・302(ログインへリダイレクト)
・419(CSRF/セッション期限)
対処:
・APIは routes/api.php を使い、webセッション前提の挙動を避ける
・認証が必要なら Bearer トークン等を付ける
・SPA + Cookie 認証なら credentials/CSRF を満たす
APIで未認証時にJSONで返したい場合。

// app/Exceptions/Handler.php
use Illuminate\Auth\AuthenticationException;

protected function unauthenticated($request, AuthenticationException $exception)
{
    if ($request->expectsJson()) {
        return response()->json(['message' => 'Unauthenticated'], 401);
    }
    return redirect()->guest(route('login'));
}

原因3:余計な出力が混ざってJSONが壊れている(echo/var_dump/BOM/空白)

JSONの前後に文字が混ざると無効になる。
発生条件の典型:
・ControllerやMiddlewareで echo / var_dump / print_r を残している
・PHPファイルの先頭にBOM(UTF-8 BOM)が付いている
・レスポンスを返す前に何か出力している(obが絡む)
対処:
・デバッグ出力は logger() に置き換える

logger()->debug('debug', ['x' => $x]);

・PHPファイルは「UTF-8(BOMなし)」で保存
・return response()->json(…) の前後で出力していないか確認

原因4:JSONにできない値(Malformed UTF-8 / INF・NaN / 循環参照)

Laravelのjsonエンコードで落ちる代表。
発生条件の典型:
・不正なバイト列が混ざっている(外部入力、DBの文字化け、バイナリ)
・浮動小数で INF / -INF / NaN が入っている
・オブジェクトが循環参照している(入れ子がループ)
対処:
・UTF-8に正規化する(保存前に検証、もしくは返却前に除外)
・INF/NaNが入り得る計算結果をnullにする等のルール化
・Eloquentモデルはそのまま返さず、Resourceで返却フィールドを制御する

use App\Http\Resources\UserResource;

return new UserResource($user);

循環参照は「モデル同士を丸ごと配列化」すると起きやすいので、必要フィールドだけ返す。

原因5:Content-Typeやレスポンス形式の不一致(JSON以外を返している)

フロントがJSON前提なのに、Laravelが view() を返している、redirect() を返している、文字列を返している、など。
発生条件:
・成功時はJSONだが、失敗時はredirect/backでHTMLになる
対処:APIは成功/失敗ともにJSONで統一する。

// OK: バリデーション失敗もJSONで返す(FormRequest + API前提)
return response()->json([
  'ok' => true,
  'data' => $data,
]);

バリデーションはFormRequestで expectsJson が効くようにするか、API専用のレスポンスに統一する。

サンプル:常にJSONを返すAPIエンドポイント(成功/失敗で形式固定)

use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

Route::post('/api/profile', function (Request $request) {
    try {
        $data = $request->validate([
            'name' => ['required', 'string'],
            'age' => ['nullable', 'integer'],
        ]);

        // 何らかの処理…
        return response()->json([
            'ok' => true,
            'data' => $data,
        ], 200);

    } catch (ValidationException $e) {
        return response()->json([
            'ok' => false,
            'message' => 'Validation failed',
            'errors' => $e->errors(),
        ], 422);
    } catch (\Throwable $e) {
        return response()->json([
            'ok' => false,
            'message' => 'Server error',
        ], 500);
    }
});

この形にすると、フロント側は常にJSONとして扱え、パース失敗の調査がほぼ不要になる。

チェックリスト(上から順に確認する)

1) curl -i で Status / Content-Type / 本文先頭({ か < か)を確認したか
2) Accept: application/json を付けたときに挙動が変わるか(HTML→JSONになるか)
3) 401/302/419 なら未認証・CSRF・セッションの問題でHTMLに飛ばされていないか
4) echo/var_dump/BOM/余計な空白がレスポンスに混ざっていないか
5) Malformed UTF-8 / INF/NaN / 循環参照など “JSON化できない値” が含まれていないか
6) 失敗時だけ redirect()/view() を返していないか(成功/失敗ともJSONで統一できているか)