例外處理的老家是 app/Exceptions/Handler.php,用 report()、register()、renderable 客製化行為——今天要把這整套邏輯搬家。Laravel 11+ 新骨架裡這個檔案預設不存在了,全部改到 bootstrap/app.php 的 withExceptions() 閉包。
Day 6 提過,APP_DEBUG=true 時例外會顯示完整堆疊、程式碼片段,方便開發除錯;正式環境務必是 false,否則等於把內部細節攤給使用者看。今天的重點是「例外真正發生時,該怎麼被記錄、又該回傳什麼給使用者」。
// 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。

除了上面三個核心方法,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() 讓你設定「這種例外每分鐘最多記錄幾次」,既不會漏掉問題發生的訊號,也不會讓監控系統被噪音淹沒。
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() 強迫每個繼承的例外類別都必須明確想清楚「使用者看到這個錯誤時該顯示什麼」,不會有例外意外漏掉這個設計。
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.php 的 withExceptions()。如果你的專案是從 Laravel 10 升級上來、本來就有這個檔案,它依然可以繼續運作,不強制搬家。report() 閉包裡又呼叫一次例外的 report() 方法,造成重複記錄:如果例外類別自己已經定義了 report(),bootstrap/app.php 的全域 report() 閉包通常不需要再對同一個類別重複處理,除非你確實需要疊加額外的全域行為(例如所有例外都要多記一份到監控系統)。render() 洩漏內部訊息:即使 APP_DEBUG 是 false,如果你在 render() 裡把 $e->getMessage() 原封不動塞進 API 回應,還是可能不小心洩漏內部實作細節(例如資料庫錯誤訊息),對外的錯誤訊息建議統一改寫成使用者能理解、不暴露內部結構的文字,前面提到的 userMessage() 分離模式正是為了解決這個問題。dontFlash(),敏感欄位被記錄進日誌:驗證失敗時 Laravel 預設會把使用者輸入的資料存進 Session(讓 old() 能重新填值),密碼這類欄位如果沒有排除,可能意外殘留在 Session 或錯誤報告裡。throttle() 沒設定,正式環境某個持續發生的錯誤把通知系統灌爆:這是實際維運中很常遇到的情境,尤其是依賴外部服務(Day 26 的 API 呼叫)的功能,外部服務不穩定時如果沒有節流,你的手機可能在幾分鐘內收到幾百則一模一樣的告警。例外處理的核心概念——區分要不要記錄、記錄時做什麼、回傳什麼給使用者——完全沒變,變的只是設定位置從 Handler.php 搬進 bootstrap/app.php 的 withExceptions()。今天也深入了自訂例外的階層架構設計、throttle()/dontFlash()/context() 這幾個實務上很有價值的進階方法。自訂例外類別可以直接在類別本身定義 report()/render(),通常比全部塞進 bootstrap/app.php 更好維護。
亡羊補牢,猶未晚也 — 《戰國策・楚策四》
Day 19 進入 Queue 佇列系統:Driver、Job、Worker 這些核心概念沒有變動,另外補上 Laravel 13 新增的 Queue::route() 集中式路由與 Job 專用 PHP Attribute。