php think 命令注册 OK 却跑不起来——PHP 8.2 把 null 传 hash_hmac 变成了 Fatal
把一段同步逻辑包成 php think 命令并不难,难的是某天把服务器 PHP 从 7.4 升到 8.2,原本跑了大半年的定时任务突然全挂——而 php think list 还显示命令注册完好。这种"看起来正常、跑起来 Fatal"的组合让人排查了很久。
现象
$ sudo -u www php think sync:contacts
PHP Deprecated: hash_hmac(): Passing null to parameter #2 ($key)
of type string is deprecated in .../fastadmin/.../Auth.php on line 234
PHP Fatal error: Uncaught ErrorException: hash_hmac(): Passing null...
in .../Auth.php:234php think list 里命令赫然在列,加 --help 同样直接 Fatal。改成 cron 调用也一样。命令本身一行业务代码都没跑到。
根因
ThinkPHP 5 + FastAdmin 项目的常见写法是在 think command 里 new SomeController(),然后调其公共方法。问题出在 controller 的构造函数:FastAdmin 的 base controller __construct 会触发鉴权初始化链路(Auth::init → Token::get → hash_hmac($algo, $user_token, $secret))。
在 PHP 7.x / 8.0 时,$user_token 为 null(CLI 没有 HTTP 请求上下文,token 从 session 拿不到)只是触发一条 E_WARNING,ThinkPHP 5 默认 error_reporting 对 Warning 静默处理。
PHP 8.2 引入了 Deprecate null to non-nullable internal args RFC,hash_hmac 等几十个内置函数的"接受 null 当 string"行为被标记为 E_DEPRECATED。ThinkPHP 5 的错误处理器把 E_DEPRECATED 包装成 ErrorException 抛出 → Fatal。
结果:controller 刚开始实例化就崩在 auth init 阶段,命令的 configure() / execute() 一行都没走到。
CLI 进程没有 HTTP 请求上下文,FastAdmin auth init 从 session 拿不到 user_token,得到 null。PHP 8.2 之前这只是 Warning,8.2 起升级为 Deprecated → ThinkPHP 5 再把它抛成 ErrorException → Fatal。
表象像 command 注册失败,实际是在 constructor 里就挂了,业务代码一行都没执行。
排查路径
php think list显示命令存在 → 排除注册问题--help也 Fatal → Fatal 发生在execute()之前,定位在configure()或构造阶段- 加
-v看完整堆栈 → 指向Auth.php:234,是hash_hmac调用 - 搜 command 文件 → 发现
new SomeController()在 command 构造函数里 - 确认根因:controller
__construct→ auth init →hash_hmac(null, ...)→ Fatal
正解
不要在 think command 里实例化 controller。把业务逻辑抽到独立的 service class,让 think command 和 controller 分别调 service。
1. 新建 service class(不依赖任何 controller 上下文)
<?php
namespace app\api\service;
use think\Db;
use think\Log;
class SyncService
{
public function __construct(string $dbConnect = 'default')
{
// 只依赖 Db / Log 等通用框架组件
// 不调 $this->auth / $this->request / Token::get
}
public function run(array $opts = []): array
{
// 业务逻辑从 controller 搬过来
return ['processed' => 0, 'failures' => []];
}
}2. controller 保留方法签名,内部 delegate 给 service
public function doSync($taskId = null)
{
return (new \app\api\service\SyncService())
->run(['task_id' => $taskId]);
}3. think command 直接 instantiate service,加 --dry-run 方便验证
protected function execute(Input $input, Output $output)
{
if ($input->getOption('dry-run')) {
$output->writeln('[dry-run] service ready, skip DB write / API call');
return 0;
}
$service = new \app\api\service\SyncService(); // ← 不经过 controller,无 auth init
$result = $service->run();
$output->writeln(json_encode($result, JSON_UNESCAPED_UNICODE));
return 0;
}4. 用 dry-run 验证修复生效
sudo -u www php think sync:contacts --dry-run
# 期望:exit=0,无 hash_hmac deprecation,无 Fatalservice class 只依赖 Db / Cache / Log 等通用框架 API,不持有 $this->auth / $this->request 等 controller-only 上下文。think command 直接实例化 service,彻底跳过 auth init 链路,PHP 8.2 的 null 限制不再触发。
FastAdmin auth init 是全站登录鉴权的核心链路,hash_hmac / md5 / Token::get 等调用点散布在多处。逐一加 (string)$key cast 需要全站回归测试,改错一处就可能影响线上登录。相比之下,抽 service class 是局部架构调整,行为等价,风险隔离在新文件里。
一句话外卖
ThinkPHP 5 / FastAdmin 项目升 PHP 8.2 后,think 命令只要直接或间接实例化过 controller,就会被 auth init 的 null 参数 Fatal 击杀——把业务逻辑抽到不依赖 controller 上下文的 service class,是性价比最高的绕开式修复。