1. 引言:为什么 PHP 也能写 CLI
提到 PHP,很多人的第一反应是「Web 后端语言」。事实上 PHP 从诞生之初就自带 CLI SAPI(Server API),可以像 Python、Node.js 一样编写命令行脚本。无论是数据迁移、定时任务、日志清理,还是内部运维工具,PHP CLI 都是一个被严重低估的选择。
本文将从最基础的 $argv / $argc 讲起,逐步过渡到工业级的 Symfony Console 组件,最后把脚本接入 Cron 实现定时调度。读完之后,你应该能独立写出一个结构清晰、可维护、可调度的 PHP 命令行工具。
2. CLI 模式与 Web 模式的区别
在动手写代码之前,先搞清楚两种运行模式的本质差异,能避免很多「为什么在浏览器里能跑、命令行里报错」的坑。
| 维度 | Web 模式(FPM/Apache) | CLI 模式 |
|---|---|---|
| 入口 | 每个 HTTP 请求触发一次脚本 | 手动或调度器执行一次脚本 |
| 生命周期 | 请求结束即销毁 | 进程运行到脚本结束 |
| 输入 | $_GET、$_POST、请求体 | $argv、$argc、STDIN |
| 输出 | HTTP 响应体 | STDOUT / STDERR |
| 超时 | 受 max_execution_time 限制 | 默认无限制(0) |
| 用户身份 | Web 服务器用户(如 www-data) | 当前登录用户 |
| 环境变量 | 由 Web 服务器注入 | 继承 Shell 环境 |
几个关键点值得单独强调:
php_sapi_name()可以判断当前运行模式,返回cli或fpm-fcgi等值。$_SERVER['argv']在 CLI 下可用,在 Web 下通常不存在。- 输出缓冲:CLI 下
echo直接输出到终端,不会经过 HTTP 缓冲;但如果你用了ob_start(),记得手动flush()。 - 标准输入输出:CLI 下可以用
STDIN、STDOUT、STDERR三个常量,Web 下它们不可用。
一个常见的兼容写法:
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "该脚本只能在命令行下运行\n");
exit(1);
}
两种模式的运行路径差异,可以用一张图直观对比:
flowchart TD
A["用户请求"] --> B{"运行模式判断"}
B -->|"HTTP 请求"| C["Web 模式 FPM/Apache"]
B -->|"命令行执行"| D["CLI 模式"]
C --> C1["读取 $_GET / $_POST"]
C1 --> C2["输出 HTTP 响应体"]
C2 --> C3["请求结束即销毁"]
D --> D1["读取 $argv / $argc / STDIN"]
D1 --> D2["输出 STDOUT / STDERR"]
D2 --> D3["进程运行到脚本结束"]
3. 最原始的入口:argc
PHP CLI 会把命令行参数原样放进两个预定义变量:
$argv:参数数组,$argv[0]是脚本文件名,后面依次是传入的参数。$argc:参数个数,等于count($argv)。
写一个最简单的脚本 hello.php:
<?php
// hello.php
var_dump($argv);
echo "参数个数: {$argc}\n";
执行:
php hello.php foo bar --name=张三
输出:
array(4) {
[0]=> string(9) "hello.php"
[1]=> string(3) "foo"
[2]=> string(3) "bar"
[3]=> string(11) "--name=张三"
}
参数个数: 4
可以看到,PHP 本身不会帮你解析 --name=张三 这种选项,它只是把整串当作一个普通参数。要真正解析选项,需要自己写逻辑,或者借助 getopt():
<?php
// 支持 -n 和 --name 两种写法
$options = getopt("n:", ["name:"]);
$name = $options['name'] ?? $options['n'] ?? '世界';
echo "你好,{$name}\n";
php greet.php --name=张三
php greet.php -n 李四
getopt() 足够应付简单场景,但它有几个明显短板:不支持子命令、不支持参数校验、帮助信息要手写、错误提示不友好。一旦工具稍微复杂一点,就会迅速失控。这正是 Symfony Console 存在的意义。
4. 使用 Symfony Console 组件
Symfony Console 是 Symfony 框架的独立组件,可以脱离框架单独使用,是 PHP 生态里最成熟的 CLI 解决方案之一。
4.1 安装
composer require symfony/console
4.2 最小可运行示例
<?php
// bin/app.php
require __DIR__ . '/../vendor/autoload.php';
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class GreetCommand extends Command
{
protected static $defaultName = 'greet';
protected function configure(): void
{
$this
->setDescription('向指定用户打招呼')
->addArgument('name', InputArgument::REQUIRED, '用户名称')
->addOption('times', 't', InputOption::VALUE_REQUIRED, '重复次数', 1);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$name = $input->getArgument('name');
$times = (int) $input->getOption('times');
for ($i = 0; $i < $times; $i++) {
$output->writeln("<info>你好,{$name}!</info>");
}
return Command::SUCCESS;
}
}
$app = new Application('Demo CLI', '1.0.0');
$app->add(new GreetCommand());
$app->run();
执行效果:
php bin/app.php greet 张三 --times=3
你好,张三!
你好,张三!
你好,张三!
php bin/app.php 不带参数时会自动列出所有命令,php bin/app.php greet --help 会输出自动生成的帮助信息——这些都不需要你写一行代码。
4.3 输入:参数与选项
Symfony Console 把输入分成两类:
- Argument(参数):位置相关,如
greet 张三中的张三。 - Option(选项):带前缀,如
--times=3或-t 3。
常用模式:
// 必填参数
->addArgument('file', InputArgument::REQUIRED, '待处理文件路径')
// 可选参数,带默认值
->addArgument('format', InputArgument::OPTIONAL, '输出格式', 'json')
// 数组参数
->addArgument('ids', InputArgument::IS_ARRAY, 'ID 列表')
// 布尔开关
->addOption('dry-run', null, InputOption::VALUE_NONE, '只演练不执行')
// 需要值的选项
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, '条数上限', 100)
// 可重复的选项
->addOption('tag', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, '标签')
读取时分别用 getArgument() 和 getOption()。
4.4 输出:格式化与样式
OutputInterface 提供了丰富的输出能力,最常用的是标签式样式:
$output->writeln('<info>操作成功</info>');
$output->writeln('<comment>注意:文件已存在</comment>');
$output->writeln('<error>执行失败:权限不足</error>');
内置标签包括 info(绿)、comment(黄)、error(红底白字)、question(青)。还可以组合:
$output->writeln('<info>成功</info> <comment>跳过 3 条</comment>');
需要更精细的控制时,可以用 SymfonyStyle,它封装了表格、进度条、提问等常用交互:
use Symfony\Component\Console\Style\SymfonyStyle;
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('数据导入工具');
$io->text('正在读取源文件...');
$io->table(
['ID', '名称', '状态'],
[
[1, '订单 A', '成功'],
[2, '订单 B', '失败'],
]
);
$io->progressStart(100);
for ($i = 0; $i < 100; $i++) {
usleep(10000);
$io->progressAdvance();
}
$io->progressFinish();
if (!$io->confirm('确认继续执行?', false)) {
$io->warning('已取消');
return Command::FAILURE;
}
$io->success('全部完成');
return Command::SUCCESS;
}
4.5 退出码与错误处理
命令行工具的退出码非常重要,Cron、CI、Shell 脚本都依赖它判断成败:
Command::SUCCESS(0):成功Command::FAILURE(1):业务失败Command::INVALID(2):参数错误
抛出异常时,Symfony Console 会自动捕获并输出红色错误信息,同时返回非零退出码。如果希望自定义异常展示,可以实现 renderThrowable() 或直接 try/catch 后 $output->writeln()。
5. 编写可执行脚本
一个合格的 CLI 脚本,除了逻辑正确,还要在工程化上做几件事。
5.1 添加 Shebang 并赋予执行权限
#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';
// ...
chmod +x bin/app.php
./bin/app.php greet 张三
用 #!/usr/bin/env php 而不是 #!/usr/bin/php,可以让脚本在不同环境下自动找到 PHP 解释器。
5.2 目录结构建议
project/
├── bin/
│ └── app.php # 入口,只负责组装 Application
├── src/
│ └── Command/
│ ├── ImportCommand.php
│ └── CleanupCommand.php
├── composer.json
└── vendor/
入口文件保持极简,业务逻辑全部放进 src/Command,每个命令一个类,便于测试和复用。
5.3 日志与输出分离
CLI 工具的输出分两类:给人看的(进度、提示)走 STDOUT,给机器看的(错误、告警)走 STDERR。这样在 Cron 里重定向时才能正确区分:
./bin/app.php import data.csv > /var/log/import.log 2> /var/log/import.err
在 Symfony Console 中,$output 默认写 STDOUT;需要写 STDERR 时用 ConsoleOutputInterface:
use Symfony\Component\Console\Output\ConsoleOutputInterface;
if ($output instanceof ConsoleOutputInterface) {
$output->getErrorOutput()->writeln('<error>写入失败</error>');
}
5.4 加锁防止重复执行
定时任务最怕「上一次还没跑完,下一次又启动了」。用文件锁是最简单的方案:
$lockFile = sys_get_temp_dir() . '/import.lock';
$fp = fopen($lockFile, 'c');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
fwrite(STDERR, "已有实例在运行,退出\n");
exit(1);
}
try {
// 业务逻辑
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
6. 与 Cron 集成
脚本写好后,交给 Cron 定时执行是最常见的落地方式。
6.1 基础写法
crontab -e
# 每天凌晨 2 点执行数据导入
0 2 * * * /usr/bin/php /var/www/project/bin/app.php import /data/daily.csv >> /var/log/import.log 2>&1
五个时间字段依次是:分、时、日、月、周。几个常用示例:
*/5 * * * * # 每 5 分钟
0 * * * * # 每小时整点
0 3 * * 1 # 每周一凌晨 3 点
0 0 1 * * # 每月 1 号零点
6.2 必须注意的坑
- 使用绝对路径:Cron 的
PATH极简,php可能找不到,务必写/usr/bin/php或which php的结果。 - 工作目录不同:Cron 默认在用户家目录执行,脚本里涉及相对路径的地方要用
__DIR__或chdir()。 - 环境变量缺失:
.env文件不会自动加载,需要在脚本里显式读取,或在 crontab 顶部声明。 - 输出重定向:不重定向的话,Cron 会尝试发邮件,长期积累可能撑爆
/var/mail。 - 时区:Cron 使用系统时区,PHP 脚本里最好显式
date_default_timezone_set()。
一个更稳妥的 crontab 写法:
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
APP_ENV=production
0 2 * * * cd /var/www/project && /usr/bin/php bin/app.php import /data/daily.csv >> /var/log/import.log 2>&1
6.3 用 Symfony Console 命令配合 Cron
由于 Symfony Console 命令本身就是可执行脚本,Cron 里直接调用即可。建议给每个定时任务单独建一个命令,并在命令内部做好日志记录:
protected function execute(InputInterface $input, OutputInterface $output): int
{
$logger = new \Monolog\Logger('cron');
$logger->pushHandler(new \Monolog\Handler\StreamHandler('/var/log/cron.log'));
try {
$logger->info('任务开始');
// 业务逻辑
$logger->info('任务结束');
return Command::SUCCESS;
} catch (\Throwable $e) {
$logger->error('任务失败: ' . $e->getMessage());
return Command::FAILURE;
}
}
7. 小结
回顾一下本文的脉络:
- PHP CLI 与 Web 模式在入口、生命周期、输入输出上都有本质差异,写脚本前先判断
PHP_SAPI。 $argv/$argc是最原始的入口,getopt()能应付简单选项,但复杂场景力不从心。- Symfony Console 提供了参数、选项、帮助、样式、进度条、退出码等一整套能力,是构建可维护 CLI 工具的首选。
- 可执行脚本要注意 Shebang、目录结构、STDOUT/STDERR 分离和文件锁。
- 接入 Cron 时,绝对路径、工作目录、环境变量、输出重定向、时区这五个坑必须提前规避。
把这套组合用熟之后,你会发现 PHP 在运维脚本、数据管道、内部工具这些场景里,其实相当顺手。