TL;DR
-
$argvは CLI では必ず存在するが、php-fpm / mod_php 経由ではregister_argc_argvが Off だと存在しない - 配布される
php.ini-production/php.ini-developmentはどちらも Off。つまり既定でこうなる -
$argv ?? []のような「未定義を黙って吸収する書き方」を挟むと、TypeError も警告も出ずにelseへ落ちる - 直すべきは設定ではなく既定値の向き。オプションが無いとき何もしない側に倒す
-
register_argc_argvを On にするとクエリ文字列が$argvに流入するので、対処として選ばない
起きたこと
CLI 実行を前提に書いたバッチスクリプトを、ブラウザ経由の入口から呼び出した。渡したはずの絞り込みオプションが1つも認識されず、「指定なし」と判定され、全件を処理する側の分岐が走った。
例外なし。警告なし。ログも空。失敗したという事実そのものが、どこにも出力されなかった。
再現する
最小限の再現コードを置く。
<?php
echo 'PHP_SAPI = ', PHP_SAPI, PHP_EOL;
echo 'register_argc_argv = ', var_export(ini_get('register_argc_argv'), true), PHP_EOL;
var_dump(isset($argv));
これを両方の入口から叩く。
# CLI
php check.php --foo
# Web(php-fpm など)
curl -s 'https://example.com/check.php?foo=1'
CLI 側は PHP_SAPI = cli / isset($argv) => bool(true)。Web 側は fpm-fcgi かつ bool(false) になるはずだ。
ここで注意したいのは、CLI と Web が読む php.ini は別ファイルであることが多いという点。php -i | grep register_argc_argv だけで判断すると、Web 側の値を見ていないまま結論を出すことになる。必ず両方から叩いて並べる。
なぜ無音になるのか
事故ったコードはこうだった。
$targetOnly = in_array('--target-only', $argv ?? [], true);
if ($targetOnly) {
processTargets(); // 軽い
} else {
processAll(); // 重い・副作用が大きい
}
$argv ?? [] は未定義変数の警告を避ける定番の書き方で、静的解析にも引っかからない。しかしこの一行のせいで、
$argv が未定義 → [] が返る → in_array() は false → 静かに else へ
という経路ができあがる。
?? [] を書いていなければどうなっていたか:
| PHP バージョン |
in_array('x', $argv, true) で $argv が未定義のとき |
|---|---|
| PHP 8.x |
TypeError(第2引数が array でない)で停止 |
| PHP 7.x | Warning: in_array() expects parameter 2 to be array |
どちらも気づける。握りつぶしたせいで、壊れ方が静かになった。isset($argv[1])、empty($argv) なども同じ性質を持つ。
対処
1. 既定値を「何もしない」に倒す(最優先)
$apply = in_array('--apply', $argv ?? [], true);
if (!$apply) {
fwrite(STDERR, 'dry run: 変更は行いません' . PHP_EOL);
exit(0);
}
引数が消えたぶんだけ動作が小さくなる形にする。被害が出たのは「指定なし=全部やる」という向きのせいで、$argv が消えること自体ではない。この形にしておけば、オプションの打ち忘れにも同じだけ効く。
2. CLI 以外の入口を閉じる(ただしログを残す)
if (PHP_SAPI !== 'cli') {
error_log('CLI 以外から呼び出されました: ' . ($_SERVER['REQUEST_URI'] ?? '-'));
http_response_code(403);
exit;
}
error_log() を省略しないこと。黙って終了するガードは「実行されなかった」のか「実行されて弾かれた」のかを事後に切り分けられなくする。
加えて、スクリプト本体を公開ディレクトリの外に置くか、Web サーバー側でも拒否しておくと二重になる。
3. 引数の読み取りを一箇所に閉じ込める
function readArgs(): array {
global $argv; // ← 必須
if (!isset($argv)) {
throw new RuntimeException('コマンドライン引数を取得できません(PHP_SAPI=' . PHP_SAPI . ')');
}
return $argv;
}
$argv はスーパーグローバルではない。関数内で global $argv; を書き忘れると、設定や入口に関係なく isset($argv) は常に false を返す。これも静かに間違うパターン。
空配列でフォールバックせず例外を投げるのが要点。getopt() も内部で同じコマンドライン引数を参照するため、入口が違えば同じように空振りする。「取得できない」と「1つも指定されていない」は別の事象として扱う。
register_argc_argv = On を選ばない理由
On にすれば Web 経由でも $argv は生える。ただし中身はURL のクエリ文字列だ。
GET /batch.php?--apply
→ $argv[1] === '--apply'
「引数がある/ない」の判定は通るようになるが、外部から任意の値を流し込める経路を自分で開けることになる。そして本来の問題——引数が届かなくても重い処理が走る——は解決していない。設定を変えても、既定値の向きはそのまま残る。
チェックリスト
-
CLI 前提のスクリプトの先頭に
PHP_SAPI !== 'cli'のガードがあるか - ガードは弾いたことをログに残しているか
- 破壊的な処理はオプション必須(=既定は dry run)になっているか
-
$argv ?? []/isset($argv[n])で未定義を吸収していないか -
register_argc_argvの値を CLI と Web の両方で確認したか - 引数読み取りを関数に集約し、取得失敗時に例外を投げているか