iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Modern Web

我推的Laravel S2!系列 第 19

我推的Laravel S2|Day 18:Exception 例外處理:bootstrap/app.php 的新寫法

  • 分享至 

  • xImage
  •  

一句話破題

例外處理的老家是 app/Exceptions/Handler.php,用 report()register()renderable 客製化行為——今天要把這整套邏輯搬家。Laravel 11+ 新骨架裡這個檔案預設不存在了,全部改到 bootstrap/app.phpwithExceptions() 閉包。

Debug 模式:快速複習

Day 6 提過,APP_DEBUG=true 時例外會顯示完整堆疊、程式碼片段,方便開發除錯;正式環境務必是 false,否則等於把內部細節攤給使用者看。今天的重點是「例外真正發生時,該怎麼被記錄、又該回傳什麼給使用者」。

withExceptions():新的中樞設定點

// bootstrap/app.php
use Illuminate\Foundation\Configuration\Exceptions;

->withExceptions(function (Exceptions $exceptions) {
    // 對應原本 Handler.php 的 $dontReport 屬性
    $exceptions->dontReport([
        PostPublishingThrottledException::class,
    ]);

    // 對應原本 register() 裡的 reportable() 閉包
    $exceptions->report(function (InvalidPostStateException $e) {
        Log::channel('slack')->error('文章狀態異常', [
            'post_id' => $e->postId,
            'message' => $e->getMessage(),
        ]);
    });

    // 對應原本 register() 裡的 renderable() 閉包
    $exceptions->render(function (InvalidPostStateException $e, Request $request) {
        if ($request->wantsJson()) {
            return response()->json(['message' => $e->getMessage()], 422);
        }
    });
})

三個方法各自對應 Handler.php 原本的核心職責:

  • dontReport():這些類型的例外不需要記錄(例如已知的、可預期的業務邏輯例外,本來就會發生、不代表系統故障)。
  • report():例外被記錄時要額外做什麼(發 Slack 通知、寫進特定日誌通道)。
  • render():這個例外該轉換成什麼樣的 HTTP 回應(區分 API 請求回 JSON、一般網頁請求回錯誤頁面)。

report()/render() 閉包的第一個參數型別提示決定了這段邏輯只對哪種例外生效,跟舊寫法的 reportable(function (SomeException $e) {...}) 是同一個機制,只是位置從獨立類別搬進 bootstrap/app.php

例外處理流程:Throwable 拋出後先檢查是否在 dontReport() 名單內,接著跑 report() 記錄,最後 render() 決定回傳 JSON 或錯誤頁面

withExceptions() 其他實用方法

除了上面三個核心方法,Exceptions 物件還提供幾個處理特定情境的方法:

->withExceptions(function (Exceptions $exceptions) {
    // 限制同一種例外在一段時間內最多記錄幾次,避免瞬間大量重複錯誤洗版日誌/通知
    $exceptions->throttle(function (Throwable $e) {
        if ($e instanceof PostPublishingThrottledException) {
            return Limit::perMinute(10);
        }
    });

    // 某些例外攜帶的資料不該被記錄進日誌(例如包含使用者輸入的敏感內容)
    $exceptions->dontFlash(['password', 'password_confirmation']);

    // 為所有記錄的例外附加額外的上下文資訊
    $exceptions->context(fn () => [
        'user_id' => auth()->id(),
    ]);

    // 完全客製化「找不到符合處理邏輯的例外」時的最終回應
    $exceptions->respond(function (Response $response, Throwable $e, Request $request) {
        if ($response->getStatusCode() === 419 && $request->wantsJson()) {
            return response()->json(['message' => 'CSRF Token 已過期,請重新整理頁面。'], 419);
        }
        return $response;
    });
})

throttle() 在正式環境特別有價值——想像一個第三方 API(Day 26 的 HTTP Client)突然完全掛掉,blog-app 每次呼叫都會拋出同一種例外,如果沒有節流機制,Slack 通知頻道可能在幾分鐘內被同一則錯誤訊息灌爆上千次,throttle() 讓你設定「這種例外每分鐘最多記錄幾次」,既不會漏掉問題發生的訊號,也不會讓監控系統被噪音淹沒。

建立自訂 Exception

php artisan make:exception InvalidPostStateException
// app/Exceptions/InvalidPostStateException.php
class InvalidPostStateException extends Exception
{
    public function __construct(
        public readonly int $postId,
        string $message = '文章目前狀態不允許此操作',
    ) {
        parent::__construct($message);
    }

    public function report(): void
    {
        Log::warning("文章 #{$this->postId} 狀態異常操作被阻擋");
    }

    public function render(Request $request): Response
    {
        return response()->json(['message' => $this->getMessage()], 409);
    }
}

這裡有個實用的細節:如果例外類別自己定義了 report()/render() 方法(如上),Laravel 會優先使用例外類別自己的邏輯,不需要額外跑去 bootstrap/app.php 註冊——withExceptions() 適合處理「這個例外類別是第三方套件丟出的、我沒辦法在它身上加方法」的情境,自己專案裡定義的例外,直接寫進例外類別本身反而更集中好維護。

設計一套自訂例外的階層架構

blog-app 隨著功能增加,遲早會需要不只一種業務邏輯例外,這時候值得花點心思設計一個共同的基底類別,而不是每個例外都直接繼承 PHP 內建的 Exception

// app/Exceptions/BlogAppException.php
abstract class BlogAppException extends Exception
{
    abstract public function userMessage(): string;
}

// app/Exceptions/InvalidPostStateException.php
class InvalidPostStateException extends BlogAppException
{
    public function __construct(public readonly int $postId)
    {
        parent::__construct("Post #{$this->postId} is not in a state that allows this operation.");
    }

    public function userMessage(): string
    {
        return '這篇文章目前的狀態不允許此操作。';
    }
}

// app/Exceptions/DailyPostQuotaExceededException.php
class DailyPostQuotaExceededException extends BlogAppException
{
    public function userMessage(): string
    {
        return '今天的發文額度已經用完了,請明天再試。';
    }
}

有了這個共同基底之後,bootstrap/app.php 可以用一條規則統一處理所有業務邏輯例外,不需要為每個新增的例外類別都重複寫一段 render()

$exceptions->render(function (BlogAppException $e, Request $request) {
    if ($request->wantsJson()) {
        return response()->json(['message' => $e->userMessage()], 422);
    }

    return back()->withErrors(['error' => $e->userMessage()]);
});

這個模式的價值在於內部技術訊息(給開發者看的 getMessage())跟對外顯示訊息(userMessage())明確分離——即使開發階段例外訊息寫得再詳細具體,也不用擔心不小心把內部細節洩漏給使用者看到,因為對外顯示永遠走 userMessage() 這條專門設計過的路徑。這也呼應 Day 22 會談到的介面分離原則:abstract public function userMessage() 強迫每個繼承的例外類別都必須明確想清楚「使用者看到這個錯誤時該顯示什麼」,不會有例外意外漏掉這個設計。

HTTP Exceptions 與自訂錯誤頁面

abort(404);
abort(403, '你沒有權限編輯這篇文章');
abort_if(is_null($post->published_at) && ! auth()->user()?->can('viewDraft', $post), 404);

自訂錯誤頁面的畫面,放進 resources/views/errors/,檔名對應狀態碼:

resources/views/errors/404.blade.php
resources/views/errors/403.blade.php
resources/views/errors/500.blade.php
{{-- resources/views/errors/404.blade.php --}}
@extends('layouts.app')

@section('content')
    <h1>找不到這篇文章</h1>
    <p>可能已經被刪除,或者網址打錯了。</p>
    <a href="{{ route('posts.index') }}">回到文章列表</a>
@endsection

如果你不想為每一種狀態碼都準備一個獨立檔案,可以用一個範圍檔案處理一整組狀態碼(例如所有 4xx 錯誤共用一個樣式):

resources/views/errors/4xx.blade.php   # 沒有明確對應檔案的 4xx 狀態碼都會 fallback 到這裡
resources/views/errors/5xx.blade.php

Laravel 會優先找完全對應的檔案(例如 404.blade.php),找不到才退回範圍檔案(4xx.blade.php),這種分層 fallback 機制讓你可以「常見的幾個狀態碼客製化,其餘的用通用樣式應付」,不需要窮舉每一種可能的狀態碼。

測試例外處理邏輯

Day 27 會深入完整的測試主題,這裡先提一個跟今天內容直接相關的測試技巧:

test('存取未發布文章時回傳 404', function () {
    $post = Post::factory()->create(['published_at' => null]);

    $this->get(route('posts.show', $post))->assertNotFound();
});

test('InvalidPostStateException 會回傳正確的錯誤訊息', function () {
    $post = Post::factory()->create();

    $this->postJson("/api/posts/{$post->id}/archive")
        ->assertStatus(409)
        ->assertJson(['message' => '這篇文章目前的狀態不允許此操作。']);
});

測試環境預設會停用一部分例外處理的正常行為(讓例外直接往外拋、方便測試框架捕捉並提供更詳細的失敗訊息),如果你想確保「例外真的會被正確轉換成 HTTP 回應」而不是被測試框架攔截,這類 Feature Test(透過 $this->get()/$this->postJson() 實際發出請求)是最貼近真實情境的驗證方式。

常見錯誤與踩雷點

  • 照舊教材去找 app/Exceptions/Handler.php:這個檔案在新骨架裡預設不存在,正確位置是 bootstrap/app.phpwithExceptions()。如果你的專案是從 Laravel 10 升級上來、本來就有這個檔案,它依然可以繼續運作,不強制搬家。
  • report() 閉包裡又呼叫一次例外的 report() 方法,造成重複記錄:如果例外類別自己已經定義了 report()bootstrap/app.php 的全域 report() 閉包通常不需要再對同一個類別重複處理,除非你確實需要疊加額外的全域行為(例如所有例外都要多記一份到監控系統)。
  • 正式環境 render() 洩漏內部訊息:即使 APP_DEBUGfalse,如果你在 render() 裡把 $e->getMessage() 原封不動塞進 API 回應,還是可能不小心洩漏內部實作細節(例如資料庫錯誤訊息),對外的錯誤訊息建議統一改寫成使用者能理解、不暴露內部結構的文字,前面提到的 userMessage() 分離模式正是為了解決這個問題。
  • 忘記設定 dontFlash(),敏感欄位被記錄進日誌:驗證失敗時 Laravel 預設會把使用者輸入的資料存進 Session(讓 old() 能重新填值),密碼這類欄位如果沒有排除,可能意外殘留在 Session 或錯誤報告裡。
  • throttle() 沒設定,正式環境某個持續發生的錯誤把通知系統灌爆:這是實際維運中很常遇到的情境,尤其是依賴外部服務(Day 26 的 API 呼叫)的功能,外部服務不穩定時如果沒有節流,你的手機可能在幾分鐘內收到幾百則一模一樣的告警。

小結

例外處理的核心概念——區分要不要記錄、記錄時做什麼、回傳什麼給使用者——完全沒變,變的只是設定位置從 Handler.php 搬進 bootstrap/app.phpwithExceptions()。今天也深入了自訂例外的階層架構設計、throttle()/dontFlash()/context() 這幾個實務上很有價值的進階方法。自訂例外類別可以直接在類別本身定義 report()/render(),通常比全部塞進 bootstrap/app.php 更好維護。

亡羊補牢,猶未晚也 — 《戰國策・楚策四》

明日預告

Day 19 進入 Queue 佇列系統:Driver、Job、Worker 這些核心概念沒有變動,另外補上 Laravel 13 新增的 Queue::route() 集中式路由與 Job 專用 PHP Attribute。


上一篇
我推的Laravel S2|Day 17:Validation 驗證器:規則、自訂驗證、Form Request、array_keys
下一篇
我推的Laravel S2|Day 19:Queue 佇列:Driver、Job、Worker、Queue::route()
系列文
我推的Laravel S2!22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言