
前言PHP 写 CLI命令行接口Command Line Interface脚本最让人抓狂的阶段往往是参数收不到。典型症状有三种第一种脚本在终端里跑php sync.php --typeuser结果程序完全没反应--type像是被空气吞了第二种参数放在前半段能收到放在位置参数后面就收不到比如php sync.php user --typefull里--type神秘失踪第三种函数里想读参数写了$argv却提示未定义变量明明在var_dump($argv)时代码还能跑。这三种症状背后是三个不同的事实PHP 的getopt()在遇到第一个非选项参数时就停止解析$argv不是超全局变量出了全局作用域就没了而$argv本身只做空格分词不做任何选项语义解析。本文把 PHP CLI 传参的三条路径——$argv/$argc、getopt()、标准输入STDIN——讲清楚并给出一份可直接运行的完整脚本涵盖短选项、长选项、可选值、位置参数、管道输入和退出码。示例最低要求 PHP 7.1因为用到getopt()的第三个参数在 PHP 8.x 上行为一致。一、最底层的入口$argv、$argc 与 $_SERVERPHP 在 CLI SAPIServer API下会自动填充两个变量$argc参数个数和$argv参数数组。规则非常简单按空格切分不做任何解析。php report.php --typeuser -v hello world对应的$argv是[ 0 report.php, // 脚本路径永远在第 0 位 1 --typeuser, 2 -v, 3 hello world, // 带引号的部分被 shell 合并成一个参数 ]$argc等于count($argv)即 4。这里有三条必须记住的性质$argv[0]是脚本路径不是第一个业务参数。写$argv[1]才拿到用户输入的第一个参数。用basename($argv[0])可以在 usage 提示里显示脚本名。$argv不是超全局变量。它只在全局作用域可用。在函数或类方法里必须global $argv;或者更推荐的做法——用真正的超全局$_SERVER[argv]它在任何作用域都能访问。PHP 不做任何解析。--typeuser就是一个完整字符串两边不会自动拆开-v也不会被理解成verbose 开关。要语义就得自己上getopt()或手写解析。另外要区分 SAPI$argv在 CLI 下总是可用即使register_argc_argv关掉但在 CGI/FPM 下Web 请求的 query string 会被塞进$_SERVER[argv]当register_argc_argvOn时这就是历史上Web 端也能读 argv的怪现象。所以脚本开头加一句 SAPI 检查是稳妥的?php declare(strict_types1); if (PHP_SAPI ! cli) { fwrite(STDERR, 本脚本只能通过命令行运行\n); exit(2); }二、getopt()短选项与长选项的解析规则getopt()的函数签名是getopt(string $short_options, array $long_options [], int $rest_index null): array|false短选项字符串用冒号表达是否带值写法含义用法示例f布尔开关不带值-vf:必须带值-f a.txt或-fa.txtf::可选带值-f或-f a.txt长选项用数组表达规则相同写法含义用法示例verbose布尔开关--verbosefile:必须带值--filea.txt或--file a.txtoutput::可选带值--output或--outputa.txt返回值是一个关联数组没带值的开关会返回false所以判断开关是否出现要用array_key_exists()不能用empty()——因为false和不存在在empty()眼里一样。getopt()最坑的一条行为官方文档明确写了解析在遇到第一个非选项参数时结束后面的内容一律丢弃。也就是php sync.php user --typefull # ^^^^ 第一个非选项参数getopt 到这里就停了 # --typefull 被彻底忽略从 PHP 7.1 起getopt()的第三个参数$rest_index会收到解析停在第几个位置用它就能自己把剩下的位置参数捞回来$options getopt(f:v, [file:, verbose], $restIndex); $positional array_slice($argv, $restIndex); // 剩下的位置参数getopt()还有一个返回值陷阱解析失败时它返回false而不是空数组。所以判断必须写成if ($options false)用if (!$options)会把合法的空结果一起判成失败。三、标准输入与退出码管道场景的正确姿势CLI 脚本经常要进管道cat ids.txt | php import.php --batch100。这时参数走getopt()数据走 STDIN。CLI SAPI 会自动定义STDIN、STDOUT、STDERR三个常量文件句柄用法和fopen()返回的句柄一样// 一次读完整个 STDIN适合小数据 $raw stream_get_contents(STDIN); // 逐行读适合大文件内存占用恒定 while (($line fgets(STDIN)) ! false) { $line rtrim($line, \r\n); if ($line ) { continue; } // 处理 $line }三个约定必须守住业务数据只写 STDOUT日志和进度写 STDERR。否则php export.php data.csv导出的 CSV 里会混进正在处理第 3 条这类提示下游解析直接崩。-作为文件名的约定很多 Unix 工具用-表示从 STDIN 读。自己实现时保持一致脚本就能自然融入管道。退出码要有语义0成功、1一般错误、2用法错误、其他值按业务自定义。用exit(1)显式设置别让脚本总是返回 0——那样在 CI 或set -e的 shell 脚本里失败会被当成成功。四、实战一份完整的 CLI 参数处理脚本下面这份脚本可以直接保存为sync.php运行覆盖短选项、长选项、可选值、位置参数、STDIN 管道和退出码#!/usr/bin/env php ?php declare(strict_types1); // 最低要求PHP 7.1getopt 的 $rest_index 参数在 PHP 8.x 上行为一致 if (PHP_SAPI ! cli) { fwrite(STDERR, 只能在 CLI 下运行\n); exit(2); } /** * 用法提示统一走 STDERR避免污染管道里的业务数据 */ function usage(string $script): void { $lines [ 用法php {$script} [选项] [目标...], 选项, -f, --fileFILE 输入文件写 - 表示从 STDIN 读, -t, --typeTYPE 任务类型默认 full, -o, --output[FILE] 输出文件可省略值, -v, --verbose 输出详细日志, -h, --help 显示帮助, ]; fwrite(STDERR, implode(PHP_EOL, $lines) . PHP_EOL); exit(2); // 用法错误统一用退出码 2 } $script basename($_SERVER[argv][0] ?? sync.php); // 1) 解析选项。遇到第一个非选项就停并用 $restIndex 记住停在哪 $opts getopt(f:t:o::vh, [file:, type:, output::, verbose, help], $restIndex); // getopt 失败返回 false不是空数组 if ($opts false) { usage($script); } // 2) 开关判断用 array_key_exists不能用 empty值可能是 false if (array_key_exists(h, $opts) || array_key_exists(help, $opts)) { usage($script); } $verbose array_key_exists(v, $opts) || array_key_exists(verbose, $opts); // 3) 取值时给默认值短选项与长选项要一起看 $type $opts[t] ?? $opts[type] ?? full; $file $opts[f] ?? $opts[file] ?? null; $output $opts[o] ?? $opts[output] ?? null; // 4) 位置参数第一个非选项之后的全部内容 $targets array_slice($_SERVER[argv], $restIndex); // 5) 可选值选项没带值时是 false用 false 判断 $outputEnabled ($output ! false); $outputFile is_string($output) ? $output : php://stdout; if ($file null $targets [] !$outputEnabled) { usage($script); } // 6) 数据来源文件名、- 或没给时读 STDIN $lines []; if ($file null || $file -) { if (function_exists(stream_isatty) stream_isatty(STDIN)) { fwrite(STDERR, 提示未提供文件且 STDIN 是终端跳过数据读取\n); } else { while (($line fgets(STDIN)) ! false) { $line trim($line); if ($line ! ) { $lines[] $line; } } } } else { if (!is_readable($file)) { fwrite(STDERR, 无法读取文件{$file}\n); exit(1); } $lines file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; } if ($verbose) { fwrite(STDERR, sprintf( [verbose] type%s file%s output%s targets%d stdin_lines%d\n, $type, $file ?? (stdin), $outputEnabled ? $outputFile : (off), count($targets), count($lines) )); } // 7) 业务结果写 STDOUT $handle $outputEnabled ? fopen($outputFile, wb) : fopen(php://stdout, wb); if ($handle false) { fwrite(STDERR, 无法打开输出{$outputFile}\n); exit(1); } foreach ($targets as $i $target) { fwrite($handle, {$type}\t{$target}\n); } foreach ($lines as $line) { fwrite($handle, data\t{$line}\n); } if ($handle ! STDOUT) { fclose($handle); } exit(0); // 显式成功码可以这样验证php sync.php -v --typeuser alice bob printf id1\nid2\n | php sync.php --file- -o out.txt alpha php sync.php --help; echo 退出码$?第二条命令会把alpha和两行 STDIN 数据一起写进out.txtverbose 日志则出现在终端STDERR上不会混进文件。如果脚本选项继续膨胀子命令、参数校验、自动补全再考虑上 Symfony Console 这类组件但在选项少于十个的场景getopt()加上面这套骨架足够而且零依赖。常见坑点1. 在函数里直接使用$argv// ❌ $argv 不是超全局函数里读到的是 null报 Undefined variable function parseInput(): array { return array_slice($argv, 1); }// ✅ 用超全局 $_SERVER[argv]或把参数显式传进去 function parseInput(array $argv): array { return array_slice($argv, 1); } parseInput($_SERVER[argv]);2. 把选项写在位置参数后面# ❌ getopt 遇到 user 就停止解析--typefull 被丢弃 php sync.php user --typefull# ✅ 选项放前面或者用 $rest_index 自己补捞后面的内容 php sync.php --typefull user3. 用empty()判断开关是否出现// ❌ 开关的值是 falseempty(false) 为 true-v 被误判成没传 if (!empty($opts[v])) { $verbose true; }// ✅ $verbose array_key_exists(v, $opts);4. 把f:写成f::导致取值时拿到 false// ❌ 可选值写法下用户写 --file 不带值不会报错$file 变成 false // 后面 is_readable(false) 报错或悄悄读错文件 $opts getopt(, [file::]);// ✅ 必填值就用单冒号让 getopt 直接拒绝非法用法 $opts getopt(, [file:]);5. 用!$opts判断 getopt 是否失败// ❌ 用户一个选项都没传时 $opts 是空数组被误判成解析失败 $opts getopt(, [verbose]); if (!$opts) { usage($script); }// ✅ 失败只会返回 false $opts getopt(, [verbose]); if ($opts false) { usage($script); }6. 把日志打到 STDOUT污染管道# ❌ data.csv 里混进了进度信息下游 csv 解析报错 php export.php data.csv// ✅ 日志走 STDERR业务数据走 STDOUT fwrite(STDERR, 已处理 100 条\n); fwrite(STDOUT, {$row}\n);7. 忘记设置退出码# ❌ 脚本内部失败但返回 0CI 与 set -e 都察觉不到 php import.php; echo $? # 0// ✅ 失败路径显式给非零码 if (!is_readable($file)) { fwrite(STDERR, 文件不可读\n); exit(1); }8. 反复出现的开关想靠 getopt 计数php build.php -v -v -v # 用户想表达更啰嗦// ❌ getopt 不会累加重复的短选项只保留最后一次拿不到次数 $level count((array)($opts[v] ?? [])); // 恒为 0 或 1// ✅ 想要可叠加的详细度就自己在解析后的原始 argv 里数或用 -vvv 这种单参数约定 $level 0; foreach ($_SERVER[argv] as $arg) { if ($arg -v) { $level; } } // 更稳的做法显式提供 --verbose1|2|3 这类带值选项总结场景推荐入口关键点简单脚本只要位置参数$_SERVER[argv]argv[0]是脚本名$argv非超全局需要-f/--file这类选项getopt()遇到第一个非选项即停止解析失败返回false需要选项后还有位置参数getopt()$rest_indexPHP 7.1 起支持用array_slice()捞回剩余数据来自管道fgets(STDIN)业务数据走 STDOUT日志走 STDERR需要子命令、自动补全Symfony Console 等组件选项超过十个再考虑一句话结论PHP CLI 传参没有魔法$argv只负责切分getopt()只负责从前往后扫到第一个非选项为止。把选项写前面、位置参数用$rest_index捞回、开关用array_key_exists判断、退出码显式设置这四条当成规则绝大多数参数传不进去的问题就消失了。