PHP 基本语法 10|错误与异常:`Error`、`Exception` 和日志
先说清楚 PHP 的"两套机制"
PHP 有两套并行的出错机制,这是它和多数语言最不一样的地方:
- 错误(Error 级别):
E_WARNING、E_NOTICE、E_DEPRECATED这些,默认行为是"打印一条提示然后继续执行"。它们不是异常,try/catch抓不到。 - 异常(Exception / Error 类):
throw出来的对象,能被catch。
所以你会遇到这种局面:
<?php
try {
$x = $arr['not_exist']; // E_WARNING,不是异常
} catch (Throwable $e) {
echo "抓到了"; // ← 不会执行
}
数组越界、未定义变量这类问题在 PHP 里仍是"警告",不是异常。这是 PHP 与 JS、Python 最大的行为差异之一,也是"以为写了 try/catch 就安全"的常见误解。
PHP 8 的一个重大变化
PHP 8.0 把一批"原本静默或只报警告"的错误,改成了抛出 Error 的子类:
| 情况 | PHP 7 | PHP 8 |
|---|---|---|
| 内置函数参数类型错误 | 警告 + 返回 null | TypeError |
| 除数为 0 | 警告 + false | DivisionByZeroError |
传非法值给str_repeat 等 |
警告 | ValueError |
match 没匹配到分支 |
— | UnhandledMatchError |
| 访问未初始化类型属性 | — | Error |
| 调用不存在的方法 | 致命错误 | Error |
这意味着 PHP 8 里"能 catch 的东西"变多了,代码健壮性确实上了一个台阶。但遗留的警告类问题(数组键不存在等)依然抓不到。
Throwable:所有可抛出对象的父接口
Throwable
├── Error // 引擎级错误(TypeError、DivisionByZeroError…)
└── Exception // 业务/运行时异常(RuntimeException、LogicException…)
catch (Throwable $e) 能一把抓住所有,一般只在最外层兜底时这么写:
<?php
try {
$app->run();
} catch (Throwable $e) {
error_log((string)$e); // 记日志
http_response_code(500);
echo "服务异常,请稍后重试"; // 不给用户看堆栈
}
try / catch / finally 的正确写法
<?php
try {
$pdo->beginTransaction();
$pdo->exec("UPDATE account SET balance = balance - 100 WHERE id = 1");
$pdo->exec("UPDATE account SET balance = balance + 100 WHERE id = 2");
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
throw new RuntimeException("转账失败", 0, $e); // 第三个参数是"上一个异常"
} finally {
// 无论成功与否都执行
}
多重 catch 与联合类型
<?php
try {
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
// JSON 格式错误
} catch (TypeError | ValueError $e) {
// PHP 7.1+ 支持多类型捕获
}
注意顺序:子类异常写在前面,父类写在后面。如果先 catch (Exception $e),后面的更具体的 catch 永远不会执行。
finally 里的 return 会吃掉异常
<?php
function f(): string
{
try {
throw new Exception("出错了");
} finally {
return "finally"; // ⚠️ 异常被吞掉,函数正常返回
}
}
echo f(); // finally,异常消失
这是 PHP 的一个反直觉行为:finally 里的 return 会覆盖 try 里的 throw。不要在 finally 里写 return。
自定义异常
<?php
class ValidationException extends RuntimeException
{
public function __construct(
private array $errors,
string $message = "参数校验失败"
) {
parent::__construct($message, 422);
}
public function errors(): array
{
return $this->errors;
}
}
try {
throw new ValidationException(['email' => '格式不对']);
} catch (ValidationException $e) {
echo $e->getMessage(); // 参数校验失败
print_r($e->errors()); // ['email' => '格式不对']
echo $e->getCode(); // 422
}
自定义异常的价值在于带上下文。只 throw new Exception("失败了") 的代码,日志里除了一句话什么都留不下。
推荐按语义分两类:
RuntimeException子类:运行期才会发生的问题(网络、数据库)。LogicException子类:代码写错了(参数非法、状态不对),正常不该发生。
异常链:getPrevious()
<?php
try {
// 底层操作
$content = file_get_contents("/etc/app.json");
} catch (Throwable $e) {
throw new RuntimeException("加载配置失败", 0, $e); // 保留原始异常
}
第三个参数把原异常挂上去,打印时会显示完整链路:
<?php
echo (string)$e;
// RuntimeException: 加载配置失败 in /app/x.php:10
// Caused by: file_get_contents(...): Failed to open stream ...
这是排查线上问题的关键信息。只写 throw new RuntimeException("失败") 而不传 $e,等于把根因扔了。
把警告变成异常(生产环境的推荐做法)
既然 try/catch 抓不到 E_WARNING,那就主动把它转成异常:
<?php
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline): bool {
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});
try {
$x = $arr['not_exist']; // 现在会抛 ErrorException,可以被 catch
} catch (ErrorException $e) {
echo "抓到了:" . $e->getMessage();
}
这个技巧能让 try/catch 真正覆盖到警告类错误。注意两点:
set_error_handler管不到E_ERROR/E_PARSE/E_CORE_*(这些是致命错误,发生在执行前或无法拦截)。- 它也会拦截
E_DEPRECATED、E_NOTICE,在升级 PHP 版本时会让一堆老代码突然抛异常。所以建议按error_reporting过滤掉不想要的级别:
<?php
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
if (!(error_reporting() & $errno)) {
return false; // 这个级别不在报告范围内,交回默认处理
}
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
}, E_ALL & ~E_DEPRECATED & ~E_NOTICE);
错误报告配置:开发与生产必须分开
开发环境(要看得见):
display_errors = On
error_reporting = E_ALL
log_errors = On
生产环境(要看得见日志,但绝不显示给用户):
display_errors = Off
error_reporting = E_ALL & ~E_DEPRECATED
log_errors = On
error_log = /var/log/php/error.log
display_errors = On 在生产环境是安全事故:堆栈信息里有绝对路径、SQL 语句、甚至数据库口令。这是大量 PHP 站点被拖库的入口之一。
代码里也可以用 php.ini 之外的方式临时控制:
<?php
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
日志:error_log 和框架的日志
最基础的写法:
<?php
error_log("用户 {$uid} 下单失败:" . $e->getMessage());
它写到 error_log 配置指定的位置,或者系统日志。优点是零依赖、任何环境下都能用;缺点是结构化能力为零。
生产项目一般上 PSR-3 的日志库(Monolog):
<?php
$logger->error('下单失败', [
'user_id' => $uid,
'order_id' => $oid,
'exception' => $e,
]);
结构化日志的价值在于可以按字段检索。error_log 写的是"人读的句子",Monolog 写的是"机器可查的记录"。
一个完整的兜底结构
<?php
declare(strict_types=1);
// 1) 统一把所有错误变成异常
set_error_handler(function (int $no, string $str, string $file, int $line): bool {
if (!(error_reporting() & $no)) return false;
throw new ErrorException($str, 0, $no, $file, $line);
});
// 2) 未捕获的异常:记日志 + 返回 500,绝不输出堆栈
set_exception_handler(function (Throwable $e): void {
error_log(sprintf(
"[%s] %s: %s in %s:%d\n%s",
date('Y-m-d H:i:s'),
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine(),
$e->getTraceAsString()
));
if (!headers_sent()) {
http_response_code(500);
}
echo "服务异常";
});
// 3) 致命错误(内存超限、语法错误等)用 shutdown 兜底
register_shutdown_function(function (): void {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
error_log("FATAL: {$err['message']} in {$err['file']}:{$err['line']}");
}
});
这三段合起来,才算是"任何错误都不会静默消失、也不会把堆栈泄露给用户"的完整方案。
什么时候该抛异常,什么时候该返回
一条实用判断:
- 预期之内的失败(用户输入不合法、库存不足、验证码错误)→ 返回结果对象/错误码,或者抛"业务异常"由统一入口转成 4xx。
- 预期之外的问题(数据库连不上、文件不存在、第三方接口超时)→ 抛异常,让它冒泡到顶层记录。
全都用异常会让正常流程充满 try/catch;全都用返回值又会让错误被忽略。分界线就是"这件事正常业务里会不会发生"。
坑清单
try/catch抓不到E_WARNING(数组越界、file_get_contents失败),要set_error_handler转换。finally里写return,会导致异常被吞、返回值被覆盖。- 抛异常不带
previous,根因丢失。 catch (Exception)写在更具体的 catch 前面,后面的永不执行。catch (Exception $e)抓不到Error(TypeError之类),要么用Throwable,要么分开 catch。- 生产环境
display_errors = On,直接泄露路径和 SQL。 @抑制符把错误吞掉却不记录,等于制造隐形故障。- 只
error_log($e->getMessage()),丢了文件、行号和调用栈,等于白记。