PHP CLI 工具开发实战:Symfony Console、交互与 PHAR 打包

系统讲解用 PHP 写专业命令行工具:Symfony Console 命令定义、参数与选项校验、SymfonyStyle 输出与表格进度条、交互式提问、依赖注入组织命令,以及用 Box 打包 PHAR、签名发布与自更新机制。

引言

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 工具

维度ShellPythonPHP CLI
字符串/数组处理强(管道)强强
复用项目业务代码不可需重写直接复用
依赖管理无pip/venvComposer
打包分发脚本PyInstallerPHAR(单文件)
团队熟悉度参差中高(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 官方文档

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. Symfony 框架实战:组件化架构、依赖注入容器与 Doctrine 集成
  2. PHP 领域驱动设计实战:限界上下文、聚合与六边形架构
  3. PHP 遗留代码现代化实战:坏味道识别、绞杀者模式与灰度切换