返回博客

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:234

php think list 里命令赫然在列,加 --help 同样直接 Fatal。改成 cron 调用也一样。命令本身一行业务代码都没跑到。

根因

ThinkPHP 5 + FastAdmin 项目的常见写法是在 think command 里 new SomeController(),然后调其公共方法。问题出在 controller 的构造函数:FastAdmin 的 base controller __construct 会触发鉴权初始化链路(Auth::initToken::gethash_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 RFChash_hmac 等几十个内置函数的"接受 null 当 string"行为被标记为 E_DEPRECATED。ThinkPHP 5 的错误处理器把 E_DEPRECATED 包装成 ErrorException 抛出 → Fatal。

结果:controller 刚开始实例化就崩在 auth init 阶段,命令的 configure() / execute() 一行都没走到。

坑:php think 命令实例化 controller 会在 CLI 环境触发 auth init,PHP 8.2 把 null token 变成 Fatal

CLI 进程没有 HTTP 请求上下文,FastAdmin auth init 从 session 拿不到 user_token,得到 null。PHP 8.2 之前这只是 Warning,8.2 起升级为 Deprecated → ThinkPHP 5 再把它抛成 ErrorException → Fatal。

表象像 command 注册失败,实际是在 constructor 里就挂了,业务代码一行都没执行。

排查路径

  1. php think list 显示命令存在 → 排除注册问题
  2. --help 也 Fatal → Fatal 发生在 execute() 之前,定位在 configure() 或构造阶段
  3. -v 看完整堆栈 → 指向 Auth.php:234,是 hash_hmac 调用
  4. 搜 command 文件 → 发现 new SomeController() 在 command 构造函数里
  5. 确认根因: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,无 Fatal
正解:把业务逻辑抽到 service class,think 命令绕开 controller auth init

service class 只依赖 Db / Cache / Log 等通用框架 API,不持有 $this->auth / $this->request 等 controller-only 上下文。think command 直接实例化 service,彻底跳过 auth init 链路,PHP 8.2 的 null 限制不再触发。

为什么不直接修 hash_hmac 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,是性价比最高的绕开式修复。