Composer安装包时自动执行脚本?理解构建钩子工作原理【深度解析】

先说一个核心事实:Composer 安装包时,默认情况下并不会自动执行任何脚本。除非你白纸黑字地在 composer.json 里配置了诸如 post-install-cmd 这类事件钩子,并且,关键的触发条件也恰好满足了。
为什么 post-install-cmd 有时不执行
这事儿的关键在于 composer.lock 文件。这个钩子只在两种情况下才会被触发:要么是 composer.lock 文件压根不存在;要么是它虽然存在,但已经“过期”了——也就是说,composer.json 里的依赖声明发生了变动,还没同步到 lock 文件里。如果你反复运行 composer install,而 lock 文件既完整又与 composer.json 完全匹配,那么这个钩子就会安静地“跳过”,根本不会运行。
- 典型现象:运行
composer install后脚本静默无声,但手动执行composer run-script post-install-cmd却能成功。这大概率是因为你的 lock 文件“太新了”,状态没变。 - 环境干扰:在 CI/CD 流水线里,为了安全和速度,常常会加上
--no-scripts参数。这个参数会直接禁用所有脚本,post-*系列钩子自然也不例外。 - 路径陷阱:脚本路径是基于项目根目录解析的。但如果你的项目使用了符号链接(symlink)或者 Docker 卷挂载(volume),PHP 的
getcwd()函数返回的当前工作目录可能并非你预想的路径,导致像./bin/init.php这样的相对路径脚本找不到。 - 拼写是硬性规定:必须严格写成
post-install-cmd(全小写,用短横线连接)。写成PostInstall、post_install_cmd或者post-install都是无效的。
怎么让脚本“每次 install 都执行”
想靠单一的 post-install-cmd 来保证每次运行,这条路走不通。需要一些组合策略:
- 双保险注册:同时注册
post-install-cmd和post-update-cmd这两个事件,这样无论是初次安装(install)还是更新依赖(update)这两种主流场景,都能覆盖到。 - 更稳定的选择:改用
post-autoload-dump事件。只要 Composer 重建了自动加载器(这发生在install、update甚至dump-autoload命令之后),它就会触发。时机虽然比post-install-cmd稍晚一点,但可靠性要高得多。 - 跳出生命周期:如果脚本的目的只是初始化配置或生成运行时文件,完全可以考虑在 PHP 应用的入口文件里加入懒加载逻辑。例如,检查
config.php是否存在,如果不存在,再调用vendor/your-package/bin/init.php来生成。这样就完全绕开了 Composer 生命周期的限制。
脚本值写法与执行安全边界
需要明确的是,composer.json 里的 scripts 字段是一个声明式的钩子注册机制,而不是一个任意的代码执行器。它的值只能接受以下三种形式之一:
- 字符串命令:例如
"php vendor/acme/tool/bin/setup.php"。注意,命令字符串必须用引号包裹,否则 JSON 解析会失败。 - 命令数组:例如
["npm ci", "php artisan migrate --force"]。数组内的命令会按顺序执行,如果其中任何一个命令执行失败,后续命令就会中断。 - 类静态方法调用:例如
["Acme\Installer", "run"]。这要求指定的类(Acme\Installer)已经能够通过 Composer 的autoload机制加载,并且被调用的方法(run)必须是公开的静态方法(public static)。 - 执行目录上下文:所有脚本默认都是在项目根目录的路径上下文下执行的,但 Composer 并不会自动帮你切换当前工作目录。如果你的脚本里使用了相对路径,务必先使用
chdir(__DIR__)切换到脚本所在目录,或者用getcwd()显式判断一下当前路径。
第三方包如何“自带”安装后逻辑
对于包作者来说,有一个必须遵守的安全底线:不能强制要求使用你包的项目去执行任何初始化脚本。Composer 默认禁止自动运行来自第三方包的任意代码,这是基本的安全原则。
- 提供标准入口,依赖用户配置:包可以提供标准的初始化入口,比如在
src/Installer.php里放一个静态方法。然后,在包的文档中明确要求用户,需要在他们自己项目的composer.json里手动注册这个钩子:"post-install-cmd": ["Vendor\Package\Installer::onInstall"]。 - 进阶方案:发布 Composer 插件:可以发布一个类型为
composer-plugin的包,通过实现Composer\Plugin\PluginInterface接口,在 Composer 的安装阶段介入。但这同样需要用户显式地require你的插件并启用它,而且插件本身也会受到更严格的审视。 - 别打
extra字段的主意:extra字段只是一个存放元数据的容器,它本身并不会触发任何执行逻辑。
说到底,脚本能否执行,往往不取决于你在 composer.json 里写了什么,而取决于谁、在什么条件下、调用了哪个 Composer 命令。锁文件的状态、持续集成环境里的参数、自动加载的顺序、路径解析的上下文——这些看似微末的细节,比脚本语法本身更容易导致静默的失败,值得投入更多注意力。
