引言
PHP 不只是 Web 语言。composer、phpunit、phpstan、php-cs-fixer、wp-cli、laravel 安装器——这些每天被数百万开发者调用的工具,全部是 PHP 写的命令行程序。PHP 写 CLI 的优势很实在:与业务代码同语言、可直接复用项目的领域模型、生态里有成熟的 Console 组件与打包方案。
一个「能跑」的脚本和一个「专业」的 CLI 工具之间差距很大:前者 php script.php foo 靠 $argv 取值,参数写错就静默出错;后者有清晰的 --help、类型化的参数校验、彩色输出、进度条、交互确认、可执行文件分发与自更新。
本文以 symfony/console 为主线,从命令定义一路讲到 PHAR 打包与分发。它既是 Laravel Artisan 的底层,也是独立工具的标准底座——学一次,两边通吃。
关联阅读:服务容器与依赖注入的机制见 https://plumephp.com/php-laravel-internals/;把工具发布成 Composer 包的完整流程见 https://plumephp.com/php-composer-package-development/。
目录
- 1. 为什么 PHP 适合写 CLI 工具
- 2. Symfony Console 快速上手
- 3. 输入解析:参数、选项与校验
- 4. 输出:样式、表格与进度条
- 5. 交互:提问、确认与选择
- 6. 命令组织与依赖注入
- 7. 打包为 PHAR
- 8. 分发、版本与自更新
- 9. 测试与工程化实践
- 延伸阅读
1. 为什么 PHP 适合写 CLI 工具
| 维度 | Shell | Python | PHP CLI |
|---|---|---|---|
| 字符串/数组处理 | 强(管道) | 强 | 强 |
| 复用项目业务代码 | 不可 | 需重写 | 直接复用 |
| 依赖管理 | 无 | pip/venv | Composer |
| 打包分发 | 脚本 | PyInstaller | PHAR(单文件) |
| 团队熟悉度 | 参差 | 中 | 高(PHP 团队) |
核心优势:当工具需要调用项目的领域逻辑(比如「批量重算订单金额」),PHP CLI 可以直接注入项目里的服务,而 Shell/Python 只能调 API 或重写一遍。
裸脚本($argv[1] 手动取值)没有帮助信息、没有校验、没有退出码语义;换成 Console 组件后,php bin/tool help export 自带文档,参数类型与默认值都可声明:
php bin/tool export --format=csv --since=2026-01-01
php bin/tool help export
常见工具形态包括:运维脚本(数据迁移、缓存预热)、开发脚手架(生成代码骨架)、独立产品(静态分析器、API 客户端)、定时任务(替代难以调试的 crontab + 裸脚本)。
2. Symfony Console 快速上手
composer require symfony/console
#!/usr/bin/env php
<?php
// bin/tool
require __DIR__ . '/../vendor/autoload.php';
$app = new Symfony\Component\Console\Application('demo-tool', '1.0.0');
$app->add(new App\Command\ExportCommand());
$app->run();
<?php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:export', description: '导出订单数据')]
final class ExportCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('开始导出……');
return Command::SUCCESS;
}
}
退出码是 CLI 的 API:CI 流水线、crontab 告警都依赖它,必须遵守常量语义。
| 退出码 | 含义 |
|---|---|
| 0 | 成功(Command::SUCCESS) |
| 1 | 一般失败(Command::FAILURE) |
| 2 | 命令用法错误(Command::INVALID) |
| 130 | 被 Ctrl+C 中断 |
命令生命周期有三个钩子:initialize() 在参数绑定前执行(做前置准备),interact() 在参数校验前执行(补问缺失参数),execute() 是主体。
3. 输入解析:参数、选项与校验
protected function configure(): void
{
$this
->addArgument('file', InputArgument::REQUIRED, '待处理文件')
->addArgument('tags', InputArgument::IS_ARRAY, '标签列表')
->addOption('format', 'f', InputOption::VALUE_REQUIRED, '输出格式', 'csv')
->addOption('dry-run', null, InputOption::VALUE_NONE, '仅演练不写入')
->addOption('level', null, InputOption::VALUE_REQUIRED, '日志级别', 'info');
}
| 类型 | 说明 |
|---|---|
| VALUE_NONE | 布尔开关(--dry-run) |
| VALUE_REQUIRED | 必须带值(--format=csv) |
| VALUE_OPTIONAL | 值可选 |
| VALUE_IS_ARRAY | 可重复传入(--tag=a --tag=b) |
取值后必须校验,失败要抛异常而不是静默用默认值——那是脚本思维:
$format = $input->getOption('format');
$file = $input->getArgument('file');
if (!in_array($format, ['csv', 'json', 'xlsx'], true)) {
throw new \InvalidArgumentException("不支持的格式:{$format}");
}
if (!is_readable($file)) {
throw new \RuntimeException("文件不可读:{$file}");
}
抛 InvalidArgumentException 时 Console 打印错误并返回退出码 2,抛其他异常返回 1。危险操作还应在 initialize() 里做前置拦截(如要求显式 --force)。
配置优先级建议:命令行选项 > 环境变量 > 配置文件 > 默认值,让同一个工具在本地与 CI 中都能用同一套代码。
4. 输出:样式、表格与进度条
use Symfony\Component\Console\Style\SymfonyStyle;
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('订单导出');
$io->success('导出完成,共 1280 条');
$io->warning('发现 3 条订单金额异常');
$io->error('无法连接数据库');
return Command::SUCCESS;
}
SymfonyStyle 自动处理缩进、颜色与 --no-interaction 场景。表格与进度条同样开箱即用:
$io->table(['订单号', '金额', '状态'], [
['SO-1001', '199.00', '已支付'],
['SO-1002', '89.50', '待支付'],
]);
$bar = new ProgressBar($output, count($rows));
$bar->start();
foreach ($rows as $row) {
$this->process($row);
$bar->advance();
}
$bar->finish();
输出详细级别由 -v 控制,把调试信息放在 verbose 级别,是让工具既能安静地跑在 crontab 里、又能在排查时吐露细节的关键。
| 选项 | 输出内容 |
|---|---|
-q / --quiet | 仅错误 |
| 默认 | 正常信息 |
-v / -vv | 详细 / 更详细 |
-vvv | 全部(含内部追踪) |
if ($output->isVerbose()) {
$output->writeln('<comment>连接参数:' . $dsn . '</comment>');
}
5. 交互:提问、确认与选择
use Symfony\Component\Console\Question\{ChoiceQuestion, ConfirmationQuestion, Question};
$io->ask('请输入租户 ID', null, function ($v) {
if (!ctype_digit((string) $v)) {
throw new \RuntimeException('必须是数字');
}
return (int) $v;
});
$io->confirm('确认删除全部数据?', false); // 默认 false,回车即否
$io->choice('选择目标环境', ['dev', 'staging', 'prod'], 'dev');
$io->askHidden('请输入 API Token'); // 输入不回显
确认类提问的默认值必须是安全的那一侧(false),让「一路回车」不会造成事故:
if ($env === 'prod' && !$io->confirm('即将在生产环境执行,确认继续?', false)) {
$io->warning('已取消');
return Command::SUCCESS;
}
CI 环境没有 TTY,任何 ask() 都会抛异常,因此必须先判断交互性:
if (!$input->isInteractive()) {
$tenantId = $input->getOption('tenant')
?? throw new \RuntimeException('非交互模式必须传 --tenant');
}
6. 命令组织与依赖注入
final class ExportCommand extends Command
{
public function __construct(
private OrderRepository $orders,
private ExporterInterface $exporter,
) {
parent::__construct();
}
}
在 Symfony 骨架中,#[AsCommand] + autoconfigure 会自动把命令注册进应用,构造器依赖照常注入——这与 Web 控制器完全一致。独立工具里则用 $app->addCommand(new ExportCommand())(7.x 推荐懒加载,只有执行时才实例化)。
命令命名建议 模块:动作(order:export),既分组又便于 Tab 补全:
$app->addCommands([
new App\Command\Order\ExportCommand(),
new App\Command\Order\ReconcileCommand(),
new App\Command\Cache\WarmupCommand(),
]);
把业务逻辑放在服务类,命令只做参数解析 + 调用 + 输出。这样同一段逻辑既能被命令调用,也能被队列任务或 Web 接口调用,且可以单元测试服务而不必启动 Console。
7. 打包为 PHAR
PHAR 是 PHP 的单文件归档格式:把整个应用(含 vendor)打包成一个可执行文件,用户 chmod +x 后直接运行,无需 Composer。创建 PHAR 需要放开只读限制:
php -d phar.readonly=0 vendor/bin/box compile # Box 是事实标准打包工具
{
"main": "bin/tool",
"output": "build/tool.phar",
"compression": "GZ",
"directories": ["src"],
"files": ["composer.json", "LICENSE"],
"finder": [{ "name": "*.php", "exclude": ["tests"], "in": ["vendor"] }],
"stub": true
}
| 坑 | 原因 | 处理 |
|---|---|---|
phar.readonly 报错 | 默认禁止创建 PHAR | 打包时用 -d phar.readonly=0 |
| 找不到文件 | 未打进包内 | 用 __DIR__ 相对路径,避免绝对路径 |
| 依赖动态加载失败 | 反射/字符串类名 | Box 的 check-requirements 会告警 |
| 体积过大 | 打进了 dev 依赖 | composer install --no-dev 后再打包 |
| 签名缺失 | 未配置 | 用 OpenSSL 私钥签名,发布 .phar.pubkey |
Box 生成的 stub 会自动处理 PHAR 与源码两种运行方式的差异,并在加载前做平台校验(PHP 版本、扩展)。发布前务必在干净环境验证:换一台没有项目依赖的机器跑一遍 tool.phar --version。
8. 分发、版本与自更新
| 方式 | 优点 | 缺点 |
|---|---|---|
| Composer 全局安装 | 依赖管理天然 | 需要 Composer |
| PHAR 单文件 | 零依赖、可离线 | 体积大、需签名 |
| 容器镜像 | 环境完全一致 | 需 Docker |
实践中常见组合:PHAR 作为主分发形态,Composer 作为开发者备选。版本号应来自构建时注入(CI 中读取 git tag),而不是硬编码,避免「代码改了版本没改」。
$latest = $this->fetchLatestRelease(); // 读 GitHub Releases API
if (version_compare($latest['tag'], $this->getVersion(), '>')) {
$tmp = tempnam(sys_get_temp_dir(), 'upd');
file_put_contents($tmp, file_get_contents($latest['url']));
if (!$this->verifySignature($tmp, $latest['sig'])) {
throw new \RuntimeException('签名校验失败,拒绝更新');
}
chmod($tmp, 0755);
rename($tmp, $_SERVER['argv'][0]); // 原子替换
}
自更新必须校验签名(Ed25519/OpenSSL),否则等于给攻击者留了一条远程执行通道。另外要处理 Windows 上「文件被占用无法替换」的场景。
发布清单:打 tag 并生成 changelog;CI 中构建 PHAR 与 .sha256、签名;上传到 GitHub Releases;在干净容器中冒烟测试 --version、--help 与一个真实子命令;更新文档中的安装命令。
9. 测试与工程化实践
use Symfony\Component\Console\Tester\CommandTester;
public function test_export_writes_csv(): void
{
$command = new ExportCommand($this->fakeRepo, $this->fakeExporter);
$tester = new CommandTester($command);
$exit = $tester->execute(['file' => 'orders.csv', '--format' => 'csv']);
$this->assertSame(Command::SUCCESS, $exit);
$this->assertStringContainsString('导出完成', $tester->getDisplay());
}
CommandTester 让命令的输入输出都可断言,也能设置 ['interactive' => false] 测试非交互分支。退出码是稳定的契约,输出文案会变——测试应优先断言退出码与副作用,例如 assertSame(Command::INVALID, $tester->execute([...]))。
CLI 工具同样应接入 PHPStan 与代码风格检查,工具类项目通常比 Web 项目更容易做到高等级类型覆盖,详见 https://plumephp.com/php-static-analysis-quality/。
| 工程化项 | 要求 |
|---|---|
--help | 每个参数都有清晰说明与默认值 |
| 退出码 | 严格遵循 0/1/2 约定 |
| 日志 | 支持 -v 分级,敏感信息脱敏 |
| 幂等 | 重复执行结果一致(配合 --dry-run) |
| 超时 | 长任务支持信号处理与优雅退出 |
| 文档 | README 给出安装、用法、退出码表 |
一句话总结:写好 CLI 工具的关键不是「让脚本能跑」,而是把它当成一个有用户、有契约、有生命周期的产品——清晰的参数与帮助、稳定的退出码、可交互也可非交互、可打包可更新、可测试可分析。
延伸阅读
- https://plumephp.com/php-laravel-internals/ — 服务容器与依赖注入,命令类构造器注入的底层机制
- https://plumephp.com/php-composer-package-development/ — 把 CLI 工具发布为 Composer 包的完整流程
- https://plumephp.com/php-testing-practice/ — CommandTester 之外的测试体系与 CI 门禁
- https://plumephp.com/php-static-analysis-quality/ — PHPStan 与 Rector 在工具类项目中的应用
- Symfony Console 官方文档
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。