Symfony 7.x 异常处理机制:自定义错误页面、HTTP 状态码模板、异常监听器与生产环境日志调试

发布于
1

本文系统讲解 Symfony 7.x 的异常与错误处理流程,涵盖 HttpKernel 异常体系、404/500 错误页面定制、Twig 例外模板、kernel.exception 监听器、API JSON 错误响应、生产环境 debug 关闭与 Monolog 日志调试。适用于需要构建稳定、可观测、用户友好的 PHP Web 应用与接口服务的开发场景。

在 Symfony 7.x 应用中,错误并不只是「页面白屏」或「抛出一个 Exception」这么简单 😅。路由未匹配、参数校验失败、权限不足、数据库异常、第三方服务超时,都会进入统一的异常调度链路。合理的异常处理策略可以实现三件事:

  1. 对用户返回清晰、品牌化的错误页面,避免暴露服务器路径与堆栈信息 🎨;

  2. 对 API 客户端返回结构化 JSON 错误体,包含 code、message、traceId 等可用字段 📦;

  3. 对运维与开发团队输出可检索日志,定位异常源头、请求上下文与复现条件 🔍。

一、Symfony 7.x 异常体系与处理流程

Symfony 基于 HttpKernel 组件调度请求。控制器、事件、服务中抛出的异常会先被 Kernel 捕获,再交由错误处理器生成 Response。

常用内置异常位于 Symfony\Component\HttpKernel\Exception

  • NotFoundHttpException:对应 404,路由不存在或资源未找到;

  • AccessDeniedHttpException:对应 403,鉴权通过但无权访问;

  • UnauthorizedHttpException:对应 401,未认证或凭证缺失;

  • BadRequestHttpException:对应 400,请求参数非法;

  • MethodNotAllowedHttpException:对应 405,路由存在但 HTTP 方法不允许;

  • HttpException:所有 HTTP 状态码异常的基类,可传 statusCode;

  • 普通 \Exception\RuntimeException 未显式映射时,生产环境通常归为 500。

处理链路简化如下:

  1. 控制器/服务抛异常;

  2. Kernel 触发 kernel.exception 事件;

  3. 内置监听器与自定义监听器可修改异常、补充上下文、替换 Response;

  4. 若未生成 Response,则进入 ErrorController/Twig 例外模板;

  5. 返回最终错误 Response,同时按日志配置写入文件或外部系统。

小提示:开发环境开启 debug: true 会展示 Web Debug Toolbar 与详细堆栈;生产环境应关闭 debug,仅展示自定义错误页,原始堆栈写入日志 🧪。

二、基础配置:debug 模式与错误控制

Symfony 7.x 通常通过环境变量与服务配置控制错误行为。

.env.env.prod 示例:

APP_ENV=prod
APP_DEBUG=0

config/packages/framework.yaml 关键项:

framework:
    secret: '%env(APP_SECRET)%'
    # 错误页控制器,可自定义覆盖
    error_controller: 'error_controller'
    # 生产环境不向浏览器暴露底层错误
    php_errors:
        log: true
        throw: true

若使用 Monolog,建议在 config/packages/prod/monolog.yaml 中单独配置错误渠道:

monolog:
    handlers:
        main:
            type: rotating_file
            path: '%kernel.logs_dir%/app.log'
            level: info
            max_files: 14
        exception:
            type: rotating_file
            path: '%kernel.logs_dir%/exception.log'
            level: error
            max_files: 30
            channels: ['!event']

生产环境配合 PHP 本身关闭显示错误更安全:

display_errors = Off
log_errors = On
error_log = /var/log/php/php_error.log

这样浏览器只看到友好页,服务器与 Symfony 日志保留排查依据 🔐。

三、Twig 自定义错误页面:404/500 按状态码模板

Symfony 使用 TwigBundle 的例外模板渲染 HTML 错误页。最规范的覆盖路径为:

templates/bundles/TwigBundle/Exception/
├── error.html.twig      # 全部状态码兜底
├── error404.html.twig   # 仅 404
├── error500.html.twig   # 仅 500
├── error403.html.twig   # 仅 403
└── error.html.twig.json # API/JSON 格式可配合格式判断

error.html.twig 基础示例:

{# templates/bundles/TwigBundle/Exception/error.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}系统错误 {{ status_code }} {% endblock %}

{% block body %}
    <div class="container py-5 text-center">
        <h1>{{ status_code }} 😢</h1>
        <p class="lead">{{ status_text }}</p>

        {% if app.environment == 'dev' %}
            <div class="alert alert-danger text-start">
                <strong>Debug:</strong>{{ exception.message }}
            </div>
        {% else %}
            <p>当前请求未能正常处理,请稍后重试或联系技术支持。🛠️</p>
        {% endif %}

        <a href="{{ path('homepage') }}" class="btn btn-primary">返回首页</a>
    </div>
{% endblock %}

仅覆盖 404 的友好页:

{# templates/bundles/TwigBundle/Exception/error404.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}页面未找到 404 {% endblock %}

{% block body %}
    <div class="container py-5 text-center">
        <h1>404 🔍</h1>
        <p class="lead">访问的页面不存在,可能被移除、改名或输入错误。</p>
        <a href="{{ path('homepage') }}" class="btn btn-outline-primary">回到首页</a>
    </div>
{% endblock %}

按状态码细分模板优先级高于通用 error.html.twig。修改后清缓存:

php bin/console cache:clear
php bin/console cache:warmup

开发环境可直接访问不存在路由验证 404 模板;生产环境需将 APP_DEBUG=0 后再用 curl 验证,避免一直看到调试页 🧹。

四、自定义异常类与业务错误码

业务系统不建议在控制器随意 throw new \Exception(‘fail’)。应定义语义异常,便于监听器、日志、API 层统一处理。

示例:订单业务异常

<?php

namespace App\Exception;

use Symfony\Component\HttpKernel\Exception\HttpException;

class OrderBusinessException extends HttpException
{
    public function __construct(
        string $message = '订单处理失败',
        int $statusCode = 422,
        private array $extra = [],
        ?\Throwable $previous = null
    ) {
        parent::__construct($statusCode, $message, $previous);
        $this->extra = $extra;
    }

    public function getExtra(): array
    {
        return $this->extra;
    }
}

控制器中使用:

#[Route('/api/orders/{id}/pay', name: 'api_order_pay', methods: ['POST'])]
public function pay(int $id, OrderService $service): JsonResponse
{
    if (!$service->exists($id)) {
        throw new NotFoundHttpException('订单不存在');
    }

    if (!$service->canPay($id)) {
        throw new OrderBusinessException('当前订单状态不可支付', 422, [
            'order_id' => $id,
            'state' => $service->getState($id),
        ]);
    }

    // 支付逻辑...

    return $this->json(['ok' => true]);
}

这样异常自带 HTTP 状态与业务字段,后续监听器无需反查消息字符串 📌。

五、异常监听器:kernel.exception 统一拦截

需要统一加 traceId、转 JSON、隐藏内部消息、上报日志时,使用事件订阅者监听 kernel.exception

Symfony 7.x 推荐实现 EventSubscriberInterface 并监听 KernelEvents::EXCEPTION

<?php

namespace App\EventListener;

use App\Exception\OrderBusinessException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\KernelEvents;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;

class ApiExceptionSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private LoggerInterface $logger,
        private string $environment,
        private array $jsonPrefixes = ['/api']
    ) {}

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::EXCEPTION => ['onException', 10],
        ];
    }

    public function onException(ExceptionEvent $event): void
    {
        $request = $event->getRequest();
        $uri = $request->getRequestUri();

        // 仅对 API 前缀或 Accept: application/json 做 JSON 响应
        $acceptJson = str_contains($request->headers->get('accept', ''), 'application/json');
        $isApi = $this->isApiUri($uri) || $acceptJson;

        $throwable = $event->getThrowable();

        // 记录日志
        $this->logger->error('Unhandled exception', [
            'uri' => $uri,
            'status' => $throwable instanceof HttpException ? $throwable->getStatusCode() : 500,
            'class' => get_class($throwable),
            'message' => $throwable->getMessage(),
            'trace' => $this->environment === 'dev' ? $throwable->getTraceAsString() : null,
        ]);

        if (!$isApi) {
            // 非 API 交给默认错误页/ErrorController,不在此替换 Response
            return;
        }

        $status = $throwable instanceof HttpException ? $throwable->getStatusCode() : 500;
        $data = [
            'success' => false,
            'status' => $status,
            'message' => $this->publicMessage($throwable),
        ];

        if ($throwable instanceof OrderBusinessException) {
            $data['extra'] = $throwable->getExtra();
        }

        if ($this->environment === 'dev') {
            $data['debug'] = [
                'class' => get_class($throwable),
                'detail' => $throwable->getMessage(),
                'file' => $throwable->getFile(),
                'line' => $throwable->getLine(),
            ];
        }

        $event->setResponse(new JsonResponse($data, $status));
    }

    private function isApiUri(string $uri): bool
    {
        foreach ($this->jsonPrefixes as $prefix) {
            if (str_starts_with($uri, $prefix)) {
                return true;
            }
        }
        return false;
    }

    private function publicMessage(\Throwable $e): string
    {
        if ($e instanceof HttpException) {
            return match ($e->getStatusCode()) {
                404 => '资源不存在',
                403 => '无访问权限',
                401 => '未认证或登录失效',
                400 => '请求参数错误',
                422 => $e->getMessage() ?: '业务校验未通过',
                default => '服务暂时不可用',
            };
        }

        // 非 Http 异常在生产环境不暴露原始信息
        return $this->environment === 'dev' ? $e->getMessage() : '服务器内部错误';
    }
}

注册服务并启用标签:

services:
    App\EventListener\ApiExceptionSubscriber:
        tags:
            - { name: kernel.event_subscriber }
        arguments:
            $environment: '%kernel.environment%'
            $jsonPrefixes: ['/api', '/mobile']

要点说明:

  • 优先级 10 可保证在默认错误页之前介入;若还要让其他监听器补充上下文,可调低优先级;

  • 非 API 请求不要强制返回 JSON,否则会破坏 Twig 错误页;

  • 生产环境对 500 类异常统一输出「服务器内部错误」,详细原因只进日志,防止泄露 SQL、路径、第三方密钥 🔏。

六、自定义 ErrorController:完全接管错误响应

若希望所有错误走自有控制器而不是 TwigBundle 默认逻辑,可定义 error_controller

framework.yaml

framework:
    error_controller: App\Controller\CustomErrorController::show

控制器示例:

<?php

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Twig\Environment;

class CustomErrorController
{
    public function __construct(private Environment $twig) {}

    public function show(\Throwable $exception): Response
    {
        $status = $exception instanceof HttpException ? $exception->getStatusCode() : 500;

        $template = sprintf('error/error%s.html.twig', $status);
        if (!($this->twig->getLoader()->exists($template))) {
            $template = 'error/error.html.twig';
        }

        $html = $this->twig->render($template, [
            'status_code' => $status,
            'status_text' => Response::$statusTexts[$status] ?? 'Unknown',
            'exception' => $exception,
        ]);

        return new Response($html, $status);
    }
}

该方式适合多站点、多主题、按主机名切换错误模板的场景;普通项目用 TwigBundle 例外模板更省事 🧩。

七、API 项目错误响应规范

REST/JSON API 建议统一结构:

{
  "success": false,
  "status": 422,
  "code": "ORDER_CANNOT_PAY",
  "message": "当前订单状态不可支付",
  "extra": {
    "order_id": 123,
    "state": "refunded"
  },
  "trace_id": "a1b2c3d4"
}

实现建议:

  1. 自定义异常增加 codetraceId 字段;

  2. 监听器在 kernel.exception 生成 traceId 并写入日志;

  3. Accept 头、URL 前缀、请求格式 _format 判断是否需要 JSON;

  4. 校验失败可用 Symfony\Component\HttpKernel\Exception\BadRequestHttpExceptionUnprocessableEntityHttpException(Symfony 6+ 起 UnprocessableEntityHttpException 可用于 422);

  5. 表单/Validator 异常可单独监听 form.error 或转换 ConstraintViolationList 为字段级错误数组。

生成 traceId 示例:

$traceId = $_SERVER['HTTP_X_REQUEST_ID']
    ?? sprintf('%08x', crc32(uniqid((string) mt_rand(), true)));

并在日志上下文与 JSON 响应中同时返回,便于后端按 traceId 拉取全链路日志 🧵。

八、生产环境调试与日志排错

生产环境原则:页面友好、日志完整、信息不外泄。

推荐组合:

  • APP_DEBUG=0

  • framework.php_errors.log=true,不向浏览器 throw detail;

  • Monolog 按 level 分流:info/warn/error/critical 分文件或接入 ELK、Loki;

  • 对 500 异常配置邮件/钉钉/Webhook 报警(通过 Monolog handler 或独立监听器);

  • 使用 bin/console debug:event kernel.exception 查看异常监听器顺序;

  • 使用 bin/console router:match /some-url 验证路由是否命中,定位伪 404;

  • 清缓存后复现:bin/console cache:clear --env=prod

查看已注册错误相关服务:

php bin/console debug:container error_controller
php bin/console debug:event kernel.exception

若某些异常未写日志,检查:

  • 自定义监听器是否提前 setResponse 但未调用 logger;

  • Monolog level 是否高于异常等级,例如 handler level=error 会忽略 warning;

  • 使用 Sentry/Bugsnag 等 APM 时,确认 client 在 kernel.exception 中捕获而非仅 Monolog。

九、常见排错清单

  • 404 模板不生效:确认路径为 templates/bundles/TwigBundle/Exception/error404.html.twig;清缓存;检查是否存在自定义 ErrorController 抢先返回;开发环境 debug 开启时会优先显示调试页。

  • 500 仍显示堆栈:生产环境检查 APP_DEBUG、web 服务器 display_errors、framework php_errors.throw 配置;若自定义监听器设了 Response,需确保不把原始 message 发到前端。

  • API 返回 HTML 错误页:监听器未识别 JSON 请求;增加 /api 前缀或 Accept: application/json 判断;确认响应 Content-Type: application/json

  • 异常监听器不执行:服务未打 kernel.event_subscriber 标签;优先级被其他监听器覆盖;在 dev 用 debug:event 核查。

  • 日志无异常:Monolog handler level 过高、channel 被排除、异常在 controller 中被 try/catch 吞掉且未 rethrow 或 log。

  • 状态码不正确:业务异常未继承 HttpException;手动 new Response($html, 200) 会误导监控系统,错误页必须返回对应 4xx/5xx。

结尾

Symfony 7.x 的异常处理不是单一「catch 块」,而是「内置 Http 异常 + kernel.exception 事件 + ErrorController/Twig 例外模板 + Monolog 日志」的整套体系 🧠。HTML 站点优先用按状态码 Twig 模板做品牌化错误页;API 服务用异常订阅者统一输出 JSON、traceId 与业务 code;生产环境坚持关闭调试输出、只记日志、按需报警。按上述分层落地后,错误率监控、用户反馈、开发定位三者可以同时兼顾,系统稳定性会明显提升 🚀。

0 讨论
热门最新
总结
暂无总结
0 / 600
嗨,下午好!
所有的成功,都源自一个勇敢的开始
¥10.00
10元抵扣券
已过期