前阵子给 PhpAsk 做功能升级,代码里要用上 PHP 8.2 的枚举和 match 表达式,可线上某个历史模块还留在 PHP 5.6 上。直接把整站切过去不现实,同一台服务器又不能同时跑两个版本然后让它们互相打架。折腾了几天后我把“多版本 PHP 共存”这件事彻底想明白了:路径分开、进程分开、配置分开、请求路由分开,剩下的都是细节。这篇文章就是把完整的配置思路和操作步骤写出来,给同样需要给 PhpAsk 配置多版本 PHP 共存环境的朋友做个参考。
1. 为什么同一个项目会需要多个 PHP 版本?先看 PhpAsk 的真实处境
1.1 一个问答系统里的“新老拉锯”
PhpAsk 本身是一个基于 PHP 的问答系统,代码量不算小,历史包袱也不轻。早期模块用的是老框架,依赖的第三方库只支持 PHP 5.6;后来新增的接口和后台功能又用了不少 PHP 8.x 的新语法。如果强制把所有代码统一到同一个 PHP 版本,要么老框架跑不起来,要么新功能没法上线,两边都难受。
这种处境在真实项目里非常普遍,尤其当你维护一个超过三年的项目时。“干脆把老模块重写”听得容易,做起来是几周甚至几个月的工程量。相比之下,让两个版本在同一台机器上同时存在、互不干扰,是投入产出比最高的过渡方案。
1.2 多版本共存的四个典型场景
我梳理了实际会用到这套方案的场景,不止 PhpAsk 一个项目适用:
- 本地联调:电脑上同时有多个项目,一个用了 PHP 7.1,另一个用了 PHP 8.2,每次手动切换 PATH 非常容易忘事,直接共存最省心。
- 线上灰度迁移:新老代码需要跑在同一台服务器上,通过 Nginx 把部分路径或子域名的请求转发到新版本对应的 PHP-FPM,老路径继续走旧版本。
- CI 多版本测试:给开源项目跑兼容性矩阵,需要在同一台 Jenkins/GitLab Runner 上依次执行多个 PHP 版本,共存环境比逐个安装虚拟机轻量得多。
- 临时排障:线上某老接口报错,需要本地用旧 PHP 版本复现,又不想影响正在开发的新功能。
1.3 “共存”和“切换”是两码事:先把目标定义清楚
很多人一开始会把“多版本共存”和“多版本切换”搞混,这直接导致后面的配置文件改得一团糟。
“切换”是指同一个解释器入口在不同版本之间轮换,典型做法是改 PATH 环境变量、用 update-alternatives 或各种版本管理工具。它的特点是:某一时刻全局只有一个默认 PHP 生效。适合命令行工具开发,但不适合 Web 服务长期同时运行。
“共存”则是让多个 PHP 版本以独立的进程、独立的配置文件、独立的监听端口同时运行。Nginx 收到请求后,可以根据站点、路径或参数把请求派发给不同的 PHP-FPM 实例。这才是生产环境真正需要的方案。本文讲的以共存为主,命令行切换只是辅助。
2. 拆解 PHP 版本这把“多合一”瑞士军刀
2.1 一个 PHP 版本并不只是一个可执行文件
想把共存配置做对,先得知道一个“PHP 版本”到底由哪些部分组成。用表格列一下最直观:
| 组成对象 | 常见默认位置 | 共存时的冲突风险 |
|---|---|---|
| PHP 解释器二进制 | /usr/bin/php、/usr/local/bin/php | 命令行敲 php 时调错版本 |
| PHP-FPM 进程 | /usr/sbin/php-fpm | 多个 FPM 同时启动,监听端口冲突 |
| php.ini 配置文件 | /etc/php/7.x/、/usr/local/etc/ | 不同版本读了同一套配置,行为全乱 |
| 扩展动态库 | /usr/lib/php/、ext 目录 | 版本不匹配,扩展加载失败 |
| FPM 的 PID 和日志文件 | /run/php-fpm.pid、/var/log/php-fpm.log | 进程管理互相覆盖,日志串线 |
| Session 目录 | /var/lib/php/sessions | 两个版本共用,登录态串号 |
你可以把一个 PHP 版本理解成一条完整的工作流水线,可执行文件只是入口,配置、扩展、进程管理文件都是这条流水线的一部分。共存的关键不是“装了两个 php 命令”,而是把这条流水线上所有会碰撞的东西全部隔离开。
2.2 CLI 和 FPM 是两个完全独立的进程入口
新手最容易困惑的一点:在服务器上执行php -v看到的是 8.2,但浏览器访问站点时执行的还是 5.6,为什么?
因为 CLI(命令行接口)和 FPM(FastCGI 进程管理器)是两套独立的入口。php -v使用的是命令行解释器,路径由 PATH 环境变量决定;而 Nginx 处理 PHP 请求时,通过fastcgi_pass把请求转发给某个 PHP-FPM 的监听地址,跟 PATH 一点关系都没有。
做 Web 开发时,真正对请求负责的是 PHP-FPM 实例。配置多版本共存时,重心应该放在 FPM 的监听地址和配置隔离上,其次才是命令行工具的便利性。
2.3 影响“哪个版本生效”的三个关键配置点
- PATH 环境变量:决定你在 shell 里敲
php、composer、phpize时调用哪个目录下的程序。 - PHP 二进制编译参数:
--prefix、--with-config-file-path、--with-config-file-scan-dir决定了这个 PHP 默认去哪里找 php.ini 和扩展配置。这是版本隔离的根本。 - FPM 的 listen 配置:决定 Nginx 的
fastcgi_pass指向哪里,是 127.0.0.1:9000 还是 127.0.0.1:9005,是 TCP 端口还是 Unix Socket。
后面所有操作都围绕这三件事展开。
3. Linux 服务器实操:让 PHP 5.6 和 PHP 8.2 在同一台机器上和平相处
3.1 编译安装前的“分身”设计
我先说结论:最稳妥的做法是两个版本都从源码编译,安装到完全不同的前缀目录。以--prefix=/usr/local/php56和--prefix=/usr/local/php82为例,这样从二进制文件、php.ini 到扩展目录,天然就是两套独立的文件树。
以 Ubuntu 22.04 为例,先准备编译环境:
apt update apt install -y build-essential libxml2-dev libssl-dev libcurl4-openssl-dev \ libsqlite3-dev libonig-dev libzip-dev libgd-dev libpng-dev libjpeg-dev \ libfreetype-dev systemd pkg-config然后下载并编译 PHP 5.6.40。要注意,PHP 5.6 是很多年前发布的,在较新的系统上编译会遇到 OpenSSL 1.1、libxml2 等兼容问题,这是正常现象。如果只是为了快速复现,可以直接跳到后面的 Docker 方案。下面是源码方式:
cd /usr/local/src curl -O https://www.php.net/distributions/php-5.6.40.tar.gz tar xzf php-5.6.40.tar.gz && cd php-5.6.40 ./configure \ --prefix=/usr/local/php56 \ --with-config-file-path=/usr/local/php56/etc \ --with-config-file-scan-dir=/usr/local/php56/etc/conf.d \ --enable-fpm \ --with-fpm-user=www-data --with-fpm-group=www-data \ --with-mysql=mysqlnd --with-pdo-mysql=mysqlnd --with-mysqli=mysqlnd \ --with-gd --with-jpeg-dir --with-png-dir --with-freetype-dir \ --with-openssl --with-curl --enable-mbstring --enable-zip make -j$(nproc) make installPHP 8.2 的编译参数和老版本不一样,不能直接照抄。PHP 8 以后不再使用--with-mysql、--with-jpeg-dir这类老式参数:
cd /usr/local/src curl -O https://www.php.net/distributions/php-8.2.15.tar.gz tar xzf php-8.2.15.tar.gz && cd php-8.2.15 ./configure \ --prefix=/usr/local/php82 \ --with-config-file-path=/usr/local/php82/etc \ --with-config-file-scan-dir=/usr/local/php82/etc/conf.d \ --enable-fpm \ --with-fpm-user=www-data --with-fpm-group=www-data \ --with-pdo-mysql=mysqlnd --with-mysqli=mysqlnd \ --with-gd --with-openssl --with-curl --enable-mbstring --enable-zip make -j$(nproc) make install编译参数里最关键的是--prefix、--with-config-file-path和--with-config-file-scan-dir。前两个决定了程序装到哪里、去哪个目录找 php.ini,第三个决定了额外加载的 .ini 片段目录。只要这三项分开,后面就不会因为配置文件互相干扰而头疼。
3.2 两个 PHP-FPM 的监听地址与服务管理
安装完成后,两个版本都带了自己的 php-fpm 可执行文件,但默认监听地址很可能都是 127.0.0.1:9000。如果直接启动,后启动的那个一定会报“Address already in use”。
解决办法是修改各自 pool 配置里的 listen 参数。PHP 5.6 配置在/usr/local/php56/etc/php-fpm.d/www.conf:
; /usr/local/php56/etc/php-fpm.d/www.conf user = www-data group = www-data listen = 127.0.0.1:9005PHP 8.2 配置在/usr/local/php82/etc/php-fpm.d/www.conf:
; /usr/local/php82/etc/php-fpm.d/www.conf user = www-data group = www-data listen = 127.0.0.1:9008如果你更习惯用 Unix Socket,也可以改成listen = /run/php56/php-fpm.sock和listen = /run/php82/php-fpm.sock,记得提前创建对应目录并设置好权限,Nginx 所在的用户需要能访问。
为了方便 systemd 管理,我建议把两个 FPM 注册成两个服务。创建/etc/systemd/system/php-fpm-56.service:
[Unit] Description=PHP 5.6 FPM for PhpAsk legacy After=network.target [Service] Type=forking PIDFile=/usr/local/php56/var/run/php-fpm.pid ExecStart=/usr/local/php56/sbin/php-fpm --fpm-config /usr/local/php56/etc/php-fpm.conf ExecReload=/bin/kill -USR2 $MAINPID PrivateTmp=true [Install] WantedBy=multi-user.target同理创建php-fpm-82.service,把路径替换成/usr/local/php82、PIDFile 换成/usr/local/php82/var/run/php-fpm.pid。PID 文件路径一定不能共用,否则 systemd 会读到错误的进程号,重载或重启时误杀另一个 PHP-FPM。
启动并设置开机自启:
systemctl daemon-reload systemctl enable --now php-fpm-56 php-fpm-823.3 每个版本各用各的 php.ini 和扩展目录
编译完成并安装后,记得从源码包复制生产版 php.ini:
cp /usr/local/src/php-5.6.40/php.ini-production /usr/local/php56/etc/php.ini cp /usr/local/src/php-8.2.15/php.ini-production /usr/local/php82/etc/php.ini然后分别修改两个 php.ini 里容易冲突的项。以下以 8.2 为例,5.6 对应路径替换即可:
; /usr/local/php82/etc/php.ini extension_dir = "/usr/local/php82/lib/php/extensions/no-debug-non-zts-20230831" session.save_path = "/var/lib/php82/sessions" date.timezone = Asia/Shanghaisession 目录分开这一点很容易被忽略。两个版本共用同一个 session 目录的后果是:同一个域名下,登录状态可能在两个版本之间来回串,用户刚登录好,刷新一次就掉了。正确做法是提前建好目录并授权:
mkdir -p /var/lib/php56/sessions /var/lib/php82/sessions chown -R www-data:www-data /var/lib/php56 /var/lib/php82 chmod 733 /var/lib/php56/sessions /var/lib/php82/sessions同时把 conf.d 目录建好:
mkdir -p /usr/local/php56/etc/conf.d mkdir -p /usr/local/php82/etc/conf.d以后给某个版本单独加配置时,往对应 conf.d 里放 .ini 文件就行,互不影响。
3.4 快速验证:一套命令确认环境正常
配置完成后,验证要分两层:命令行层和 FPM 层。
# 命令行层 /usr/local/php56/bin/php -v | head -n 1 /usr/local/php82/bin/php -v | head -n 1 # FPM 进程层 ps aux | grep php-fpm # 端口监听层 ss -tlnp | grep -E '9005|9008'命令行层验证的是两个二进制是否能独立运行。FPM 层验证的是两个进程是否正确监听不同端口。最后再用浏览器或 curl 访问两个测试页面,分别写一个phpinfo()文件,放到不同版本的站点根目录下,看输出的 PHP Version 是否和预期一致。
如果 curl 输出不对,优先检查 Nginx 的fastcgi_pass是否指向了正确的端口,不要一上来就怀疑 PHP 安装有问题。
4. PhpAsk 站点路由:Nginx 把请求送到正确的 PHP 版本
4.1 不同站点走不同 PHP 版本:server 级配置
装上两个 PHP 版本后,Nginx 是调度器的角色。最简单的情况是:PhpAsk 主站跑 PHP 8.2,历史遗留的管理后台跑 PHP 5.6,分别用不同域名或不同 server 块。
主站配置:
server { listen 80; server_name phpask.local; root /var/www/phpask; index index.php; location ~ \.php$ { include fastcgi_params; fastcgi_pass 127.0.0.1:9008; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }老后台配置:
server { listen 80; server_name legacy.phpask.local; root /var/www/phpask/legacy; index index.php; location ~ \.php$ { include fastcgi_params; fastcgi_pass 127.0.0.1:9005; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }这里唯一的实质区别就是fastcgi_pass指向的端口。整个 Nginx 配置里,绝大多数内容都一样,版本切换只发生在最终转发这一步,理解这一点后排查问题思路会清晰很多。
4.2 同一个站点按 URL 前缀切到另一个版本
有时候不想再开一个域名,而是希望同一个 PhpAsk 站点下,/m/开头的路径走老版本 PHP,其他路径走新版本。可以用 location 前缀匹配实现:
server { listen 80; server_name phpask.local; root /var/www/phpask; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include fastcgi_params; fastcgi_pass 127.0.0.1:9008; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } location ^~ /m/ { alias /var/www/phpask/legacy/; index index.php; location ~ \.php$ { include fastcgi_params; fastcgi_pass 127.0.0.1:9005; fastcgi_param SCRIPT_FILENAME $request_filename; } } }要注意alias和嵌套location的组合很容易踩坑,SCRIPT_FILENAME用$request_filename比拼接$document_root$fastcgi_script_name更可靠。改完配置后记得nginx -t做语法检查,再执行systemctl reload nginx。
这种“同站不同路径不同版本”的模式,非常适合上线前的灰度验证。先在/m/前缀下跑新版本代码,观察日志和错误率,确认稳定后再逐步扩大范围。
4.3 CLI 侧怎么配合:PATH、alias 和 Composer 的版本指定
Web 请求走 FPM,但日常维护和部署时你还是要用命令行。我的习惯是给两个版本做显式别名,而不是频繁改 PATH:
alias php56='/usr/local/php56/bin/php' alias php82='/usr/local/php82/bin/php'如果希望打开 shell 时默认使用某个版本的 php,就把它放在 PATH 的最前面:
export PATH="/usr/local/php82/bin:$PATH"需要特别注意 Composer。Composer 本身是一个 PHP 脚本,它用哪个版本的 PHP 执行,完全取决于你调用它时的解释器。用错了版本,会出现“Composer 检测到 PHP 5.6,但项目需要 PHP 8.2”之类的情况。正确的做法是显式指定:
/usr/local/php82/bin/php /usr/local/bin/composer install还可以在composer.json中设置 platform 检查,让 Composer 在解析依赖时按指定版本判断:
/usr/local/php82/bin/php /usr/local/bin/composer config platform.php 8.2.0这个配置写成后,即使当前 shell 的默认 php 是 5.6,Composer 也会按 8.2 的平台要求去解析依赖,避免装到不支持当前语法的老版本库。
5. Windows 与 macOS 开发机的轻量级共存方案
5.1 Windows:多个 php-cgi 进程加 Nginx 端口分流
Windows 上没有原生的 php-fpm,开发环境通常用 php-cgi 配合 Nginx 使用。思路和 Linux 完全一致:不同版本占不同端口。
去 PHP 官网下载两个非线程安全版本的压缩包,分别解压到C:\php56和C:\php82。把压缩包里的php.ini-development复制为php.ini,并启用必要扩展:
extension_dir = "C:\php82\ext" extension=mysqli extension=pdo_mysql extension=curl extension=mbstring然后开两个命令窗口,分别启动:
C:\php56\php-cgi.exe -b 127.0.0.1:9005 -c C:\php56\php.ini C:\php82\php-cgi.exe -b 127.0.0.1:9008 -c C:\php82\php.iniNginx 配置里的fastcgi_pass指向对应端口即可。要注意的是 php-cgi 是阻塞型进程,命令窗口不能关;而且 Windows 下路径不能有空格,老 PHP 版本的配置文件名也可能不一样,解压后先看看目录里实际是什么文件名。
5.2 macOS:用版本管理工具减少重复编译
macOS 下的思路有两个方向。一个是直接用 phpbrew 这类版本管理工具,它能把不同版本 PHP 编译到独立目录,并通过命令切换 PATH。基本流程是:
phpbrew install 5.6.40 +default +fpm phpbrew install 8.2.15 +default +fpm phpbrew use php-5.6.40 phpbrew use php-8.2.15phpbrew 切换的是命令行默认版本,FPM 的启动仍要手动控制。多个 FPM 同时运行时,依然要注意监听端口和 PID 文件冲突。macOS 上编译老版本 PHP 的 OpenSSL 兼容问题比 Linux 更明显,如果遇到编译失败,不要死磕,直接用 Docker 方案更快。
5.3 用容器跑多版本:一套 Compose 定义多个 FPM
不管是 Windows、macOS 还是 Linux,Docker 都是目前最省心的多版本方案。最核心的好处是:版本隔离做到极致,不用编译、不会污染宿主机,删掉容器后环境完全干净。
假设 PhpAsk 代码在./www目录,用一个docker-compose.yml定义两个 PHP-FPM 容器和一个 Nginx 容器:
services: php56: image: php:5.6-fpm-alpine volumes: - ./www:/var/www/html networks: - phpask php82: image: php:8.2-fpm-alpine volumes: - ./www:/var/www/html networks: - phpask nginx: image: nginx:1.24-alpine ports: - "8080:80" volumes: - ./www:/var/www/html - ./nginx/phpask.conf:/etc/nginx/conf.d/default.conf depends_on: - php56 - php82 networks: - phpask networks: phpask:容器内的 Nginx 不再通过127.0.0.1访问宿主机端口,而是直接通过容器服务名解析到对应 PHP-FPM 容器,比如fastcgi_pass php56:9000。两个容器的 9000 端口互不冲突,因为它们在各自的网络命名空间里。Nginx 配置示例:
server { listen 80; server_name _; root /var/www/html; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ ^/legacy/.*\.php$ { include fastcgi_params; fastcgi_pass php56:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } location ~ \.php$ { include fastcgi_params; fastcgi_pass php82:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }启动命令就一行:
docker compose up -d如果容器里的 PHP 缺少某些扩展,可以在 Dockerfile 里用docker-php-ext-install安装,或者在php56和php82服务的build字段里指定各自镜像。每套环境都是独立的,不会因为装一个扩展把另一个版本搞崩。
6. 多版本环境里我踩过的五个坑(含排查方法)
6.1 php -v 对不上:PATH 优先级陷阱
现象:打开终端执行php -v,显示的版本号和自己预期不一样,但which php看到的路径明明是自己刚安装的目录。
原因:系统里可能同时存在/usr/bin/php、/usr/local/bin/php、/etc/alternatives/php等入口,shell 执行命令时按 PATH 顺序查找,不一定能找到你最新安装的那个。
排查方法:先执行which php看实际调用的路径,再执行echo $PATH看目录顺序,最后检查是否有软链接指向了其他版本的二进制。确认无误后再清理软链接或调整 PATH。
6.2 扩展加载失败:phpize 版本不匹配和 extension_dir 错误
现象:用源码安装扩展时,./configure报错或安装成功后php -m里看不到扩展。
原因:调用了系统自带的phpize,它属于另一个 PHP 版本,生成的扩展文件自然加载不进当前版本。
排查方法:编译扩展前确认你用的是目标 PHP 自带的工具链:
/usr/local/php82/bin/phpize ./configure --with-php-config=/usr/local/php82/bin/php-config make && make install查看扩展目录是否匹配:
/usr/local/php82/bin/php -i | grep extension_dir6.3 9000 端口被占:FPM 启动失败
现象:第二个 PHP-FPM 启动时一条错误日志,提示端口已被占用。
原因:两个版本默认都监听 127.0.0.1:9000。
排查方法:先看端口占用情况:
ss -tlnp | grep 9000然后确认两个 pool 配置里的 listen 是否已经改成不同端口。如果之前启动过其中一个 FPM,还要检查它的子进程是否残留,先停掉再改配置重启。
6.4 Composer 依赖检查不过:platform 版本不对
现象:执行composer install时提示当前 PHP 版本不满足依赖要求,但明明已经切换到新版 PHP 了。
原因:Composer 的记录缓存或 platform 配置里锁定了旧版本。
排查方法:看composer config platform.php输出的是什么。如果显示 5.6,说明 platform 配置被写死了,改成实际目标版本:
composer config platform.php 8.2.0如果只是临时安装不想改配置,也可以跳过平台检查:
composer install --ignore-platform-req=php这个方法只适合本地临时复现问题,线上部署不要依赖它,否则装出来的依赖可能和线上环境不兼容。
6.5 session 和缓存目录串线:登录态和配置互相污染
现象:同一个域名下,一会儿能登录、一会儿又提示未登录;另一个版本的配置改动莫名影响到了这个版本。
原因:两个 PHP 共用同一个 session.save_path,或者 opcache 配置写在同一个文件树里。
排查方法:分别查看两个版本的 session 路径:
/usr/local/php56/bin/php -i | grep session.save_path /usr/local/php82/bin/php -i | grep session.save_path如果不一致,按 3.3 节的方法建目录、改 php.ini、授权并重启对应 FPM。opcache 方面,不同 PHP 版本的 opcache 是独立编译的扩展,不会互相覆盖文件,但如果两个版本共享同一个 opcache 目录配置,也会出现奇怪问题,建议让opcache.file_cache指向各自前缀下的独立目录。
6.6 额外提醒:日志文件也要分开
最后补一个容易忽略但很影响排查效率的点:两个 PHP-FPM 的错误日志、慢请求日志最好也分开。FPM 的error_log和 pool 里的slowlog都指向各自前缀目录。否则排查问题时,日志里混着两个版本的报错,完全分不清是哪一个进程吐出来的。把日志分隔清楚,后续排障效率能提升一大截。
这套方案我在 PhpAsk 的测试环境用了三个多月,最大的体会是:从一开始就把“身份区分”刻在命名和配置里,比事后补救省心得多。新建一个版本时,照着这套流程把前缀、端口、PID、日志、session 目录全部错开,二十分钟左右就能再跑一套环境。多版本 PHP 共存其实没有太多高深技巧,无非就是确认每一项公共资源都有唯一的归属,然后把 Nginx 这个调度器的指路牌立清楚。