~/blog/laravel-custom-validation-middleware-security-guide-2.md
Laravel 與後端開發 · 2025 / 12 / 27

Laravel 防線告急?手刻驗證 (Validation) 與中介層 (Middleware),打造駭客繞道的安全防線!

Eric — 浪花科技創辦人 / AI 架構師
Eric
浪花科技創辦人 · AI 架構師
Laravel 防線告急?手刻驗證 (Validation) 與中介層 (Middleware),打造駭客繞道的安全防線!
目錄 table-of-contents.md

Laravel 安全防線該怎麼做?

只用 'email' => 'required|email' 這類內建規則就覺得 API 夠安全,等於只在大門裝了個喇叭鎖。一條穩固的 Laravel 防線需要兩道把關:客製化驗證規則(Custom Validation Rule)負責檢查「資料內容是否符合商業邏輯」,客製化中介層(Custom Middleware)則在請求進入控制器前先攔下「沒資格的請求」。

說穿了,Validation 管的是「進來的資料對不對」,Middleware 管的是「這個請求該不該進來」。兩者分工清楚、各司其職,Controller 才能保持乾淨,同時把非法請求擋在外面。底下用優惠券驗證與 API 金鑰兩個實戰範例,帶你把這套防線從 0 到 1 建起來。

身為一個天天在 Code 海裡打滾的工程師,我看過太多 Laravel 新手把專案當作一個開放式樂園,大門敞開,誰都能進來玩。他們可能會說:「我有用 Laravel 內建的 validation 啊!'email' => 'required|email',很安全吧?」

每次聽到這個,我工程師的小囉嗦模式就會自動開啟。兄弟,這就像是你家大門裝了個喇叭鎖,就自以為固若金湯了。真正的戰場上,你需要的是護城河、是瞭望塔,是層層把關的鐵衛。在 Laravel 的世界裡,這對無敵的守衛組合,就是「客製化驗證(Custom Validation)」與「客製化中介層(Custom Middleware)」。

今天,就讓我帶你跳脫官方文件的基礎教學,深入這兩個 Laravel 核心功能的精髓,從 0 到 1 打造出真正企業級、讓駭客看到都想繞道的應用程式防線。

客製化驗證與 Middleware 差在哪?該用哪一個?

動手寫程式之前,先把職責分清楚,後面才不會把邏輯放錯地方。它們處在請求生命週期的不同階段:

  • Middleware(中介層):站在請求生命週期的最前端,在請求還沒碰到控制器與驗證之前就先攔截。它關心的是「這個請求有沒有資格繼續往下走」——身份、金鑰、權限、來源。
  • Validation(驗證):在請求進入控制器(或 Form Request)後,檢查使用者送進來的資料內容是否合規——格式對不對、商業規則符不符合。

一個簡單的判斷準則:如果你的檢查不依賴「使用者填了什麼欄位」,而是針對整個請求(例如 Header 裡的 API Key、登入者的角色),那就放 Middleware;如果你的檢查是針對某個欄位的值(例如優惠券代碼是否有效),那就放 Validation。下面我們分別實戰。

第一道防線:客製化驗證規則 — 不只驗證格式,更要驗證商業邏輯

Laravel 內建的驗證規則(required、email、max、min⋯)非常方便,處理常規的表單綽綽有餘。但當你的業務邏輯開始變得複雜時,它們就顯得捉襟見肘了。這時候,就是客製化驗證規則登場的最佳時機。

什麼時候該手刻驗證規則?

當你的驗證邏輯符合以下任一情境時,別懷疑,就是它了:

  • 複雜的格式驗證:例如台灣的身分證字號、手機條碼、公司統一編號,這些都有固定的演算法,不是一個簡單的正規表達式就能搞定的。
  • 需查詢資料庫的驗證:例如,驗證使用者輸入的優惠券代碼是否存在、是否已過期、是否已被使用。
  • 跨欄位的相依性驗證:例如,當「運送方式」選擇「宅配」時,「運送地址」欄位才變成必填。
  • 可重用的商業邏輯:如果某個驗證邏輯會在多個地方被使用(例如,A 表單和 B API 都需要驗證同一個東西),把它封裝成一個獨立的 Rule Object 是最乾淨的做法。

實戰演練:打造一個「優惠券驗證」規則

想像一下,我們正在開發一個電商網站,需要一個驗證 Coupon Code 的規則。這個規則需要檢查三件事:1. Code 存在於資料庫 2. 尚未過期 3. 未被使用。光用內建規則肯定做不到。來,打開你的終端機,跟著我一起做。

步驟一:建立 Rule 物件

Artisan 指令是我們最好的朋友,一行指令就能建立好我們的規則檔案結構:

php artisan make:rule IsValidCoupon

這會在 app/Rules 資料夾下建立一個 IsValidCoupon.php 檔案。

步驟二:撰寫驗證邏輯

打開 IsValidCoupon.php,你會看到兩個主要的方法:passes()message()。這就是我們的主戰場。

  • passes($attribute, $value):這是驗證的核心。$value 就是使用者傳入的值(我們的 coupon code)。這個方法必須回傳 true(驗證通過)或 false(驗證失敗)。
  • message():當驗證失敗時,要回傳給使用者的錯誤訊息。

讓我們把商業邏輯寫進去:

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use App\Models\Coupon; // 假設你有一個 Coupon 的 Eloquent Model
use Carbon\Carbon;

class IsValidCoupon implements Rule
{
    /**
     * Determine if the validation rule passes.
     *
     * @param  string  $attribute
     * @param  mixed  $value
     * @return bool
     */
    public function passes($attribute, $value)
    {
        // 1. 檢查優惠券是否存在
        $coupon = Coupon::where('code', $value)->first();

        if (!$coupon) {
            return false;
        }

        // 2. 檢查是否已被使用 (假設 used_at 欄位為 null 代表尚未使用)
        if ($coupon->used_at !== null) {
            return false;
        }

        // 3. 檢查是否已過期 (假設 expires_at 是到期日)
        if (Carbon::now()->gt($coupon->expires_at)) {
            return false;
        }

        // 所有檢查都通過了!
        return true;
    }

    /**
     * Get the validation error message.
     *
     * @return string
     */
    public function message()
    {
        // 給使用者一個通用的、安全的錯誤訊息
        return '您輸入的優惠券代碼無效或已過期。';
    }
}

步驟三:在控制器或 Form Request 中使用

現在,我們可以在需要的地方優雅地使用這個規則了。我強烈建議使用 Form Request 來組織你的驗證邏輯,這能讓你的 Controller 保持乾淨。這才是資深工程師的作法,別把所有邏輯都塞在 Controller 裡,那會變成一場災難!

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use App\Rules\IsValidCoupon; // 記得引入我們的規則

class ApplyCouponRequest extends FormRequest
{
    public function rules()
    {
        return [
            'coupon_code' => ['required', 'string', new IsValidCoupon()],
            // 其他欄位...
        ];
    }
}

看到了嗎?new IsValidCoupon(),就是這麼簡潔。我們把複雜的商業邏輯完美地封裝起來,控制器只需要專注於處理驗證通過後的請求即可。這不僅提高了程式碼的可讀性,也讓這個規則可以在任何地方被重複使用。

為什麼錯誤訊息要「故意講得模糊」?

注意上面 message() 回傳的是一句籠統的「無效或已過期」,而不是分別告訴使用者「這張券不存在」「這張券已被使用」「這張券已過期」。這是一個刻意的安全設計。

如果錯誤訊息太精確,等於免費送給攻擊者一個探測工具——他可以反覆送出不同代碼,從回應差異中推斷「哪些代碼真的存在」,進而暴力枚舉出有效優惠券。對外的訊息越模糊越好;真正詳細的失敗原因,應該寫進伺服器端的日誌(Log)裡供你自己除錯,而不是回給前端。

一個容易被忽略的坑:驗證通過不代表沒有競態問題

上面的規則檢查了「優惠券尚未被使用」,但要特別注意:驗證階段的檢查,和真正扣除優惠券的動作,是兩個分開的時間點。在高併發場景下(例如限量優惠券,很多人同時搶),可能出現兩個請求都通過了驗證、然後都去把同一張券標記為已使用的情況。

客製化驗證規則負責的是「擋掉一望即知無效的輸入」,而「同一張券只能被用一次」這種強一致性保證,最終仍要在實際寫入時,用資料庫交易(Transaction)、唯一索引或鎖(lock)來收尾。把驗證當成第一道篩子,但別把它當成唯一的保險。

新版 Laravel 的 Rule 介面有什麼不同?

上面範例實作的是 Illuminate\Contracts\Validation\Rule 介面,用的是 passes()message() 兩個方法,這是長期以來最廣為人知的寫法,目前仍能運作。較新版本的 Laravel 另外提供了 ValidationRule 介面,把驗證集中在單一的 validate() 方法中,透過一個 $fail 回呼來丟出錯誤訊息。兩種寫法的核心觀念完全一致——把商業邏輯封裝成可重用的物件——差別只在方法簽章。若你正在維護既有專案,沿用現有寫法即可;若是全新專案,可參考你所用版本的官方文件選擇對應介面。

第二道防線:客製化中介層 — API 的忠實守門人

如果說 Validation 是檢查「信件內容」是否合規的審查員,那 Middleware 就是在信件送達前,檢查「信封」和「郵差身份」的守衛。它在請求生命週期的最前端,可以決定一個請求是否有資格繼續往下走。

什麼時候該手刻 Middleware?

  • API 金鑰驗證:所有需要保護的 API 都應該有一個 Middleware 來檢查請求 Header 中的 API Key 是否有效。
  • 權限控管:檢查登入的使用者是否具有特定角色(例如:管理員、編輯)才能存取某個路由。
  • 請求日誌(Logging):記錄所有傳入的 API 請求,方便追蹤與除錯。
  • 維護模式:在特定條件下,將網站導向維護頁面。
  • 修改請求或回應:在請求送達控制器前,可以先加上特定的 Header;或在回應送回給使用者前,統一加上某些 Header。

先搞懂:Middleware 的「前置」與「後置」是什麼意思?

很多人以為 Middleware 只能在請求進來時做事,其實它的位置很靈活。關鍵在於你的邏輯寫在 $next($request)前面還是後面

  • 前置(before):把邏輯寫在呼叫 $next($request) 之前。適合做「攔截」——金鑰驗證、權限檢查,不合格就直接擋下,根本不讓請求進到控制器。
  • 後置(after):先取得 $response = $next($request),再對這個回應動手,最後才回傳。適合做「加工」——統一在回應掛上某個 Header、記錄回應狀態碼等。

理解這個前後關係,你就能寫出更有彈性的 Middleware,而不只是「擋掉或放行」這麼單純。下面的 API 金鑰範例,就是典型的「前置」用法。

實戰演練:打造一個「API 金鑰驗證」中介層

假設我們的 App 需要提供一組 API 給合作夥伴使用,我們得確保只有合法的請求才能存取。最常見的做法就是透過 HTTP Header 中的 X-API-KEY 來驗證。

步驟一:建立 Middleware

php artisan make:middleware EnsureApiKeyIsValid

這會在 app/Http/Middleware 資料夾下建立一個 EnsureApiKeyIsValid.php 檔案。

步驟二:撰寫處理邏輯

打開檔案,核心就在 handle() 方法。這個方法有兩個重要的參數:$request$next

  • $request:就是傳入的 HTTP 請求物件,我們可以從中取得 Header、輸入等資訊。
  • $next:這是一個 Closure(閉包)。如果驗證通過,你必須呼叫 $next($request),把請求「交棒」給下一個處理程序(可能是另一個 Middleware,或最終的控制器)。如果沒有呼叫它,請求就會在這裡被中止。
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class EnsureApiKeyIsValid
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle(Request $request, Closure $next)
    {
        // 從 .env 檔案取得我們自己設定的合法 API Key
        $validApiKey = config('services.api.key');

        // 從請求的 Header 中取得傳入的 API Key
        $submittedApiKey = $request->header('X-API-KEY');

        // 進行比對
        if (!$submittedApiKey || $submittedApiKey !== $validApiKey) {
            // 如果驗證失敗,直接回傳 401 Unauthorized 錯誤,中斷請求
            return response()->json(['message' => 'Unauthorized.'], 401);
        }

        // 驗證通過!交給下一個程序處理
        return $next($request);
    }
}

一個小提醒:千萬不要把 API Key 這種敏感資訊直接寫在程式碼裡,務必放在 .env 檔案中,然後透過 config() 輔助函數來讀取。

進階提醒:比對金鑰時的安全細節

上面用 !== 做字串比對,邏輯上完全正確,足以應付一般情境。但若你想再嚴謹一點,這裡有兩個值得知道的觀念:

  • 時序攻擊(timing attack):一般的字串比較會在「第一個不相符的字元」就提前結束,理論上攻擊者可以從回應時間的細微差異反推金鑰。PHP 內建的 hash_equals() 提供「定時比較」,比較兩個字串時不會因內容差異而提早返回,能降低這類風險。
  • 狀態碼語意:金鑰錯誤回傳 401 Unauthorized(你沒通過身份驗證)是合理的;若是「身份有效但沒有權限存取此資源」,語意上更貼近 403 Forbidden。把狀態碼用對,對串接你 API 的合作夥伴會友善很多。

這些都是錦上添花的細節,原本的範例已經能正常運作;要不要採用,看你的安全等級需求。

步驟三:註冊並使用 Middleware

Middleware 寫好了,但 Laravel 還不知道它的存在。我們需要去註冊它。在 Laravel 11 中,這件事變得更簡潔了,直接在 bootstrap/app.php 檔案中操作:

->withMiddleware(function (Middleware $middleware) {
    // 給我們的 middleware 一個好記的別名
    $middleware->alias([
        'api.key' => \App\Http\Middleware\EnsureApiKeyIsValid::class,
    ]);
})

註冊完畢後,就可以在路由檔案(例如 routes/api.php)中保護我們的 API 了:

use App\Http\Controllers\PartnerController;

// 方法一:保護單一路由
Route::get('/partner/data', [PartnerController::class, 'getData'])->middleware('api.key');

// 方法二:保護一整個路由群組 (更推薦的作法)
Route::middleware(['api.key'])->prefix('partner')->group(function () {
    Route::get('/data', [PartnerController::class, 'getData']);
    Route::post('/update', [PartnerController::class, 'updateData']);
});

搞定!現在所有在那個群組內的 API,都必須在 Header 帶上正確的 X-API-KEY 才能存取。這道防線,堅固!

兩道防線怎麼搭配,才是企業級寫法?

把前面所有觀念串起來,一個健康的請求流程會像這樣,從外到內層層收斂:

  1. Middleware 先把關身份與資格:API 金鑰、登入狀態、角色權限。沒通過,請求在這裡就被擋下,連控制器都進不去。
  2. Form Request + 客製化驗證規則檢查資料內容:欄位格式、商業邏輯(如優惠券有效性)。這層保證進到控制器的資料都是「乾淨且合規」的。
  3. Controller 只專注於業務本身:因為前兩道防線已經幫它過濾掉非法請求與不合格資料,控制器可以假設「手上拿到的東西都是可信的」,邏輯自然清爽。
  4. 最終寫入時的一致性保證:對於限量、扣款這類不可重複的動作,在實際寫入資料庫的當下,用交易與鎖收尾,補上驗證階段無法保證的競態防護。

這套分層的精神,正是 DRY(Don't Repeat Yourself)原則的具體實踐:共通的把關邏輯抽到 Middleware,可重用的驗證封裝成 Rule Object,業務邏輯留在它該在的地方。

總結:打造可信賴的應用程式,從細節做起

身為工程師,我們寫的 Code 不只是要「能動」,更要「可靠」、「安全」、「可維護」。Laravel 驗證與 Middleware 客製化,正是實踐這些原則的關鍵武器。

客製化驗證讓我們能將複雜的商業規則封裝起來,保持 Controller 的簡潔;客製化中介層則為我們的應用程式建立了一道又一道的安檢門,將非法請求拒之門外。當你熟練地運用這兩者時,你就不再只是一個「會用框架」的開發者,而是一個真正懂得如何「架構應用程式」的工程師。

這條路很長,但每一步都值得。別再讓你的 Laravel API 裸奔了,現在就動手,為它穿上最堅實的盔甲吧!

延伸閱讀

// FAQ

常見問題

Laravel 的 Validation 和 Middleware 差別在哪?該用哪一個?
Middleware 站在請求生命週期的最前端,在請求碰到控制器前先攔截,關心的是「這個請求有沒有資格繼續往下走」,例如身份、API 金鑰、權限、來源。Validation 則在請求進入控制器後,檢查使用者送進來的資料內容是否合規,例如格式與商業規則。簡單判斷:檢查若針對整個請求(如 Header 的 API Key、登入者角色)就放 Middleware;若針對某個欄位的值(如優惠券代碼是否有效)就放 Validation。
什麼情況該手刻 Laravel 客製化驗證規則,而不是用內建規則?
當驗證邏輯超出內建規則能處理的範圍時就該手刻,常見情境包括:需要固定演算法的複雜格式驗證(如身分證字號、統一編號)、需查詢資料庫的驗證(如優惠券是否存在或過期)、跨欄位相依性驗證(如選宅配時地址才必填),以及會在多處重複使用、適合封裝成 Rule Object 的可重用商業邏輯。
如何在 Laravel 建立客製化驗證規則?
使用 Artisan 指令 php artisan make:rule IsValidCoupon,會在 app/Rules 資料夾下建立規則檔。檔案中實作 passes($attribute, $value) 方法寫驗證核心邏輯並回傳 true 或 false,再用 message() 方法回傳驗證失敗時的錯誤訊息。建議在 Form Request 中以 new IsValidCoupon() 的方式套用,讓 Controller 保持乾淨。
驗證失敗的錯誤訊息為什麼要故意寫得模糊?
若錯誤訊息太精確(分別指出代碼不存在、已被使用、已過期),等於給攻擊者一個探測工具,他可反覆送出不同代碼、從回應差異推斷哪些代碼真的存在,進而暴力枚舉出有效資料。因此對外訊息越模糊越好,例如統一回「無效或已過期」;詳細的失敗原因應寫進伺服器端日誌供除錯,不要回給前端。
通過了優惠券驗證,就能保證一張券只被用一次嗎?
不能。驗證階段的「尚未被使用」檢查,和真正扣除優惠券的動作是兩個分開的時間點,在高併發場景下可能出現多個請求都通過驗證、又都把同一張券標記為已使用的競態問題。驗證只是擋掉一望即知無效的輸入,「同一張券只能用一次」這種強一致性保證仍要在實際寫入時用資料庫交易、唯一索引或鎖來收尾。
~/roamer-tech/newsletter // FREE
// newsletter

訂閱免費電子報

把 AI 自動化、企業系統設計與 WordPress / Laravel 開發的真實案例和可直接照做的技巧,整理成電子報寄給你。只寄精選內容、不灌垃圾信,一鍵就能退訂。

$
// final.exec()

準備好讓你的網站開始為你工作了嗎?