PHP 基本语法 10|错误与异常:`Error`、`Exception` 和日志

先说清楚 PHP 的"两套机制"

PHP 有两套并行的出错机制,这是它和多数语言最不一样的地方:

  1. 错误(Error 级别):E_WARNING、E_NOTICE、E_DEPRECATED 这些,默认行为是"打印一条提示然后继续执行"。它们不是异常,try/catch 抓不到。
  2. 异常(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 真正覆盖到警告类错误。注意两点:

  1. set_error_handler 管不到 E_ERROR / E_PARSE / E_CORE_*(这些是致命错误,发生在执行前或无法拦截)。
  2. 它也会拦截 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;全都用返回值又会让错误被忽略。分界线就是"这件事正常业务里会不会发生"。

坑清单

  1. try/catch 抓不到 E_WARNING(数组越界、file_get_contents 失败),要 set_error_handler 转换。
  2. finally 里写 return,会导致异常被吞、返回值被覆盖。
  3. 抛异常不带 previous,根因丢失。
  4. catch (Exception) 写在更具体的 catch 前面,后面的永不执行。
  5. catch (Exception $e) 抓不到 Error(TypeError 之类),要么用 Throwable,要么分开 catch。
  6. 生产环境 display_errors = On,直接泄露路径和 SQL。
  7. @ 抑制符把错误吞掉却不记录,等于制造隐形故障。
  8. 只 error_log($e->getMessage()),丢了文件、行号和调用栈,等于白记。