画面下部の入力データだけが保存されない?PHPの max_input_vars 超過によるサイレントエラーと根本的解決


システムのインフラを新しいサーバーへリプレースした直後、特定のデータ更新画面で奇妙な不具合が発生しました。 今回は、原因特定が難航しがちな「エラー画面が出ないフォーム送信トラブル」について、その原因のメカニズムとアーキテクチャ面からの根本的な解決策を共有します。

課題(Situation / Task)

現象:最終行のデータだけが保存されず、クリアされてしまう

問題が発生したのは、既存のデータに対して明細行の追加・変更・削除を一括で行う画面です。 数十行ある明細データのうち、 「一覧の最終行に入力した値だけが、変更ボタンを押しても保存されず空になってしまう」 という報告を受けました。

不思議なことに、以下の通りエラーの痕跡が一切ありませんでした。

  • ブラウザ側 (JS / DevTools): コンソールエラーや通信エラーはなく、正常にPOSTリクエストが完了している。
  • サーバー側 (PHP): 画面が真っ白になるような致命的なエラー(Fatal Error)は出力されていない。

インフラリプレース前は正常に動いていた機能であり、アプリケーション側のコードは変更していません。なぜ突然、下部のデータだけが消えてしまうようになったのでしょうか。

原因:POSTパラメータの上限超過と仕様の罠

調査の結果、根本的な原因は PHPの送信パラメータ上限(max_input_vars)の超過 でした。

なぜデータが保存されなかったのか?(メカニズム)

PHPには、DoS攻撃(ハッシュ衝突攻撃など)を防ぐ目的で、1回のリクエストで送信できる入力変数(POST / GET / COOKIEなど)の最大数を制限する max_input_vars という設定が存在します。デフォルト値は 1000 です。

不具合が起きた画面のソースコードを確認したところ、以下のようなレガシーな設計になっていました。

  • 明細1行ごとに、変更しないデータ(名称、重量、各種フラグなど)まで含めて 約60個の隠しパラメータ(<input type="hidden"> を出力している。
  • 明細が15行ある場合、15行 × 約60項目 = 約900項目 となる。
  • これに画面全体の共通パラメータを合わせると、送信されるパラメータ数がデフォルト上限の 1000 に到達してしまう。

PHPは、この上限を超えたパラメータを受け取った際、超過分を強制的に切り捨てます。 HTMLフォームのデータは上から順に送信されるため、常に画面の一番下(最終行)のデータが切り捨てられます。その結果、PHP側では「対象データが空で送られてきた」と解釈され、そのまま空の値でデータベースを上書き保存してしまう、というメカニズムでした。

なぜエラー画面にならなかったのか?

この問題が厄介なのは、処理自体は完走してしまう点です。

  • ブラウザ側: クライアントサイドでのバリデーションは通過し、ブラウザはデータを正常に送信しているためエラーになりません。
  • PHP側: max_input_vars の上限超過は、サーバーのログに「警告(Warning)」として記録されるのみで、プログラムの実行を停止させる致命的エラー(Fatal Error)ではありません。そのため、後続のデータベース更新処理がそのまま実行されてしまいます。

※旧インフラでは、過去にこの設定値(max_input_vars)が引き上げられて運用されていましたが、インフラのリプレースによって初期値(1000)に戻ったことが、今回不具合が顕在化したトリガーでした。

解決策(Action)

この問題に対し、一時的な緩和策と根本的な設計改修の2段階で対応を行いました。

1. サーバー設定の変更(一時的な緩和策)

まずは業務への影響を止めるため、サーバーの php.ini を編集し、max_input_vars の上限値を一時的に引き上げました。 これにより後方データの切り捨てが発生しなくなり、正常に保存されるようになります。

ただし、この設定値が存在する理由は「DoS攻撃を防ぐため」です。上限を上げっぱなしで運用を続けることはセキュリティリスクを伴うため、あくまで一時的な回避策としました。

2. プログラムの設計改修(根本治療)

真の元凶は、「変更しない情報まで全て隠しフィールド(hidden)に持たせ、POSTで受け取ってそのままUPDATEする」という画面アーキテクチャにあります。

そこで、フォームの設計を以下のようにモダンなアプローチへ改修しました。

  • 送信データの極小化: 画面に出力する hidden 項目を row_idproduct_id といった必要最低限のキー情報のみに絞り込みました。
  • バックエンドでのデータ再取得: データ保存時の処理内で、クライアントから送られてこない不変情報(商品名や各種マスター情報)については、DBから再度 SELECT して引き直すように設計を変更しました。

結果(Result)

設計を見直したことで、1回のリクエストで送信されるパラメータ数は劇的に減少し、デフォルトの max_input_vars (1000)の範囲内で余裕を持って動作するようになりました。不要なトラフィックも削減され、セキュリティ的な懸念も払拭されています。

システム移行やリプレースの際、古いシステムの処理をそのまま使い回すケースはよく見られますが、「以前は動いていたレガシーな実装」がインフラの標準設定と衝突し、思わぬサイレントエラーを引き起こすリスクがあることを再確認させられました。

(補足)max_input_vars について

公式マニュアルにも記載されている通り、ハッシュ衝突を悪用したDoS攻撃を軽減するためのセキュリティ設定です。

URL: PHPマニュアル - 実行時設定 (max_input_vars)

max_input_vars (integer) 入力変数を最大で何個まで受け付けるかを指定します (この制限は、$_GET、$_POST そして $_COOKIE のそれぞれに対して個別に適用されます)。このディレクティブを使うと、ハッシュ衝突を悪用したサービス拒否攻撃 (DoS攻撃) の可能性を軽減できます。 このディレクティブで設定した数を超える入力変数があった場合は E_WARNING が発生し、超えた分の変数はリクエストから切り捨てられます。

  • 背景: 2011年末ごろ、PHPなどのWeb言語が配列の管理に使っている「ハッシュテーブル」の仕組みを悪用し、意図的にハッシュ衝突を大量発生させてサーバーのCPUを枯渇させる攻撃手法が問題になった経緯があります。
  • PHPの対応: これを受けて、PHP 5.3.9(2012年)のアップデートで導入されたのがこの max_input_vars です。一度に受け付ける変数の数を制限(デフォルト1000個)することで、CPUを枯渇させるほどの計算をさせないようにする、という物理的な防波堤として機能している模様です。

同様の「エラーが出ないのにデータが消える」現象に悩まされている方の参考になれば幸いです。