如果你配置过 Xdebug 远程调试,大概率经历过这样的场景:本地写代码时断点一打一个准,换到虚拟机、Docker 容器或者测试服务器上,明明看着文档配完了,刷新页面却始终不进入断点。日志里永远只有一句 “Could not connect to debugging client.”。我也曾在这种状态下翻了两小时 Stack Overflow,最后发现是脑子里对“远程调试”的理解从根上就是错的。
把 PHP 的 Xdebug 远程调试彻底搞明白之后,后面再配任何环境都顺了。所谓远程调试,本质是 PHP 进程作为调试协议的客户端,主动去连你电脑上正在监听的 IDE,然后双方通过一套叫做 DBGp 的协议对话。这篇文章我不讲官话,直接把底层的握手协议、Xdebug 2 到 3 的配置差异、多项目下的路径映射和几个高频故障的排查链路讲透,适合正在搭 PHP 调试环境、或者已经在用但断点经常不生效的人。
1. 远程调试这件事,难在“三方关系”而不是工具
1.1 PHP 是客户端,IDE 才是服务端,这一句能省很多事
很多人第一次听到“远程调试”四个字,脑子里默认的模型是:IDE 连到远程服务器上去操控代码。这个模型从根上就反了。
Xdebug 远程调试里的“远程”两个字,指的并不是你从本地 IDE 去访问远程的 PHP 代码。实际上,整个调试过程的发起者是 PHP。当你给 PHP 开启了 Xdebug 的调试模式后,每一次 PHP 请求如果可以触发调试,PHP 进程会主动尝试往一个 TCP 端口发起连接。那个端口对应的是你本机 IDE 正在监听的端口,PHP 连过来之后,IDE 通过调试协议向 PHP 询问当前执行状态、设置断点、获取变量值。
所以打开你的 PHPStorm,点那个电话图标,监听的是 9000 或者 9003 端口;而在服务器上,Xdebug 配置文件里写的 xdebug.client_host 其实是 IDE 所在机器的 IP。用一张通俗的图来说:IDE 是接待员,坐在门口等待电话;Xdebug 是打电话的人,PHP 每次执行到代码某一行时,就会拨号过来问“我现在停在这一行,接下来怎么办”。
这个基本模型一旦建立,再去排查常见的“为什么连不上”,思路就不会乱。容器里的 PHP 连 127.0.0.1 肯定连不到宿主机上的 IDE,因为容器内的 127.0.0.1 表示容器自己,除非你把 IDE 跑在同一个容器里。云服务器上跑着 PHP,本机 IDE 监听 9003,但配置里 xdebug.client_host 却写了公网 IP,一样不合理,因为调试连接的方向是 PHP 主动出门找你,而不是你去找 PHP。
1.2 本地调试和远程调试的本质差异在于文件路径映射
本地调试时,Xdebug 报告给 IDE 的脚本路径是 C:/web/htdocs/index.php,而你本地打开的工程路径一般也就是这个路径,IDE 能直接对应上,所以断点工作很好。
远程调试环境就不同了。PHP 跑在 Linux 服务器上,真实路径是 /var/www/html/project/index.php,而你本地的工程目录可能是 /Users/name/workspace/project 或者 D:/workspace/project。两边路径不一样,IDE 必须知道“服务器上的 /var/www/html/project 对应当地磁盘上的哪个目录”,调试器才能把你在代码里打的断点翻译成远程文件路径,再通过调试协议去远程 PHP 进程中设断点。
这就是 PHPStorm、VS Code 里要配置 Server、pathMappings 的原因。我见过太多人远程调试不成功,查了半天 Xdebug 配置没问题、端口能连通,最后发现 IDE 那边根本没有配置路径映射。
1.3 哪些场景必须依赖远程调试
我实际用到 Xdebug 远程调试的基本是这几类场景:
- PHP 跑在 Docker 容器里,容器里的服务由 Nginx + PHP-FPM 承载,宿主环境只有 IDE 和代码。
- 团队共用一台开发机或者一台 Linux 虚拟机,代码不在我本地。
- CLI 脚本或者队列消费进程运行时有诡异逻辑,单纯靠
error_log打日志定位太慢,需要单步看每一行调用栈。 - 测试环境里代码表现和本地不一致,必须直接看远程 PHP 进程里某个变量的值。
这些场景的共同特点是:PHP 进程的物理位置和代码编辑器分离了。而只要物理位置分离,就离不开远程调试本身这件事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. DBGp 协议拆解:Xdebug 与 IDE 之间那几个问答命令
2.1 一条 init 数据包:会话开始的第一句话
Xdebug 实现调试时用的不是某种“官方标准协议”之外的私有协议,而是 DBGp。DBGp 是一种通用的调试协议,PHP、Python、Perl 都有对应实现。理解了 DBGp 的报文长什么样,你就知道 Xdebug 到底是怎么和 IDE 打交道的。
当 PHP 进程连上 IDE 监听的端口后,Xdebug 会先发一个 XML 格式的 init 包,大致长这个样子:
xml复制<?xml version="1.0" encoding="iso-8859-1"?>
<init xmlns="urn:debugger_protocol_v_1_0" fileuri="file:///var/www/html/project/index.php"
language="PHP" xdebug.lang="PHP"
appid="12345" idekey="PHPSTORM"
session="d7b8c4a9e2f1">
<engine version="3.3.1"><![CDATA[Xdebug]]></engine>
<author>Derick Rethans</author>
<url>https://xdebug.org</url>
<copyright>Copyright (c) 2002-2023 by Derick Rethans</copyright>
</init>
这个包里的信息量很大,但最关键的是 fileuri 和 idekey。fileuri 告诉 IDE,当前 PHP 正在执行的脚本在服务器上的绝对路径;idekey 则相当于一个小组件,用来区分同一台机器上不同项目或者不同开发者的调试会话。
IDE 收到这个 init 包之后,才知道当前需要调试哪个文件,并把文件路径与自己工程里的路径做匹配。如果匹配不上,IDE 会弹出提示,问你是否容忍这个路径,或者让你手动建立映射。所以很多人遇到“IDE 提示 Unknown Debug Session”也好,“Cannot find local file”也好,问题都出在路径对应关系上。
2.2 你真正需要记住的几条调试命令
init 包发完,后续就是一问一答的交互模式。IDE 发送调试命令给 PHP,PHP 里的 Xdebug 执行之后返回 XML 格式的 response。命令里一般都会带一个 -i 参数,这个参数表示 transaction id,用于把请求和响应配对。
常用命令大致是这张表:
| 命令名 | 作用 | 典型参数 |
|---|---|---|
run |
让 PHP 继续执行到下一个断点或结束 | -i 1 |
step_into |
单步进入函数调用内部 | -i 1 |
step_over |
单步跳过当前行,不进入函数内部 | -i 1 |
step_out |
跳出当前函数 | -i 1 |
breakpoint_set |
在指定文件行号设置断点 | -t line -f file:///var/www/... -n 37 |
stack_get |
获取当前调用栈 | -i 1 |
context_get |
获取当前作用域内的变量列表 | -d 0 -c 0 |
property_get |
获取某个变量的值 | -n $order |
eval |
在 PHP 进程内执行一段代码并返回结果 | -- 需要 base64 编码的代码 |
来感受一下 IDE 在设置断点时会发出什么样的原始指令:
text复制breakpoint_set -i 10 -t line -s enabled -f file:///var/www/html/project/app/Http/Controllers/OrderController.php -n 37
这条命令翻译过来就是:我在远程这个文件路径的第 37 行设置一个行断点。紧接着 PHP 进程执行到这个文件路径第 37 行时,Xdebug 会暂停下来,给 IDE 返回一个 status="break" 的响应,然后把当前的调用栈和变量列表发送过去。
从这层看下去,你会发现 IDE 里的“断点”并不是什么神奇的东西,它就是一行命令,告诉远程 PHP 进程“在这个文件的这个行号停下来”。所以远程文件路径和本地文件路径如果不一致,IDE 连这条命令都发不出来,断点自然就悬空了。
2.3 为什么默认端口那么重要
DBGp 协议本身并没有绑定必须用哪个端口,Xdebug 2 时代默认端口是 9000,到 Xdebug 3 改成了 9003。
9000 这个端口在 PHP 生态里很容易造成混淆,因为 PHP-FPM 默认的监听端口也是 9000。你如果配置 Xdebug 2 的 remote_port 为 9000,同时本机又跑着 PHP-FPM,是很别扭的。一方面你可能不敢在 IDE 里监听 9000,怕跟 PHP-FPM 冲突,另一方面偶尔会把 PHP-FPM 的日志当成 Xdebug 的日志去查。
Xdebug 3 把默认端口改成 9003 算是做了件好事,至少把调试连线端口和 PHP-FPM 的 9000 区分开了。配置是死的,但理解了协议本身,即使某天有人把 client_port 改成 9100、9200,你也能一眼看出配置里哪里不对:
- PHP 里配置的
xdebug.client_port必须等于 IDE 监听端口。 - 两边只要不一致,PHP 发出的 TCP 建连请求就会被拒绝,Xdebug 会在一段时间后放弃并记日志。
3. Xdebug 3 的配置才是真正的门槛:mode、触发方式与三个典型运行场景
3.1 Xdebug 2 到 Xdebug 3:配置项改名不是小事
网上大量教程还停留在 Xdebug 2 时代,比如 xdebug.remote_enable、xdebug.remote_host、xdebug.remote_port。从 Xdebug 3 开始,这些配置全部都改了。如果你把 2 的配置直接丢进 PHP 8.1 + Xdebug 3 环境,很多配置会被直接忽略,调试根本不会启动。
| 用途 | Xdebug 2 配置 | Xdebug 3 配置 |
|---|---|---|
| 关闭/开启调试 | xdebug.remote_enable=1 |
xdebug.mode=debug |
| 连接 IDE 的地址 | xdebug.remote_host=127.0.0.1 |
xdebug.client_host=127.0.0.1 |
| 连接 IDE 的端口 | xdebug.remote_port=9000 |
xdebug.client_port=9003 |
| 是否自动发起调试 | xdebug.remote_autostart=1 |
xdebug.start_with_request=yes |
| 调试 key | xdebug.idekey=PHPSTORM |
xdebug.idekey=PHPSTORM |
xdebug.mode 是 Xdebug 3 引入的一个非常核心的配置。它可以取这些值:develop、debug、coverage、profile、trace,多个模式可以用逗号组合,比如 xdebug.mode=debug,develop。
如果你只做远程断点调试,xdebug.mode=debug 就够用了。需要提醒的是,xdebug.mode 如果没有显式配置,默认值是 develop,此时代码里可能出现一些 Xdebug 的提示信息,但断点功能完全不生效。所以每次排查远程调试问题,第一个要确认的就是当前 PHP 进程的 xdebug.mode 到底是不是 debug。
3.2 debug 模式必须与 start_with_request 搭配
xdebug.mode=debug 只是把调试功能打开,但 PHP 进程并不是每次请求都会真去连接 IDE。因为如果每个 PHP 请求都尝试连接 IDE,而恰好没有 IDE 在监听,会白白浪费时间,还会往日志里写入无谓的错误。
Xdebug 3 控制“是否要在某次请求开启调试”的关键配置是 xdebug.start_with_request。最常用的是两个值:
ini复制; 每次 PHP 请求都尝试启动调试
xdebug.start_with_request=yes
; 只有检测到触发条件(Cookie、URL 参数等)时才启动调试
xdebug.start_with_request=trigger
trigger 模式是我个人强烈推荐的方式。配置好之后,平时 PHP 页面正常访问完全不受影响,只有你通过浏览器插件或 URL 参数告诉 Xdebug“这次要调试”,PHP 才会去连接 IDE。
浏览器里触发的机制靠的是 Cookie XDEBUG_SESSION 或者请求参数 XDEBUG_SESSION。Chrome 上装个 Xdebug Helper 之类的插件,点一下把 IDE key 设为 PHPSTORM,然后刷新页面,Cookie 就会带上。PHP 看到这个 Cookie,知道当前请求需要被调试,就会主动去连 IDE。
3.3 CLI、Web、Docker 三种调用方式下怎么带参数
同样的 Xdebug 配置,在 PHP-FPM 提供的 Web 服务、命令行 PHP、Docker 容器三种环境里表现并不完全相同。
Web 请求场景一般走 php.ini 配置,把上面那段配置写进 php.ini 即可。需要注意的是,如果你用 Nginx + PHP-FPM,改完 php.ini 之后要重启的是 PHP-FPM,不是 Nginx。很多人改完配置只 nginx -s reload,其实 PHP-FPM 的进程还是老的配置。
CLI 命令行调试又是另一个套路。CLI 模式下,PHP 不一定加载 php.ini 里那一套带有 start_with_request=trigger 的配置,尤其你用的是系统自带的 PHP 命令行环境时,它会加载 php-cli.ini,跟 FPM 是两套配置文件。就算所有配置都写了,每次命令行跑脚本前你还得想着设置触发 Cookie,非常麻烦,所以 CLI 调试我更习惯直接用 -d 参数临时覆盖:
bash复制php -d xdebug.mode=debug \
-d xdebug.client_host=127.0.0.1 \
-d xdebug.client_port=9003 \
-d xdebug.start_with_request=yes \
script.php
这样跑一次,IDE 里在 script.php 打上断点就能直接命中,不需要动任何全局配置文件。跑完脚本也不会对后续其它命令造成影响。
Docker 里的 PHP 容器其实也是一种 Web 或 CLI 环境,但出于镜像统一的原则,我不喜欢把调试配置写死在镜像里,更推荐用环境变量在运行时动态注入:
bash复制docker run -p 8080:80 \
-e XDEBUG_MODE=debug \
-e XDEBUG_CLIENT_HOST=host.docker.internal \
-e XDEBUG_CLIENT_PORT=9003 \
-e XDEBUG_START_WITH_REQUEST=yes \
my-php-app
前提是你镜像里的 Xdebug 版本是 3.x,并且容器里已经把 Xdebug 扩展加载好了。如果用的还是老版本的 php 镜像或者自定义编译的 Xdebug 2,环境变量配置名就不一样了。
4. 多项目实战:路径映射、端口隔离与调试会话的“身份”问题
4.1 为不同远程项目建立 IDE 的路径映射
多项目调试的第一件事,是先弄清楚你本地的多个工程和服务器上的多个路径是怎么对应的。举个例子,我本地有 mall-api 和 erp-admin 两个工程,它们部署在同一台开发机上,路径分别是 /var/www/mall-api 和 /var/www/erp-admin。
PHPStorm 的做法是打开 Settings -> PHP -> Servers,分别添加两条 Server 记录。第一条填主机名、端口、Debugger 选 Xdebug,然后把 /var/www/mall-api 映射到本地 D:/work/mall-api;第二条把 /var/www/erp-admin 映射到本地 D:/work/erp-admin。VS Code 则在 launch.json 的 pathMappings 字段里写:
json复制{
"name": "Listen for XDebug",
"type": "php",
"request": "launch",
"port": 9003,
"pathMappings": {
"/var/www/mall-api": "D:/work/mall-api",
"/var/www/erp-admin": "D:/work/erp-admin"
}
}
关键点在于:这里的左侧键必须是服务器上真实的 PHP 路径,右侧才可以是本地路径。如果你把两条命令写反,IDE 会拿着本地路径去远程找文件,当然是找不到的。
给每个项目都配好映射之后,启动监听,然后在浏览器里访问 http://dev-server/mall-api,PHP 进程连接过来时报告的是 /var/www/mall-api/...,IDE 才能根据这个前缀判断应该打开本地 D:/work/mall-api 下的对应文件。
4.2 多站点并行调试时,一个 IDE 监听端口够不够用
很多人在配置多个项目时,会想着给不同项目设置不同的 client_port,比如项目 A 用 9003,项目 B 用 9004,再开两个 PHPStorm 窗口分别监听。这样确实能做到真正意义上的“同时调试两个项目”。
如果只是想在同一台机器上开发不同项目,但同一时间只处理一个调试会话,那么所有项目共用 9003 端口就够了。PHP 每次请求发起的连接都会进到同一个 IDE 监听端口,IDE 自己会判断当前连接对应的是哪个本地项目。
我从实际项目里得到的一个经验是:与其纠结端口隔离,不如给不同项目配上不同的 idekey。比如项目 A 设为 MALL_API,项目 B 设为 ERP_ADMIN。浏览器端 Xdebug Helper 插件可以随时切换 IDE key,这样你可以做到:
- 打开项目 A 的调试插件,访问 A 的 URL,才触发 A 的调试。
- 打开项目 B 的调试插件,访问 B 的 URL,才触发 B 的调试。
- 如果只打开了 B 的调试插件,却访问 A 的 URL,Xdebug 不会为 A 启动调试。
这里面的原理并不复杂。start_with_request=trigger 模式下,PHP 会检查当前请求携带的 Cookie 或 URL 参数里的 XDEBUG_SESSION 值是否等于 xdebug.idekey。如果不等,就直接忽略触发信号。这个机制为“多个项目在一台 PHP 环境上各自调试,又不互相干扰”提供了很干净的隔离方案。
4.3 容器环境里的典型调试链路:从一次请求到断点命中
假设你有一个很常见的 Docker Compose 环境,里面有一个 php 服务跑 PHP-FPM,一个 nginx 服务做 Web 服务器。宿主机上的 IDE 监听 9003。
第一步,确认 PHP 容器里能访问到宿主机。Linux 上常见的做法是给 Docker 容器加 extra_hosts:
yaml复制services:
php:
build: ./docker/php
extra_hosts:
- "host.docker.internal:host-gateway"
这条配置让容器里的 host.docker.internal 指向宿主机。接着在 php.ini 里写:
ini复制xdebug.mode=debug
xdebug.client_host=host.docker.internal
xdebug.client_port=9003
xdebug.start_with_request=trigger
xdebug.idekey=PHPSTORM
然后把本地工程目录挂载进容器,比如 /var/www/html 对应的是本地 D:/work/mall-api,本地与容器内的文件保持一致。由于容器内 PHP-FPM 读取的是 /var/www/html/index.php,IDE 的 pathMappings 就要把 /var/www/html 映射到 D:/work/mall-api。
全部准备好之后,我一般的验证路径是:
在宿主机终端先跑一句最简单的测试,确认容器到宿主机的端口通不通:
bash复制docker exec -it php bash -c "php -r 'echo 1;'"
再确认 PHP 是否已加载 Xdebug 并处于 debug 模式:
bash复制docker exec -it php bash -c "php -i | grep xdebug.mode"
接着在 IDE 里打开 index.php 的某一行设断点,点击监听,再用浏览器访问 http://localhost:8080/index.php?XDEBUG_SESSION=PHPSTORM。这时 PHP 进程收到的 URL 参数里的 idekey 就是 PHPSTORM,会触发调试连接。断点命中之后,你可以正常单步、看变量、看调用栈。
容器环境里最容易踩的坑是 pathMappings 对不上。因为很多人把本地代码挂载到容器时并不是根路径直接挂,比如本地 D:/work/mall-api 挂载到容器里的 /app,而你的 Nginx 又让 PHP-FPM 执行的是 /var/www/html/index.php,这就涉及到容器内路径与容器内 PHP 执行路径进一步错位的问题。解决思路是始终以 Xdebug 上报的 fileuri 为准,启动调试后直接看 init 包或者 IDE 面板提示的远程路径,再按那个路径调整映射。
5. 断点不触发、连接失败:一套可重复的排查链路
5.1 先确认 Xdebug 真的在运行,并且模式是 debug
遇到“断点不触发”的问题,先别急着改端口,更别怀疑是 IDE 坏了。第一件事永远是确认当前 PHP 进程加载的 Xdebug 状态。
如果你可以访问 phpinfo() 页面,直接搜索 xdebug,看有没有 xdebug.mode,以及它的值是否包含 debug。如果页面根本搜不到 xdebug,说明扩展没启用,配置再对也没用。
命令行环境可以直接跑:
bash复制php -v
# 输出里有 With Xdebug v3.3.1 才算正常
再跑一句更具体的:
bash复制php -i | grep -E "xdebug.mode|xdebug.start_with_request|xdebug.client_host|xdebug.client_port"
输出结果里如果 xdebug.mode 变成 debug,再往下走。如果这些配置都为空或者没有显示,那就要检查你改的是不是当前 PHP 正在用的 php.ini。命令 php --ini 会列出当前加载的配置文件路径,很多人改了 /etc/php/8.1/apache2/php.ini,但命令行跑的是 /etc/php/8.1/cli/php.ini,两套根本不同。
Xdebug 3 还提供了一个很实用的函数 xdebug_info(),在 Web 页面里访问一个包含 <?php xdebug_info(); ?> 的临时文件,可以直接看到调试功能是否开启,以及相关配置项。这一步能节省大量时间。
5.2 TCP 连接失败:从“Could not connect”开始查
如果你已经能看到 Xdebug 在积极尝试连接,但日志里出现这句话:
text复制Xdebug: [Step Debug] Could not connect to debugging client.
Tried: host.docker.internal:9003 (through xdebug.client_host:xdebug.client_port)
说明 PHP 已经走到了启动调试的逻辑,只是没有连上 IDE。检查的顺序应该是:
- IDE 的监听有没有真的开启?PHPStorm 手机图标的状态是不是绿色的?VS Code 里面是不是正在运行“Listen for XDebug”的调试配置?
- PHP 端配置的
client_host从 PHP 所在机器的视角看,能不能访问到 IDE?如果 PHP 跑在 Docker 容器里,127.0.0.1通常不对,要用宿主局域网 IP 或者host.docker.internal。 - 端口两边是否一致?PHP 里是 9003,IDE 里是否也监听 9003?IDE 默认监听的不一定是 9003,尤其老版本 PHPStorm 可能还是 9000。
- 如果 PHP 跑在云主机上,IDE 运行在你本地笔记本上,那还得看云主机防火墙是否放行了从 PHP 访问回本地 IP 的路径。因为这里的连接方向是 PHP 主动连出来,而不少公司的出方向防火墙会限制非标准端口。
实际操作中,我习惯先在 PHP 所在的那台机器上用工具测一下端口通不通。比如用 PHP 本身去测:
bash复制php -r '$fp=@fsockopen("host.docker.internal", 9003, $errno, $errstr, 2); var_dump($fp);'
如果你能拿到 resource 类型的结果,说明网络层是通的。返回 false 的话,就不用浪费时间在 Xdebug 配置上了,先集中精力解决端口可达性问题。
5.3 断点不生效,但连接已经建立,大部分是路径映射的锅
有一类问题表现很迷惑:IDE 能收到调试连接,也能看到请求进来,但断点打在本地代码上,页面跑完也没有停在断点处。这种状态下,问题基本都出在路径映射。
原因在协议章节已经点破:IDE 设置断点时,要发送给远程的是一个远程文件路径。比如你本地文件是 D:/work/mall-api/app/Http/Controllers/OrderController.php,IDE 需要通过 pathMappings 把它翻译成 /var/www/html/app/Http/Controllers/OrderController.php,再通过 breakpoint_set 命令发送出去。如果映射关系缺失或错误,IDE 会把断点状态标记为无效,或者干脆在调试控制台提示无法匹配文件。
解决步骤很机械:
- 看调试会话开始时 IDE 控制台或者 session 面板里显示的远程文件路径是什么。
- 对比实际访问的 PHP 文件在服务器上是哪个绝对路径。
- 在 IDE 里修正 pathMappings,确保本地目录与服务器目录一一对应。
另外还有一个容易忽略的因素:如果你用的是 start_with_request=trigger,浏览器插件状态没开或者 idekey 不匹配,请求根本不会启动调试。这种情况在 IDE 端看不出任何连接,也不会报错,只是一切静悄悄。把 Xdebug 的日志级别开高一点能帮助你看到到底哪个环节没满足。
ini复制xdebug.log=/tmp/xdebug.log
xdebug.log_level=10
级别 10 会输出较多的 detail,内容包括“是否检测到调试触发信号”“尝试连接的地址是什么”“连接失败原因为何”。这套日志比你去翻 PHP-FPM 的 error log 有用得多。
提示:
xdebug.log_level=10在生产环境不要长期开着,日志量大,而且可能记录请求相关细节,仅限排查问题时开启。
5.4 容器内的“文件时间差”也会让断点行为变得诡异
还有一个很容易被忽略的细节是:远程调试时,PHP 执行的文件路径如果和 IDE 本地路径不一致,你设置断点的行号也可能会错位。
比如本地文件因为版本管理或者换行符原因,理论上第 37 行是一行代码,但同样的文件在容器内因为 CRLF 与 LF 的差异,行号完全乱了。PHP 容器里如果是从 Windows 挂载进去的代码,容易出现这个问题。建议在项目根目录放一个 .gitattributes 或者在 IDE 里把行尾符统一成 LF。文件内容不一致的情况下,远程断点命中的“第 37 行”不一定是你眼睛看到的“第 37 行”。
另一个现象是 opcache 把旧文件缓存了。如果你改了本地文件并挂载进容器,但容器内 PHP-FPM 的 opcache 还缓存着旧版本,Xdebug 上报的脚本内容和实际磁盘内容不一致,断点行为会很怪。调试期间可以临时把 opcache 关掉,用一段空路由脚本去触发,保证 Xdebug 读到的是磁盘上的最新文件。
6. 几个让远程调试真正变得“好用”的操作习惯
6.1 用 trigger 模式加浏览器插件,避免每个请求都连 IDE
很多人配完环境之后图省事,直接 xdebug.start_with_request=yes,于是每次访问页面,PHP 都要尝试连一次 IDE。IDE 没开监听时,每个请求都会产生几秒延迟,因为 Xdebug 在等待连接超时;IDE 开着监听时,一些非调试请求也会强行走调试握手,反而把正常请求拖慢。
我的做法是 trigger 模式常驻,平时 PHP 环境完全不启动调试,只有需要调试时才点浏览器插件,或者在 URL 后手动加 XDEBUG_SESSION=PHPSTORM。这样带来一个额外的好处:重新运行单元测试、跑队列脚本、模拟 HTTP 请求时,都不必担心调试会话被意外触发。
6.2 学会给 CLI 脚本和队列进程也挂上远程调试
Web 请求的断点调试大家都会,但 CLI 脚本的调试经常被忽略。定时任务、队列消费者、Excel 导入导出这些脚本一旦出现问题,很多人只会写日志然后反复跑,效率很低。
CLI 下调试最省事的方式可以不用修改全局配置,而是用环境变量提前注入:
bash复制XDEBUG_MODE=debug \
XDEBUG_START_WITH_REQUEST=yes \
XDEBUG_CLIENT_HOST=127.0.0.1 \
XDEBUG_CLIENT_PORT=9003 \
php artisan queue:work --once
只要 IDE 在监听,脚本一执行就能进入断点。对于 Laravel 项目,跑一条队列任务、一个 command,都能像 Web 请求一样单步调试。
6.3 把排错时最常用的检查命令写进一个脚本
很多时候调试环境出现问题不是某个配置没写,而是环境和人的状态在变。Docker 容器重启了、IP 变了、IDE 更新把监听端口改了、换了新电脑没有重新映射路径,各种情况都可能发生。
我自己在项目里保存了一个 xdebug-check.sh 脚本,内容基本都是上面提到的检查命令:
bash复制#!/bin/bash
echo "== PHP version =="
php -v
echo "== loaded ini =="
php --ini
echo "== xdebug config =="
php -i | grep -E "xdebug.mode|xdebug.start_with_request|xdebug.client_host|xdebug.client_port|xdebug.idekey|xdebug.log_level"
echo "== try connect 9003 =="
php -r '$fp=@fsockopen("127.0.0.1", 9003, $errno, $errstr, 2); var_dump($fp); if ($fp) fclose($fp);'
每次发现“断点不生效”,先在容器或者服务器里跑一遍这个脚本,10 秒内就能定位是配置文件没加载、端口不对,还是网络不通,完全不用靠猜。远程调试这件事,一旦你把协议交互和三大映射关系(PHP 客户端连 IDE、路径映射、触发器映射)印在脑子里,剩下的一切都只是按图索骥。
