7 个从入门到资深 PHP 开发者都在用的核心调试技能
调试的残酷真相
大多数 PHP bug 难搞,不是因为它们"复杂",而是因为它们看不见。
变量在比你预期早两层的地方就变成了 null。一个"不可能发生"的条件偏偏只在生产环境发生。请求在本地正常,放到代理后面就挂了。队列 worker 的行为和 HTTP 运行时不一样。还有经典场景:你修好了......下周它又回来了。
想快速成长为 PHP 开发者,别急着学更多框架特性。先学会观察系统实际在做什么。
下面是我认为每个 PHP 开发者从第一天就该掌握的 7 个调试技能。它们不是花招,而是会持续产生复利的习惯。
原文 7 个从入门到资深 PHP 开发者都在用的核心调试技能
错误要看得见,但别暴露给用户
看不到错误,你就不是在调试------你是在猜。
PHP 提供了可靠的错误可见性原语:error_reporting、display_errors 和日志设置。关键是把开发环境和生产环境当作不同的可观测模式来对待。
PHP 官方手册强烈建议在生产网站上记录错误而非显示错误。
开发环境:全开
在开发环境,你需要最大化的信号:
ini
; php.ini (development)
error_reporting = -1
display_errors = On
display_startup_errors = On
log_errors = On
如果你用 Docker 或开发容器,确认容器内部的设置:
bash
php -i | grep -E "error_reporting|display_errors|log_errors"
生产环境:只记录,不显示
在生产环境,display_errors=On 不是"有帮助",而是漏洞。你要的是日志,不是泄露的堆栈跟踪。
ini
; php.ini (production)
error_reporting = -1
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/app-error.log
然后在故障期间 tail 日志:
bash
tail -f /var/log/php/app-error.log
异常日志要带上下文
别完全依赖 PHP 默认的错误日志格式。在应用启动时添加一个顶层异常处理器(框架无关):
php
<?php
declare(strict_types=1);
set_exception_handler(function (Throwable $e) {
error_log(json_encode([
'level' => 'error',
'event' => 'uncaught_exception',
'message' => $e->getMessage(),
'type' => $e::class,
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => explode("\n", $e->getTraceAsString()),
], JSON_UNESCAPED_SLASHES));
});
这是"穷人版结构化日志",但已经比经典的"崩了,不知道为啥"强多了。
技能检验:如果有人给你一张生产环境的错误截图,你应该能说:"关掉那个。放到日志里。加上关联 ID。然后复现。"
Xdebug 步进调试:别再瞎猜了
var_dump() 在探索时还行。步进调试才是你认真排查时用的工具。
Xdebug 的步进调试器让你可以逐步执行代码并交互式地检查状态。
理解它能干什么
步进调试回答的问题:
- 代码实际走了哪条路径?
- 这里的值是什么,不是你脑子里想的那个?
- 为什么这个分支被执行了?
- 是什么修改了这个变量?
Xdebug 3 最小配置
Xdebug 有多种模式和现代化的配置方式。官方文档覆盖了步进调试和所有设置。
在大多数环境中,可以这样配置:
ini
; xdebug.ini
xdebug.mode = debug
xdebug.start_with_request = yes
xdebug.client_port = 9003
为什么是 9003?Xdebug 3 改了默认端口(这是"连不上"问题的常见原因)。IDE 文档和社区答案通常把 9003 作为 Xdebug 3 的默认端口。
共享环境别常开调试
更好的习惯是"需要时才调试"。这样能保持性能稳定,避免意外暴露调试行为。
尽量用环境变量:
bash
XDEBUG_MODE=debug php -S localhost:8000 -t public
或者对于 PHP-FPM/容器,只在开发环境设置 XDEBUG_MODE=debug。
高效的调试流程
- 在可疑函数的第一行打断点
- 触发请求
- 步进进入函数
- 观察:
- 输入参数
- 分支条件
- 意外的 null / 空字符串
- "神秘"变化的值
- 找到错误假设后,停下来。用一句话写下来。
最后一步很重要:目标不是"无限探索",而是快速找到错误的假设。
告别 var_dump(),用更好的方式 dump
dump 仍然有用。错误在于盲目 dump。
Symfony 的 VarDumper 组件提供了比 var_dump() 更易读的 dump() 函数。很多流行生态(包括 Laravel)的 dd() 便捷函数都基于 VarDumper 风格的 dump 构建。
dump 关键字段,别 dump 整个对象
与其 dump 整个对象图然后滚动 5 分钟,不如 dump 你关心的形状。
差:
php
dump($request);
好:
php
dump([
'method' => $request->getMethod(),
'path' => $request->getPathInfo(),
'userId' => $user?->id,
]);
写个临时的调试辅助函数
在纯 PHP 应用中,定义一个小辅助函数:
php
<?php
function dd(mixed $value): never {
header('Content-Type: text/plain; charset=utf-8');
var_dump($value);
exit(1);
}
然后谨慎使用。重点是速度。
规则:如果一个流程里有超过 3 个 dump,你可能需要步进调试或带关联 ID 的日志。
日志要像证据,不是流水账
日志不应该是散落在代码里的随机句子。日志应该是证据。
如果你用 Laravel,它的日志系统是基于 channel 的,默认使用"stack" channel。Laravel 还有"context"能力,可以在请求/任务/命令中捕获共享元数据并包含在日志里。
即使你不用 Laravel,这个模式到处适用:一次性附加上下文,每行日志都变得更有用。
给每个请求加关联 ID
这是投入产出比最高的调试手段之一。
框架无关的示例:
php
<?php
declare(strict_types=1);
function getCorrelationId(): string {
$hdr = $_SERVER['HTTP_X_REQUEST_ID'] ?? '';
if (is_string($hdr) && $hdr !== '') return $hdr;
// 如果没有就生成一个
return bin2hex(random_bytes(16));
}
$reqId = getCorrelationId();
header('X-Request-Id: ' . $reqId);
然后加到日志里:
php
error_log(json_encode([
'level' => 'info',
'event' => 'checkout.start',
'request_id' => $reqId,
'user_id' => $userId ?? null,
'cart_items' => count($items),
]));
记录分支决策,而非流水事件
决策点是行为分叉的地方:
- 授权检查
- 选择支付提供商
- 回退逻辑
- 重试
- 功能开关
示例:
php
<?php
$logger->info('payment.route', [
'request_id' => $reqId,
'order_id' => $orderId,
'provider' => $providerName,
'reason' => 'currency_supported',
]);
敏感信息绝对不能进日志
脱敏 token、密码、Authorization header、session ID。调试不值得让凭证永远泄露在日志里。
不能复现的 bug 等于没修
不能复现的 bug 没有被修复,它只是在睡觉。
成为"调试高手"最快的方法是学会创建最小复现。
最小复现清单
bug 报告来了,立刻捕获这些:
- 精确的输入 payload(或脱敏版本)
- 环境差异(PHP 版本、扩展、配置)
- 时间相关条件(时区、当前日期、夏令时)
- 并发条件(1 个请求 vs 10 个并发)
- 数据前置条件(特定的数据库行)
然后精简。
如果你的应用需要 20 步才能复现,目标是 3 步。如果需要 3 步,目标是 1 步。
把 bug 变成测试用例
这是真正的团队阻止回归的方式。
示例:一个微妙的折扣舍入 bug,只在特定价格时出现。
php
<?php
declare(strict_types=1);
final class Discount
{
public function apply(int $priceCents, int $percent): int
{
$cut = (int) round($priceCents * ($percent / 100));
return max(0, $priceCents - $cut);
}
}
写回归测试:
php
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class DiscountTest extends TestCase
{
public function testRoundingEdgeCase(): void
{
$d = new Discount();
// Bug 报告:999 分钱打 10% 折扣产生了 900 而非 899
$this->assertSame(899, $d->apply(999, 10));
}
}
现在你有了:
- 几秒钟就能跑完的复现
- 防止以后重新引入 bug 的安全网
时间相关的 bug:冻结时间
如果你的代码依赖时间,你会追鬼。
在应用代码中,包装"现在":
php
<?php
interface Clock {
public function now(): DateTimeImmutable;
}
final class SystemClock implements Clock {
public function now(): DateTimeImmutable {
return new DateTimeImmutable('now');
}
}
在测试中,注入固定的 clock。你的调试就变得确定性了。
边界调试:数据库和外部 API
在现代 PHP 应用中,bug 往往不在你的业务逻辑里。它在:
- 返回意外结构的查询
- N+1 查询模式
- 导致超时的慢事务
- 返回微妙不同 payload 的外部 API
数据库:先看查询数量
如果你的接口突然变慢,第一个问题往往是:"这个请求执行了多少查询?"
框架无关的方式(PDO 包装器)可以计数查询。Laravel/Symfony 可以接入它们的数据库 profiler 功能。不管什么技术栈,习惯是一样的:
- 捕获查询数量
- 捕获慢查询
- 小心捕获参数(避免泄露敏感信息)
示例(概念性伪包装器):
php
<?php
final class Db
{
private int $count = 0;
public function __construct(private PDO $pdo) {}
public function query(string $sql, array $params = []): array
{
$this->count++;
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function queryCount(): int { return $this->count; }
}
请求结束时:
php
$logger->info('request.db', [
'request_id' => $reqId,
'query_count' => $db->queryCount(),
]);
外部 HTTP:记录元数据,别记 body
你需要的是:
- method
- host/path
- status
- duration
- retries
- 关联 ID
示例包装器:
php
<?php
function callPartnerApi(string $url, array $headers): array
{
$start = microtime(true);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array_map(
fn($k, $v) => "$k: $v",
array_keys($headers),
$headers
),
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 3,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$durationMs = (int) ((microtime(true) - $start) * 1000);
curl_close($ch);
error_log(json_encode([
'event' => 'http.out',
'url' => $url,
'status' => $status,
'duration_ms' => $durationMs,
]));
return ['status' => $status, 'body' => $body];
}
调试收益:一旦你有了带请求 ID 的 duration 和 status 日志,你就不用再猜 bug 是"我们的问题"还是"合作方 API 负载过高"了。
硬核手段:二分法、git bisect、断言守卫
卡住的时候,暴力往往比聪明更有效。
二分法定位代码路径
如果你有一个复杂流程(结账、预订、审批流水线),别读整个代码库。缩小范围。
- 在可疑区域的开头加一条日志
- 在结尾加一条日志
- 如果开头的日志出现了但结尾的没出现,bug 就在里面
- 把区域一分为二,重复
这就是"代码的二分查找"。效果惊人。
回归 bug 用 git bisect
如果一个 bug"最近才开始出现",别争论了,直接 bisect。
bash
git bisect start
git bisect bad HEAD
git bisect good <已知正常的-commit-或-tag>
然后运行一个能复现问题的脚本/测试,每一步标记 good/bad,直到 Git 找到引入 bug 的 commit。
如果你没有复现脚本,那就是你的第一个任务(见技能 5)。
在边界加断言守卫
大量调试时间花在处理"不可能的状态变成了可能"上。
添加快速失败并带清晰消息的守卫:
php
<?php
declare(strict_types=1);
function requireNonEmptyString(mixed $v, string $name): string {
if (!is_string($v) || trim($v) === '') {
throw new InvalidArgumentException("$name must be a non-empty string");
}
return $v;
}
在 bug 聚集的地方使用它:解析输入、读取环境变量、处理外部 payload。
示例:
php
$apiKey = requireNonEmptyString(getenv('PARTNER_API_KEY'), 'PARTNER_API_KEY');
这不是"额外代码"。这是调试预防。
完整的调试工作流
遇到 bug 时,跑这个循环:
第一步:让错误可见
确认错误设置和日志(技能 1)。拿到真正的堆栈跟踪。
第二步:复现
精简步骤。捕获输入。让它确定性(技能 5)。
第三步:选工具
- 一个变量错了?
dump()一个小形状(技能 3) - 控制流不清楚?Xdebug 步进调试(技能 2)
- 分布式/系统问题?关联 ID + 结构化日志(技能 4)
- 回归?git bisect(技能 7)
第四步:修复并锁定
添加测试或守卫,让它不再回来(技能 5 / 技能 7)。
这就是整个游戏。
结语
最好的 PHP 调试者没有神奇的直觉。他们有更好的反馈循环。
- 错误可见但不泄露(PHP 官方也建议在生产环境记录而非显示)
- 猜不出来时有步进调试可用(Xdebug 就是为此而生)
- dump 可读且有意识(VarDumper 的
dump()存在是因为var_dump()太痛苦) - 日志在执行过程中携带上下文(Laravel 明确支持在日志中包含 context)
- bug 变成可复现的测试,而非反复出现的故事
尽早掌握这七个技能,你的"调试时间"会大幅缩短------不是因为 bug 消失了,而是因为你的系统不再对你隐藏真相。