Laravelをマイナーアップデートしたら、外部API連携が“静かに”全部壊れた話 〜Http::withOptionsのhandlerと旧libcurl対策〜

Laravel
laravel
B!

TL;DR

  • セキュリティ対応で Laravel を 13 系のパッチ更新(13.16 → 13.35)しただけで、Google Search Console / GA4 など外部APIの定期取得が全部止まった。
  • 原因は Laravel 13.30+ で HTTP クライアントの仕様が変わり、Http::withOptions(['handler' => ...]) が使えなくなったこと。Guzzle が handler を「リクエストオプション」として受け付けず、次のエラーを投げる:The "handler" request option is not supported; configure the handler when creating the client, or use a separate client instance for requests that need a different handler.
  • 直し方はシンプルで、withOptions(['handler' => ...]) をやめて PendingRequest::setHandler() に置き換えるだけ。
  • 教訓は「フレームワークやGuzzleを上げたら、外部HTTPの疎通を必ず確認する」「“データ0件”と“パイプライン故障”を取り違えない仕組みを持つ」の2点。

背景:なぜ「素のGuzzle」ではなく StreamHandler を使っていたか

対象は、とある辞書系のWebサービス(Laravel + Blade のモノリス)。共有レンタルサーバー上で動かしているのだが、このサーバーの libcurl が 7.29.0(2013年リリース)と非常に古く、TLSv1.2 の定数が未定義だった。

この環境で cURL ハンドラのまま HTTPS の外部APIを叩くと、本番だけ TLS エラーで落ちる。ローカルや新しい環境では再現しないので、非常にハマりやすい。

対策として、PHP の streams ベースの Guzzle StreamHandler を明示的に使うようにしていた。AI の文章生成、CAPTCHA 検証、Webhook 通知、IndexNow、そして今回の主役である Search Console / GA4 の取得まで、すべての外部HTTPをこのハンドラ経由にしていた。

当時の実装(Laravel 13.30 未満では動いていた)はこうだ:

use GuzzleHttp\Handler\StreamHandler;
use GuzzleHttp\HandlerStack;

class StreamHandlerOption
{
    public static function get(): array
    {
        // テスト時は Http::fake() を壊さないよう素通し
        if (function_exists('app') && app()->runningUnitTests()) {
            return [];
        }

        return ['handler' => HandlerStack::create(new StreamHandler)];
    }
}

呼び出し側はこの配列を withOptions() に渡すだけ:

$res = Http::withOptions(StreamHandlerOption::get())
    ->withToken($accessToken)
    ->timeout(90)
    ->post($endpoint, $payload);

これで「本番だけ StreamHandler、テストは fake 可能」を両立できていた。はずだった。


発端:セキュリティ更新の“副作用”

ある日、サービス全体のセキュリティ点検をして、composer audit で出た依存の脆弱性に対応した。フレームワーク本体にもデバッグページ関連の XSS 修正が入っていたので、Laravel を 13.16 → 13.35 に上げた。

テストは全部グリーン、本番デプロイも成功、サイトも正常。めでたし……と思っていた。

数日後、管理画面の「検索とアクセス」ダッシュボードを見た運用担当から、ひと言:

「GA4/GSCのデータが更新されてないみたいだけど、アクセスが無いだけ?」


切り分け:「アクセス0」なのか「取得が壊れている」のか

ここが今回いちばん大事なポイント。“データが増えていない”には2つの可能性がある。

  1. 本当にアクセス/表示が無い(=正常。静かな期間)
  2. 取得パイプラインが壊れて、取り込み自体が止まっている(=障害)

この2つを一瞬で見分けられるように、取得テーブルには 「最後にいつ取り込んだか」の updated_at が入るようにしてあった。見てみると:

最新データ日付      = 5日前
最終取り込み(updated_at) = 5日前の朝 07:00

取り込み時刻が5日前で止まっている。 つまり「アクセス0」ではなく「毎朝7時の定期取得が、ある日を境に動いていない」。

次に「cron(スケジューラ)自体が死んでいるのか、この取得だけが失敗しているのか」を切り分ける。幸い、同じスケジューラで予約投稿の公開ジョブも回していたので、それを確認:

予約記事 → 直近まで正常に公開済み

スケジューラは生きている。“analytics 取得コマンドだけ”が毎回失敗しているとわかった。

最後に、あらかじめ用意しておいた疎通確認コマンドを本番で実行する。これが決め手になった:

=== 1. 認証鍵 ===        OK
=== 2. アクセストークン ===
  The "handler" request option is not supported; configure the handler
  when creating the client, or use a separate client instance for
  requests that need a different handler.

トークン取得の HTTP リクエストの時点で、Guzzle がエラーを投げていた。

設計の勝因:外部連携には「鍵 → トークン → 各APIの権限」と段階ごとに切り分けて結果を出す疎通コマンドを用意していた。これがなければ「なんか取れない」で終わっていた。


根本原因:Laravel 13.30+ の HTTP クライアント仕様変更

エラーメッセージの

The “handler” request option is not supported; configure the handler when creating the client

は Guzzle が出すもの。Guzzle では handler は クライアント生成時のオプションであって、リクエストごとのオプションではない。

Laravel の Http::withOptions([...]) は、以前は渡した配列をうまくクライアント構築側にマージしてくれていたため ['handler' => ...] が通っていた。ところが Laravel 13.30 前後で HTTP クライアント(PendingRequest)の内部実装が変わり、withOptions の中身がリクエストオプション側に渡るようになった。その結果、handler が「未サポートのリクエストオプション」として弾かれる。

タイムラインも完全に一致していた:

日付出来事取得
X日正常稼働(Laravel 13.16)✅ 最終取得
X+1日セキュリティ対応で 13.35 に更新❌ ここから全停止
X+6日「データ更新されてない?」で発覚❌

そして怖いのは、これは analytics だけの問題ではないということ。StreamHandlerOption は AI 生成・CAPTCHA 検証・各種通知・IndexNow など、外部HTTPすべてに噛ませていた。つまり更新した瞬間に、ユーザーの目に付きにくい外部連携が軒並み静かに壊れていた。

“静かな障害”の典型。例外は出ていたが、各処理が try/catch で握りつぶす設計(通知が一本落ちてもサービスは止めない、という方針)だったため、表に出てこなかった。良かれと思った握りつぶしが、発見を遅らせる諸刃の剣になる。


修正:withOptions(['handler'=>..]) をやめ、setHandler() へ

新しい Laravel の PendingRequest には setHandler() が用意されている。これはハンドラをクライアント構築時に差し込むための正しい口で、しかも Laravel 側が内部で HandlerStack に包み、Http::fake() 用のスタブ・ミドルウェアも維持してくれる(ここ重要)。

フレームワーク内部を読むと、だいたいこういう流れになっている:

// PendingRequest(イメージ)
public function setHandler($handler)
{
    $this->handler = $handler; // callable を受け取るだけ
    return $this;
}

public function buildHandlerStack()
{
    // 渡したハンドラを HandlerStack に包み、
    // その上に Laravel 自身のミドルウェア(before-sending・記録・fakeスタブ)を積む
    return $this->pushHandlers(HandlerStack::create($this->handler));
}

つまり setHandler(new StreamHandler) を使えば、StreamHandler を土台にしつつ、Http::fake() も効く。テスト互換性を落とさずに直せる。

ヘルパをこう書き換えた:

use GuzzleHttp\Handler\StreamHandler;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

class StreamHandlerOption
{
    /** 外部HTTPの“起点”。StreamHandler を差したうえで PendingRequest を返す。 */
    public static function client(): PendingRequest
    {
        $request = Http::withOptions([]);

        // テスト時は setHandler しない(Http::fake のスタブを確実に通すため)
        if (function_exists('app') && app()->runningUnitTests()) {
            return $request;
        }

        return $request->setHandler(new StreamHandler);
    }
}

呼び出し側は、起点を差し替えるだけで後続のチェーンはそのまま:

// Before(13.30+ で壊れる)
$res = Http::withOptions(StreamHandlerOption::get())
    ->withToken($accessToken)->timeout(90)->post($endpoint, $payload);

// After(setHandler 方式)
$res = StreamHandlerOption::client()
    ->withToken($accessToken)->timeout(90)->post($endpoint, $payload);

置換は機械的なので、Http::withOptions(StreamHandlerOption::get()) → StreamHandlerOption::client() を全呼び出し箇所に一括適用。外部HTTPを一か所の「起点ヘルパ」に集約しておいたおかげで、修正は15箇所の置換だけで済んだ。

設計の勝因その2:外部HTTPのハンドラ指定を各所にベタ書きせず、1つのヘルパに寄せていた。仕様変更の影響範囲が1クラスに閉じ、横断修正が一瞬で終わった。


復旧とバックフィル

修正をデプロイして疎通コマンドを再実行:

=== 2. アクセストークン ===  取得できました。
=== 3. Search Console ===   対象プロパティ 両方 OK
=== 4. GA4 ===              プロパティ読み取り OK
すべてつながりました。

止まっていた期間を埋めるため、取得コマンドを日数指定で流し直して(冪等に上書きされる設計)、欠損日をバックフィル。ダッシュボードも最新データで復活した。


学び(再発防止のチェックリスト)

  1. フレームワーク/Guzzle を上げたら、外部HTTPの“実疎通”を必ず確認する。
    ユニットテストは Http::fake() でモックしているので、この種の「実トランスポート層の仕様変更」はテストでは絶対に検出できない。更新後は本番(または本番相当)で疎通を叩く。
  2. “データ0件”と“パイプライン故障”を取り違えない仕組みを持つ。
    取得テーブルに「最後に取り込んだ時刻(updated_at)」を残しておくだけで、「静かな期間」と「障害」を一瞬で見分けられる。ダッシュボードにも最終取得時刻を出すとさらに良い。
  3. 外部連携の疎通を“段階ごと”に切り分ける診断コマンドを用意する。
    「鍵 → トークン → 各APIの権限」のように理由別にOK/NGを出すだけで、原因特定が桁違いに速くなる。
  4. 外部HTTPのトランスポート設定は一か所に集約する。
    ハンドラやタイムアウトの指定を各所にベタ書きしていたら、今回の横断修正は地獄だった。「起点ヘルパ」に寄せておくと、仕様変更に強い。
  5. 例外の握りつぶしは“発見性”とのトレードオフ。
    「通知が1本落ちてもサービスは止めない」は正しい方針だが、静かに全停止する温床にもなる。握りつぶすなら、監視・アラート・最終成功時刻の可視化をセットで持つ。

まとめ

「パッチバージョンを上げただけ」でも、HTTP クライアントのような基盤レイヤーの内部実装が変わると、モックでは見えない形で外部連携が折れることがある。特に、特殊な環境対応(今回の旧libcurl向け StreamHandler)のような“フレームワークの標準から少し外れた使い方”をしている箇所は要注意だ。

テストが通ること/アプリが表示されることは、外部連携が生きている保証にはならない。更新後の疎通確認と、「止まったらすぐ気づける可観測性」を、地味だが必ず仕込んでおきたい。

B!
← 一覧へ戻る