症状
/admin/reports/dblog(最近のログメッセージ)を、重大度「エラー」で絞り込んだら、ページ全体が 500 になりました。フィルタを外すと表示できる。エラーだけに絞ると 500。
ログを見るための画面が、ログのせいで見られない。しかも壊しているのはたった1行でした。
原因: null プレースホルダ + PHP 8.4
Drupal でログを書くとき、プレースホルダを使います。
\Drupal::logger('mymodule')->error('取得に失敗しました @request_id', [
'@request_id' => $requestId,
]);
この $requestId が null だと、何が起きるか。
記録は成功します。 ログは書き込まれる。エラーは出ません。ここが罠です。「ログが書けている=安全」だと思ってしまう。
破綻するのは表示時です。/admin/reports/dblog は、コアのコントローラではなく dblog 同梱の View(watchdog)で描画されます。その message フィールドは、こういう処理を通ります。
(string) new FormattableMarkup($message, (array) $variables);
FormattableMarkup は @/% プレースホルダの値を Html::escape() に渡します。ここで PHP 8.4 が効いてきます。PHP 8.4 の Html::escape(string $text) は型を厳格に見て、null を拒否して TypeError を投げる。
つまり、null を仕込んだ1行を描画しようとした瞬間に例外が飛び、その行だけでなくページ全体が 500 になる。重大度フィルタで「エラー」に絞ると、その壊れた行が結果に入って顕在化します。
なぜ気づきにくいか
3つ重なっています。
記録時には描画されないので、破損が遅延する。 ログを書いた時点では何も起きず、後日ログを見に行って初めて 500 になる。書いた本人と、踏む本人が別のことが多い。
プレースホルダの契約は string / MarkupInterface。 null は契約違反ですが、記録経路ではその契約が検査されません。描画経路で初めて型が問われる。
PHP 8.3 までは通っていた可能性がある。 8.4 で Html::escape() の型がより厳格になり、それまで暗黙変換で済んでいた null が例外になります。PHP を上げた瞬間に、過去に書かれた null プレースホルダが一斉に牙をむく。
実際に踏んだ経路
自分のコードではなく、contrib モジュール由来でした。S3 互換ストレージのモジュールが、あるエラー応答(request-id を含まない ServiceUnavailable)を受けたとき、@request_id = null でログを書いていました。それが /admin/reports/dblog を 500 にしていた。パッチで是正しています。
自分で書いていなくても、依存先が null プレースホルダを書けば同じことが起きる、ということです。
対策1: nullable な値は coalesce してから渡す
外部 API 由来の値(request-id、error code など)は null になり得る前提で、プレースホルダに渡す前に空文字へ寄せます。
// null になり得る値は ?? '' で潰してから渡す
\Drupal::logger('mymodule')->error('取得に失敗しました @request_id', [
'@request_id' => $e->getAwsRequestId() ?? '',
]);
?? '' を1つ挟むだけです。プレースホルダに渡る値が必ず文字列になるので、描画時の TypeError が起きません。外部由来の値は「あるはず」と思っても null で来ることがある、と構えておきます。
対策2: 既に書かれてしまった壊れた行を無害化する
すでに DB に入ってしまった null プレースホルダの行は、放っておくと今後もずっと 500 の原因になります。watchdog テーブルの variables はシリアライズされた配列なので、null を空文字に置換して無害化します(配列の要素数は変えず、正当なシリアライズのまま)。
そして、直したことを机上で信じないために、View と同じ処理を全行に回して例外ゼロを確認します。
// 全 watchdog 行に対し、描画と同じ経路を通して例外が出ないか検証する
(string) new FormattableMarkup($message, unserialize($variables));
これを全行で回して例外が出なければ、少なくともこの経路での 500 は消えたと言えます。「1行直した」ではなく「全行が描画経路を通る」ことを確認する。
まとめ
- ログのプレースホルダに null を渡すと、記録は成功するが表示時に 500 する。 破損が描画まで遅延するので「ログが書けている=安全」は誤り
- PHP 8.4 の
Html::escape()が null を TypeError で拒否する。FormattableMarkup経由で描画される dblog レポートが、1行の null でページ全体 500 になる - nullable な外部由来の値は
?? ''で潰してからプレースホルダに渡す。 自分のコードだけでなく contrib も踏む - 既存の壊れた行は無害化し、全行を描画経路に通して例外ゼロを確認する。 直したことを机上で信じない
この記事は、事業・Webサイトの売買プラットフォーム RIKKA M&A を開発する中で得た知見です。RIKKA M&A は GitHub リポジトリを解析する技術デューデリジェンスを備え、Drupal 11 / PHP / LLM で構築・運用しています。

