写 PHP 谁没遇到过白页——display_errors 关了的,线上直接给你一张白屏;本地开着的,Fatal error: Uncaught TypeError 一坨黑底白字糊脸上,文件路径和行号是有了,但堆栈一深就只能眼睁睁数 #0 #1 #2 数到迷路。Whoops 干的事就是把这一坨错误信息变成一个能折叠、能跳转、能看每个栈帧代码上下文的网页——Filipe Dobreira 2012 年写的,现在 13K+ Star、4.25 亿+ 累计安装,Laravel 4 起就默认带着它,是 PHP 错误展示这个细分领域的事实标准。
安装
composer require filp/whoops
最新稳定版 2.18.4(2025-08),要求 PHP ^7.1 || ^8.0,只有一个依赖 psr/log。MIT 协议。如果你用 Laravel 5.5+、Mezzio、ZubZet 1.2+,它已经在 vendor 里了,不用重复装。
最小可用:5 行代码出效果
use Whoops\Run;
use Whoops\Handler\PrettyPageHandler;
$whoops = new Run;
$whoops->pushHandler(new PrettyPageHandler);
$whoops->register();
// 触发一个错误试试
throw new RuntimeException('演示一下错误页');
把这 5 行塞在入口文件最前面,访问任意会报错的页面,原来的白屏就变成了一张带代码高亮、堆栈列表、请求环境、应用数据的页面——出错那行连同前后几行代码直接渲染在错误页里,不用再回去翻 IDE。
下面是我用 TypeError 触发的实际效果(App\Order::create(int $id) 收到字符串):

顶部一行红色 TypeError 标题直接告诉你"是什么错",下面跟着文件路径和出错行号;左侧是完整调用栈,右侧是当前选中那一帧的代码上下文,出错行用红色背景高亮。整页是单个 HTML 文件,没有外部资源依赖,丢到任何 PHP 环境都能渲染。
为什么原生错误页不够用
立个靶子。下面是 PHP 原生 Fatal error 输出(开了 display_errors):
Fatal error: Uncaught TypeError: Argument 1 passed to App\Order::create() must be of the type int, string given, called in /var/www/app/Service.php on line 42 and defined in /var/www/app/Order.php on line 15
TypeError: Argument 1 passed to App\Order::create() must be of the type int, string given, called in /var/www/app/Service.php on line 42 and defined in /var/www/app/Order.php on line 15
Stack trace:
#0 /var/www/app/Service.php(42): App\Order::create('abc')
#1 /var/www/app/Controller.php(11): App\Service->run()
#2 /var/www/public/index.php(5): App\Controller->handle()
#3 {main}
thrown in /var/www/app/Order.php on line 15
一堆路径和行号摆在那,但你要回答这些问题都得自己再去翻代码:
- 出错那行前后写的什么?得切回 IDE 看
-
create('abc')这个调用方传的参数完整值是什么?看不到 - 哪一帧是自己的代码、哪一帧是 vendor 里的依赖?没标注
- HTTP 请求当时带没带某个 header?得自己
var_dump($_SERVER) - 这个错误能不能一键在 IDE 打开那一行?想都别想
Whoops 把这些问题全部在错误页里直接答了。
PrettyPageHandler:默认就是天花板
PrettyPageHandler 是 Whoops 默认的错误页面 handler,整个库最值钱的部分。一张错误页分成几块:
- 顶部:异常类型 + 消息 + 出错的文件:行号,可点击直接在 IDE 打开
- Stack Frames:每一帧显示文件、行号、类/方法、参数;每帧可单独展开看代码上下文(默认前后各几行);vendor 目录里的帧默认折叠,只突出你自己应用的代码
- Request / Server / Query / Cookie / Session 等环境数据表,点开即看
-
应用自定义数据表(后面讲
addDataTable)
每个栈帧点开能看到那段代码,意味着你不用再"切窗口 → 翻 IDE → 找行号"——错误现场一目了然。App\Order::create('abc') 这一帧点开,create 方法签名、参数、调用那行的上下文全在一个屏幕里。
这是 Whoops 的核心价值:让"排查一个错误"从多窗口切换降级成"在一个页面里点几下"。
点击任意一帧就能切换到那一层的代码上下文。下面这个循环动画展示了同一个错误从 #4 帧(异常抛出点 Order.php)依次切到 #3 Service.php、#2 Controller.php、#1 / #0 index.php,右栏代码区跟着同步变化:

栈帧标签是"这一帧即将进入的方法",文件路径是"调用发生的那一行"——所以左侧标签写 App\Order create 但右侧代码是 Service.php,因为 Service.php 第 15 行正在调用 Order::create。这跟 IDE 调试器的栈帧语义一致。
栈帧列表里 vendor 目录的帧默认折叠,只突出你自己应用里的代码,对定位"是我代码的锅还是依赖的锅"非常关键。
多 Handler 协作:Web / AJAX / CLI 自动适配
Whoops 的设计是 handler 栈(LIFO,后进先出)。pushHandler 越晚压入的越先执行,配合 Handler::DONE / Handler::LAST_HANDLER 两个常量,可以做"按场景挑 handler"。
最常见的组合——AJAX 请求返 JSON、CLI 走纯文本、其他走漂亮页面:
use Whoops\Run;
use Whoops\Util\Misc;
use Whoops\Handler\PrettyPageHandler;
use Whoops\Handler\JsonResponseHandler;
use Whoops\Handler\PlainTextHandler;
$whoops = new Run;
if (Misc::isCommandLine()) {
$whoops->pushHandler(new PlainTextHandler);
} elseif (Misc::isAjaxRequest()) {
$json = new JsonResponseHandler;
$json->addTraceToOutput(true); // AJAX 错误也带完整堆栈
$whoops->pushHandler($json);
} else {
$whoops->pushHandler(new PrettyPageHandler);
}
$whoops->register();
注意 pushHandler 的顺序:只有压入的第一个 handler 会真正渲染输出(除非它返回 Handler::DONE 把球踢给下一个)。所以这里用 if/elseif 让"该用哪个"在注册阶段就决定清楚,比压一堆然后用 LAST_HANDLER 互踢更清晰。
JsonResponseHandler 的输出长这样(带 trace):
{
"error": {
"type": "TypeError",
"message": "Argument 1 passed to App\\Order::create() must be of the type int, string given",
"file": "/var/www/app/Order.php",
"line": 15,
"trace": [
{"file": "/var/www/app/Service.php", "line": 42, "function": "create", "class": "App\\Order", "args": ["abc"]},
...
]
}
}
前端 fetch / axios 拿到这个就能直接把错误展示在弹窗里,不用自己 try/catch 拼装。
setEditor:一键在 IDE 打开出错文件
这是我最喜欢的功能。PrettyPageHandler::setEditor() 把错误页上的文件路径换成可点击链接,一点就直接在 IDE 里打开出错那一行。内置支持 11 种编辑器:
$handler = new PrettyPageHandler;
$handler->setEditor('phpstorm'); // 或者 vscode / sublime / atom / idea / macvim / textmate / emacs / netbeans / espresso / xdebug
VSCode、PhpStorm 这两个最常用,开发时点一下就跳过去,比手动复制文件路径再切窗口快一个量级。
点击错误页右栏的 Open: 文件路径 链接,浏览器会弹窗询问是否允许打开 VSCode(首次需要授权,之后就直接跳):

不在列表里也没事,传闭包自己拼 URL:
$handler->setEditor(function (string $file, int $line): string {
// 远程文件映射到本地路径
$file = preg_replace('#^/var/www/#', '/Users/me/code/', $file);
return "vscode://file/$file:$line";
});
Laravel 项目里这一步已经默认配好了,不用手动设置。
addDataTable:把请求上下文塞进错误页
错误页自带的 $_SERVER / $_GET / $_POST 那些表只是基础。真实项目里排查问题往往还想知道:当前登录用户是谁?这条请求的 trace ID 是什么?路由命中的是哪个 controller?
addDataTable 就是给错误页加自定义数据表:
$handler = new PrettyPageHandler;
// 直接给数据
$handler->addDataTable('Current User', [
'id' => $user->id ?? null,
'name' => $user->name ?? 'guest',
'roles' => $user->roles ?? [],
]);
// 懒加载(错误发生时才执行,避免无错误时浪费性能)
$handler->addDataTableCallback('Request Context', function () {
return [
'trace_id' => request()->attributes->get('trace_id'),
'route' => request()->attributes->get('_route'),
'memory' => memory_get_usage(true),
];
});
这些表会渲染在错误页底部,跟原生环境表并列。我自己的项目里固定塞 trace_id 和 当前用户,因为日志系统按 trace_id 索引,错误页直接复制 trace_id 去日志系统查上下游,闭环。
实际渲染效果(错误页底部展开 Environment & details 区域):

Current User 和 Request Context 是我自己加的表,下面 GET Data / POST Data / Server/Request Data 是 Whoops 默认就会渲染的环境信息。
silenceErrorsInPaths:精确静音
有些路径里的 deprecated 警告就是噪音(比如某个老库在 PHP 8.4 下报一堆),但又不能整体降低 error_reporting。silenceErrorsInPaths 按路径正则+错误级别精确静默:
$whoops->silenceErrorsInPaths(
['#/vendor/legacy/.*#'],
E_DEPRECATED | E_STRICT
);
调用多次会累加。我用来静音过 WordPress 集成模块的 deprecated 警告——其他代码里 E_DEPRECATED 还是照常报。
框架集成
Laravel 5.5+ 自带,开发环境默认开启,生产环境自动关。配置在 config/app.php 的 debug 项,.env 里 APP_DEBUG=true 时启用。不用动任何代码。
接入原生项目 / 不支持 Whoops 的框架,3 行搞定:
// 入口文件最早的地方
$whoops = new \Whoops\Run;
$whoops->pushHandler(new \Whoops\Handler\PrettyPageHandler);
$whoops->register();
Slim / Symfony / Yii / CakePHP / Phalcon / Laminas 等都有社区桥接包,README 里列了完整链接,按需取用。
踩过的坑
-
生产环境忘关等于数据泄露。Whoops 默认会把
$_SERVER、$_COOKIE、$_SESSION、甚至addDataTable里你塞进去的所有数据完整渲染到错误页里——session token、API key、数据库密码、用户隐私一条不漏。上线前必须根据环境判断关掉,或者只让特定 IP 看到:
Laravel 这块已经处理好了,但自己集成的时候必须想清楚。if (env('APP_ENV') !== 'production' || $_SERVER['REMOTE_ADDR'] === '办公室出口 IP') { $whoops->register(); } // 生产环境走自己的 try/catch + 日志 + 通用错误页 -
handler 栈是 LIFO。
pushHandler后压的先执行。注册顺序写反了,你以为 PrettyPageHandler 会兜底,结果被压在底下的 JsonResponseHandler 永远抢不到球。规则:先 push 兜底 handler,再 push 优先级更高的。或者直接用prependHandler显式插队首。 -
register() 必须在所有其他错误处理器设置之前。Whoops 用
set_error_handler/set_exception_handler/register_shutdown_function,后注册的同类型 handler 会覆盖它。如果框架有自己的错误处理初始化逻辑,先把 Whoopsregister()放进去,再让框架覆盖;或者反过来,看你想谁优先。 -
silenceErrorsInPaths的正则要带分隔符。文档里写#/path/.*#是有意的——内部用preg_match,不写#会被当成普通字符串。第一次用的时候我直接传了/vendor/legacy/,怎么都不生效,盯着日志看了一小时。 -
sendExitCode(1)在 PHPUnit 里会污染测试结果。Whoops 默认 CLI 报错后exit(1),集成测试里如果意外触发错误会让整个测试进程挂掉。测试环境用$whoops->allowQuit(false); $whoops->writeToOutput(false);关掉,把错误转成可控的输出。 -
addDataTableCallback是错误发生时才执行的。别在里面依赖还没初始化的服务(比如请求还没路由前的某个 DI 容器实例),否则原始错误会被你这段 callback 自己的 NullPointer 覆盖,看不出最初到底是什么错了。
它依然是首选
13K+ Star、4.25 亿累计安装、Laravel 默认、PHP 7.1 到 8.x 全覆盖——这种成熟度,PHP 错误展示这个细分没什么对手。Symfony 自带的错误页也好看,但缺"一键跳 IDE"和"自定义数据表"这两个对调试效率影响最大的特性。我的判断是:任何 PHP 项目,开发环境不开 Whoops 就是给自己添堵。
原文标题: [filp/whoops]让 PHP 错误页好看又好用
原文地址: https://phpreturn.com/index/a6a30d6eb13dfa.html
原文平台: PHP武器库
版权声明: 本文由phpreturn.com(PHP武器库官网)原创和首发,所有权利归phpreturn(PHP武器库)所有,本站允许任何形式的转载/引用文章,但必须同时注明出处。