PHP 调试与日志

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

PHP 8.4

PHP 的默认错误处理对新手很不友好:白屏、什么都不显示,或者把堆栈直接吐给用户。这一篇把「看得见」和「不泄露」两件事同时解决。

环境准备

示例在 PHP 8.4 下验证。开发环境建议装 Xdebug 3,php -v 能看到 with Xdebug 即成功。

两套配置

开发时把错误打全,生产时只写日志:

// config/error.dev.php
declare(strict_types=1);

error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
ini_set('log_errors', '1');
// config/error.prod.php
declare(strict_types=1);

error_reporting(E_ALL);
ini_set('display_errors', '0');       // 绝不输出到页面
ini_set('log_errors', '1');
ini_set('error_log', __DIR__ . '/../storage/logs/php-error.log');

用环境变量选择加载哪一份:

$env = getenv('APP_ENV') ?: 'prod';
require __DIR__ . "/config/error.{$env}.php";

display_errors=1 在生产环境会把文件路径、SQL、密钥片段暴露给任何访问者,也是很多扫描器的第一入口。

捕获错误与异常

错误(E_WARNING 之类)和异常(Throwable)是两套机制,都要接管:

set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
    if (!(error_reporting() & $severity)) {
        return false;                 // 被 @ 抑制的,交给默认处理
    }
    throw new ErrorException($message, 0, $severity, $file, $line);
});

set_exception_handler(function (Throwable $e): void {
    error_log(sprintf(
        "[%s] %s: %s in %s:%d\n%s",
        date('c'),
        $e::class,
        $e->getMessage(),
        $e->getFile(),
        $e->getLine(),
        $e->getTraceAsString(),
    ));

    if (!headers_sent()) {
        http_response_code(500);
        header('Content-Type: text/html; charset=utf-8');
    }
    echo '<h1>服务器出错了</h1><p>请稍后重试。</p>';
});

register_shutdown_function(function (): void {
    $error = error_get_last();
    if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
        error_log("[fatal] {$error['message']} in {$error['file']}:{$error['line']}");
    }
});

把警告转成异常,能让「忘了检查返回值」在测试阶段就炸出来。但要排除 @ 抑制的场景,否则第三方库的容错写法会一起失效。

致命错误(解析错误、内存耗尽)走不到 set_exception_handler,只能靠 register_shutdown_function + error_get_last() 记录。

结构化日志

function log_event(string $level, string $message, array $context = []): void
{
    $record = [
        'time'     => date('c'),
        'level'    => $level,
        'message'  => $message,
        'request'  => $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8)),
        'context'  => $context,
    ];

    error_log(json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}

log_event('info', '文章已发布', ['post_id' => 42, 'user_id' => 7]);

输出一行一个 JSON,grep 和日志系统都能直接消费。请求 ID 从入口生成并回写到响应头,前端报错时带上它,排查能直接定位:

$requestId = $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8));
header("X-Request-Id: {$requestId}");

不要记录密码、完整身份证、支付卡号、会话 ID。需要关联用户时记 ID。

日志分级与文件

enum LogLevel: string
{
    case Debug = 'debug';
    case Info = 'info';
    case Warning = 'warning';
    case Error = 'error';
}
  • debug:只在开发环境开,生产关闭。
  • info:关键业务动作(登录、下单、发布)。
  • warning:可恢复的异常(重试成功、降级返回)。
  • error:需要人介入的失败。

按天切分,避免单个文件无限增长:

$file = __DIR__ . '/../storage/logs/app-' . date('Y-m-d') . '.log';
error_log($line . "\n", 3, $file);

权限设为仅应用用户可写,Web 根目录下的日志文件要禁止直接访问(Nginx/Apache 配 deny)。

Xdebug 断点调试

php.ini 关键配置(Xdebug 3):

xdebug.mode=debug,develop
xdebug.start_with_request=yes
xdebug.client_port=9003
xdebug.idekey=VSCODE

配好 IDE 的监听后,在编辑器里点断点,刷新页面即停在断点处,可以看局部变量、调用栈、单步执行。

临时排查也可以不装 Xdebug:

var_dump($value);            // 输出类型与结构
error_log(print_r($value, true));
debug_zval_dump($value);     // 引用计数,排查引用问题

查看执行时间:

$start = microtime(true);
// ... 代码
error_log('耗时 ' . round((microtime(true) - $start) * 1000, 2) . ' ms');

白屏排查顺序

  1. 看 display_errors 是否关闭、error_log 路径是否可写;
  2. 命令行直接跑 php public/index.php,绕过 Web 服务器看真实报错;
  3. 检查 php -l 语法(E_PARSE 不进日志时尤其有用);
  4. 看 Web 服务器错误日志(Nginx 的 error_log、Apache 的 error_log);
  5. 检查内存限制与执行时间:memory_limit、max_execution_time。

400/500 同时出现且页面空白,九成是致命错误没有显示出来。

生产环境的错误页

// 入口处提前注册,避免半渲染状态
if ($env === 'prod') {
    ini_set('display_errors', '0');
}

错误页本身也要简单:不要查数据库、不要渲染菜单。静态 HTML 最稳。

常见错误与注意事项

1. 用 var_dump 调试后忘了删

生产环境把内部结构输出给用户。提交前搜一遍 var_dump / print_r,或用 lint 规则拦。

2. @ 抑制一切

@$result = $obj->method() 把致命问题也吞掉。改成显式检查返回值或 try/catch。

3. 日志里写敏感信息

包括异常消息里拼进去的 SQL 参数。记录前过滤,或用白名单字段。

4. 只记 $e->getMessage()

没有文件和行号,等于没记。至少记 $e::class、getFile()、getLine()。

5. 日志文件放在 Web 可访问目录

/logs/app.log 能被直接下载。放 Web 根目录之外,或加服务器规则拒绝。

6. 时区不一致

date_default_timezone_set('Asia/Shanghai') 或 date.timezone 配置统一,否则日志时间对不上应用数据。

7. 用 echo 当调试手段

输出会混进页面/JSON,破坏响应格式。调试信息走 error_log,只在明确知道后果时用 echo。

小结

  • 开发开 display_errors,生产只写日志并返回通用错误页。
  • 错误与异常分别接管:set_error_handler、set_exception_handler、register_shutdown_function。
  • 日志一行一条 JSON,带请求 ID,禁止写入敏感字段。
  • Xdebug 断点优于到处 var_dump;临时排查用 error_log(print_r(..., true))。
  • 白屏先看日志与 php -l,再看 Web 服务器与资源限制。

下一步