iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Modern Web

我推的Laravel S2!系列 第 18

我推的Laravel S2|Day 17:Validation 驗證器:規則、自訂驗證、Form Request、array_keys

  • 分享至 

  • xImage
  •  

一句話破題

所有來自使用者的輸入都是不可信的,這句話在資安領域講了幾十年,而 Validation 就是 Laravel 幫你把這條原則落實成程式碼的地方。今天把驗證器的完整能力攤開,並補上 Laravel 12、13 各自帶來的一個實際更新——其中一個還是為了修補 SVG 上傳的 XSS 風險。

前情提要

Day 13 我們在 PostController 裡用了最簡化的 $request->validate()。今天把驗證器的完整能力攤開:常用規則、怎麼抽成 Form Request 讓 Controller 保持乾淨、怎麼寫自訂規則,並補上 Laravel 12、13 的兩個相關更新。

常用驗證規則

$request->validate([
    'title' => ['required', 'string', 'max:255'],
    'body' => ['required', 'string', 'min:10'],
    'published_at' => ['nullable', 'date', 'after_or_equal:today'],
    'category_id' => ['required', 'exists:categories,id'],
    'cover' => ['nullable', 'image', 'max:2048'],
    'slug' => ['required', 'alpha_dash', 'unique:posts,slug'],
]);

幾個常見規則的語意:

規則 用途
required 必填
nullable 允許為 null(沒有這個規則,其他規則遇到 null 可能會驗證失敗)
string/numeric/integer/boolean 型別檢查
max:255/min:10 長度或數值範圍限制
exists:categories,id 必須存在於指定資料表的指定欄位
unique:posts,slug 必須在指定資料表裡唯一(更新資料時記得排除自己,見下方常見錯誤)
date/after/before 日期格式與時間先後判斷
image/mimes:jpg,png 檔案型別限制

條件式驗證規則

實務上很常遇到「這個欄位要不要驗證,取決於另一個欄位的值」的情境:

$request->validate([
    'status' => ['required', 'in:draft,scheduled,published'],

    // 只有狀態是 scheduled 時,才要求 scheduled_at 必填
    'scheduled_at' => ['required_if:status,scheduled', 'date', 'after:now'],

    // 狀態是 draft 時,這個欄位不能出現(禁止傳入)
    'published_at' => ['prohibited_if:status,draft'],

    // 只有欄位存在時才驗證,不存在就跳過(適合 PATCH 局部更新)
    'title' => ['sometimes', 'required', 'string', 'max:255'],
]);

sometimes 這個規則值得特別說明:它跟 nullable 常被搞混,但語意完全不同。nullable 代表「這個欄位可以是 null」,sometimes 代表「這個欄位可以完全不存在於請求裡」。Day 13 的 update() 方法如果要支援「使用者只想改標題,不想動內文」這種局部更新,body 欄位應該用 sometimes(沒傳就跳過驗證、也不更新),而不是 nullable(沒傳會被當成 null,可能誤把內文清空)。

巢狀與陣列驗證

blog-app 之後如果要一次接收多個標籤 ID(Day 9 提過的多對多 Tag 關聯),驗證陣列型別的資料要用萬用字元語法:

$request->validate([
    'tag_ids' => ['array'],
    'tag_ids.*' => ['integer', 'exists:tags,id'],   // 陣列裡每個元素都要符合這個規則

    'comments' => ['array'],
    'comments.*.body' => ['required', 'string', 'max:1000'],   // 巢狀物件陣列
    'comments.*.author_name' => ['required', 'string', 'max:50'],
]);

tag_ids.* 這種寫法會對陣列裡的每一個元素分別套用規則,如果驗證失敗,錯誤訊息會明確指出是第幾個元素出問題(例如 tag_ids.2 代表索引 2 的那個值不合法),比起自己寫迴圈逐一檢查陣列元素,這種宣告式寫法既精簡又能得到精確的錯誤定位。

Validator::make():比 $request->validate() 更有彈性

$request->validate() 驗證失敗會直接拋例外並自動導回上一頁(帶著錯誤訊息),這在多數表單情境很方便,但如果你需要驗證失敗後做更多客製化處理,改用 Validator::make()

use Illuminate\Support\Facades\Validator;

$validator = Validator::make($request->all(), [
    'title' => ['required', 'string', 'max:255'],
]);

if ($validator->fails()) {
    return response()->json(['errors' => $validator->errors()], 422);
}

$validated = $validator->validated();
// 或只取部分欄位:$validator->safe()->only(['title']);

客製化錯誤訊息與欄位顯示名稱

$validator = Validator::make($request->all(), [
    'title' => ['required', 'max:255'],
], messages: [
    'title.required' => ':attribute 是必填欄位,別忘記給文章取個標題。',
], attributes: [
    'title' => '文章標題',   // :attribute 佔位符會被替換成這個中文名稱
]);

:attribute:max:min 這類佔位符會在錯誤訊息裡自動被替換成對應的實際值,這系列從 Day 1 就強調中文撰寫,attributes 這個參數正是讓錯誤訊息對繁體中文使用者更友善的關鍵——沒有它,錯誤訊息預設會顯示英文欄位名稱(例如「The title field is required.」的 title),對非技術背景的使用者不夠直觀。

Form Request:把驗證邏輯抽出 Controller

Controller 方法一多,驗證規則寫在裡面會讓方法越來越肥。Form Request 把「這個請求該長什麼樣子」獨立成一個類別:

php artisan make:request StorePostRequest
// app/Http/Requests/StorePostRequest.php
class StorePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // 建立新文章任何登入者都可以,所以直接回傳 true
        // 若是 UpdatePostRequest,這裡就會接上 Day 13 建立的 PostPolicy:
        // return $this->user()->can('update', $this->route('post'));
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string', 'min:10'],
        ];
    }

    public function messages(): array
    {
        return [
            'title.required' => '文章標題不能是空的喔!',
        ];
    }
}

Controller 方法只要把型別提示換成這個類別,Laravel 會自動在方法執行前跑完驗證:

public function store(StorePostRequest $request)
{
    $post = auth()->user()->posts()->create($request->validated());

    return redirect()->route('posts.show', $post);
}

Day 13 那個簡化版的 store()/update(),從今天起在實際專案裡都建議換成這種 Form Request 寫法,尤其是驗證規則會被多處重用、或規則本身邏輯複雜時。

Form Request 的兩個進階 Hook

Form Request 除了 rules()/authorize()/messages(),還有兩個容易被忽略、但實務上很有用的方法:

class StorePostRequest extends FormRequest
{
    // 驗證「之前」執行,適合在驗證前先整理/標準化輸入資料
    protected function prepareForValidation(): void
    {
        $this->merge([
            'slug' => $this->slug ? Str::slug($this->slug) : Str::slug($this->title),
        ]);
    }

    // 在 Validator 實例建好、但還沒開始驗證時被呼叫,
    // 用來掛一個「等規則跑完之後」才執行的 after 回呼,適合跨欄位的複雜業務規則檢查
    public function withValidator(Validator $validator): void
    {
        $validator->after(function ($validator) {
            if ($this->status === 'published' && strlen($this->body) < 50) {
                $validator->errors()->add('body', '要發布的文章內容不能少於 50 字。');
            }
        });
    }
}

prepareForValidation() 讓你在真正跑驗證規則之前,先對輸入資料做一次「整理」——上面範例的 slug 正規化就是很好的例子,讓使用者不需要自己確保輸入格式正確,你在驗證前先幫他標準化。

withValidator() 的時機容易被誤解,這裡講清楚:它本身是在 Validator 物件建立好、但驗證還沒開始跑的時候被呼叫,你在裡面做的事通常是「登記一個 after() 回呼」,而那個回呼會等到所有規則都跑完之後才執行。這個設計適合處理「單一欄位規則沒辦法表達,需要同時看好幾個欄位才能判斷」的業務邏輯,例如上面範例的「要發布就必須內容夠長」這種跨欄位條件。

要注意 after() 回呼不管前面的規則有沒有全部通過都會被執行,所以如果你的判斷邏輯依賴某個欄位「已經通過基本驗證」,記得自己先確認一次,例如先檢查 $validator->errors()->isEmpty(),或在存取欄位前做好 null 防護,避免前面驗證已經失敗、後面的跨欄位邏輯又對著不完整的資料再噴一個令人困惑的錯誤訊息。

自訂驗證規則

php artisan make:rule NoBannedWords
// app/Rules/NoBannedWords.php
class NoBannedWords implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $banned = ['廣告', '博彩'];

        foreach ($banned as $word) {
            if (str_contains($value, $word)) {
                $fail("內容不能包含「{$word}」這類字詞。");
                return;
            }
        }
    }
}
'title' => ['required', 'string', new NoBannedWords()],

自訂規則類別實作 ValidationRule 介面、定義 validate() 方法,這套介面從 Laravel 10 就是這個形狀,13 沒有變動;如果你維護的舊專案還在用更早期的 Rule 介面(passes()/message() 兩個方法的寫法),一樣能運作,但新規則建議直接用現行的 ValidationRule 寫法。

簡單規則不需要建立獨立類別:閉包寫法

如果驗證邏輯只在單一場景用得到、不需要重用,建立一個獨立類別檔案可能有點小題大作,Laravel 也支援直接用閉包定義規則:

$request->validate([
    'slug' => [
        'required',
        function (string $attribute, mixed $value, Closure $fail) {
            if (Post::where('slug', $value)->where('user_id', '!=', auth()->id())->exists()) {
                $fail('這個網址代稱已經被其他作者使用了。');
            }
        },
    ],
]);

判斷原則:這條規則會在多個地方重用,或邏輯複雜到需要獨立測試,就抽成 ValidationRule 類別;只在單一表單用一次的簡單邏輯,閉包更省事,不需要為每個一次性的驗證需求都建立一個檔案。

Laravel 12:image 規則預設排除 SVG

image 規則過去會允許 SVG 檔案通過驗證。Laravel 12 起基於安全性考量(SVG 可以內嵌 JavaScript,是已知的 XSS 攻擊向量之一),image 規則預設不再允許 SVG

// 如果你的專案有正當理由需要接受 SVG 上傳(例如允許使用者上傳向量圖示)
'cover' => ['required', 'image:allow_svg'],

// 或用物件寫法
use Illuminate\Validation\Rules\File;
'cover' => ['required', File::image(allowSvg: true)],

blog-app 的文章封面圖沒有特別需要支援 SVG,維持預設行為(不允許)即可,這本身就是一個更安全的預設值。

File 這個規則物件寫法除了控制 SVG,還提供更精細的檔案驗證選項,值得認識:

'cover' => [
    'required',
    File::image()
        ->min(10)                    // 最小檔案大小(KB)
        ->max(2 * 1024)              // 最大檔案大小(KB),這裡是 2MB
        ->dimensions(Rule::dimensions()->maxWidth(4000)->maxHeight(4000)),   // 圖片解析度限制
],

比起字串規則 'image', 'max:2048' 這種寫法,File 物件在需要組合多個條件、或條件需要動態計算時(例如依使用者方案調整上傳大小上限)更容易維護。

Laravel 13:新增 array_keys 規則

required_array_keys 規則(驗證陣列「必須包含哪些 key」)已經存在一段時間,Laravel 13 補上互補的另一半:array_keys 規則驗證陣列「只能包含哪些 key」

$request->validate([
    'sort_options' => ['array', 'array_keys:field,direction'],
]);

// 也可以用 Rule::arrayKeys() 的鏈式寫法,效果相同
use Illuminate\Validation\Rule;

$request->validate([
    'sort_options' => ['array', Rule::arrayKeys('field', 'direction')],
]);

上面這條規則允許 sort_options 是一個只包含 fielddirection 這兩個 key 的陣列(可以只出現其中一個,但不能出現第三個沒列出的 key),適合驗證那種「使用者可以傳入一組選填設定,但不希望他們塞進其他未定義的 key」的情境,例如 blog-app 之後如果要開放使用者自訂文章列表的排序/篩選參數,這條規則能防止意外或惡意帶入不該存在的參數 key。

提早中止驗證:bail

預設情況下,Laravel 會把一個欄位的所有規則都跑過一遍,把所有失敗的規則都收集起來回報。如果某個規則的檢查成本較高(例如 unique 需要查資料庫),你可能想讓它「一失敗就不繼續檢查後面的規則」:

$request->validate([
    'slug' => ['bail', 'required', 'alpha_dash', 'unique:posts,slug'],
]);

bail 讓這個欄位只要有一條規則沒過就立刻停止,不會繼續往下驗證同一欄位的其他規則——例如 slug 如果連 required 都沒過(根本沒填),就不需要浪費一次資料庫查詢去檢查 unique。這是個小優化,但在驗證規則多、或某些規則涉及資料庫/外部服務查詢時,能省下不必要的檢查成本。

常見錯誤與踩雷點

  • 更新資料時 unique 規則把自己也算進重複'slug' => ['unique:posts,slug'] 在編輯既有文章時,會把資料庫裡「自己原本的那筆」也算成衝突,正確寫法要排除當前這筆:Rule::unique('posts', 'slug')->ignore($post->id)
  • nullable 漏加,導致選填欄位傳空值時驗證失敗:例如 published_at 允許不填(代表草稿),如果只寫 'date' 沒加 nullable,傳空字串或 null 會被 date 規則擋下來。
  • Form Request 的 authorize() 忘記改成實際判斷,卻不小心留著預設的 falsemake:request 產生的預設骨架 authorize() 有時是 return false;,忘記改的話所有請求都會被擋在 403,這是新手很常踩的一個坑。
  • sometimesnullable 搞混:前面提過的重點,sometimes 是「欄位可以不存在」,nullable 是「欄位存在但值可以是 null」,用錯會導致局部更新(PATCH)時預期外地清空了某個欄位。
  • 陣列驗證忘記先驗證外層是 array 型別'tag_ids.*' => ['integer'] 如果 tag_ids 本身傳入的不是陣列(例如客戶端傳了字串),Laravel 的行為可能不如預期,先加上 'tag_ids' => ['array'] 確保外層型別正確,再驗證內層元素。

小結

Validation 的核心概念——常用規則、Form Request、自訂規則類別——在 Laravel 10 到 13 間維持穩定,今天除了補上 Laravel 12 起 image 規則預設排除 SVG(安全性強化)、Laravel 13 新增 array_keys 規則這兩個版本更新,也深入了條件式驗證、巢狀陣列驗證、Form Request 的兩個進階 Hook,這些是實際專案裡驗證邏輯會用到的完整工具箱。

差之毫釐,謬以千里 — 《禮記・經解》

明日預告

Day 18 處理 Exception 例外處理,Handler.php 那套集中處理邏輯,一樣要搬進 bootstrap/app.php


上一篇
我推的Laravel S2|Day 16:Middleware 與請求偽造防護新篇章:bootstrap/app.php、CSRF 改名
系列文
我推的Laravel S2!18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言