iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Modern Web

Laravel Filament 從入門到實戰系列 第 14 篇

Day 13:表單驗證不只是必填,跨欄位規則怎麼寫

  • 分享至 

  • xImage
  •  

Day 12:把商品、訂單、客戶串起來,第一次像個真正的系統 結尾留下一句話,關聯打通只解決了資料能不能被正確串起來的問題,還沒有處理填進去的值本身合不合理這個更細緻的層次。

訂單明細的數量欄位目前只做了最基本的必填限制,使用者手滑填入負數,或是打進一個不合常理的超大數字,介面完全不會阻擋,這筆明細照樣能存進資料庫。

今天我們來處理表單驗證這件事情。

關聯打通了,但誰都能填出一張荒謬的訂單

打開任何一張訂單的編輯頁,商品明細區塊點下新增,選一個商品,數量欄位打進 -5,送出。這筆明細會被順利接受,畫面上不會出現任何警示,資料庫裡也確實多了一筆數量是負五的訂單明細。換一個更誇張的測試,數量欄位打進 9990000,一樣照樣存檔成功。這兩個例子都不是刻意刁難系統,而是實際操作時很容易發生的手滑情境,鍵盤多按一個零,或是負號打錯位置沒注意到,Filament 目前完全沒有能力替使用者擋下這種輸入。

問題不在於 Day 11:Relation Manager,訂單明細這種巢狀資料怎麼管理 定案的 Relation Manager 機制哪裡做錯了,這個機制負責的是讓訂單明細能被獨立新增、編輯、刪除,這件事它做得很好。問題出在數量這個欄位身上目前只掛了 required() 跟 numeric(),前者只問「有沒有填」,後者只問「填的是不是數字」。

兩者都沒有回答「填的這個數字合理嗎」。

今天的任務範圍講清楚,不重新設計任何欄位型別,也不動版面配置,商品、客戶、訂單、訂單明細這四個模組。ˊ這四個模組的欄位定義完全沿用前面幾天已經定案的內容。

要做的事情,是替既有欄位補上更貼近業務常理的驗證規則,並處理跨欄位這種規則本身需要參照另一個欄位才能判斷的情境。對有 Laravel 開發經驗的讀者來說,這是 Laravel Validation 這個我們早就熟悉的機制,只是今天要示範它在 Filament 表單上怎麼掛載。

必填之外,欄位值本身要落在合理範圍

如果借用開場提過的比喻,必填規則像是檢查一扇門有沒有鎖上,鎖了就算過關,至於鎖頭本身鎖的對不對,必填規則管不到。

範圍規則要處理的,正是鎖頭鎖得對不對這一層,數量欄位除了不能空白,填進去的值本身還必須落在業務上說得過去的區間。

先處理數字下限,訂購數量至少要是一,零跟負數在業務上都不成立,沒有人會下一張數量是零或負五的訂單:

TextInput::make('quantity')
    ->label('數量')
    ->numeric()
    ->minValue(1)
    ->default(1)
    ->required(),

minValue(1) 掛上去之後,使用者填入零或負數,表單會直接擋下,跳出提示要求數量至少為一,不會再讓這種明顯違反常理的值混進資料庫。

上限的業務邏輯一般來說會稍微複雜一點,但今天先處理最基本的版本,設定一個業務上合理的單筆訂購上限,避免鍵盤多按一個零這類誤觸造成數量暴增:

TextInput::make('quantity')
    ->label('數量')
    ->numeric()
    ->minValue(1)
    ->maxValue(9999)
    ->default(1)
    ->required(),

這裡的 9999 只是一個示範用的粗略上限,實際專案會依照批發商真正的單筆訂購常態去調整這個數字。之所以先示範這個版本,是為了跟接下來要處理的跨欄位驗證做對照,maxValue(9999) 是一個寫死在程式碼裡的固定天花板,不管選的是哪個商品,上限永遠是同一個數字。

但業務常理上,飼料這種常備品可能一次叫貨上百件都合理,某個冷門玩具庫存本來就只有十幾件,用同一個固定天花板去框住所有商品,本身就是另一種不夠貼近真實情況的規則。

這個落差,我們留到下一段,用跨欄位驗證真正解決。

範圍規則跟必填規則在性質上的差異到這裡已經很明顯,必填只問「有沒有填」,範圍規則問的是「填的這個值本身合理嗎」,這是驗證規則需要覆蓋的第二個層次,也是今天要建立的第一個手感。

格式也是一種規則:聯絡電話不該接受任意字元

數值型欄位處理完,換一個完全不同性質的破綻。回頭看 Day 09:表單裡選關聯資料,下拉選單背後在做什麼 定案的客戶模組,phone 這個聯絡電話欄位目前的寫法只有 ->tel():

TextInput::make('phone')
    ->label('聯絡電話')
    ->tel(),

tel() 這個方法目前只負責讓輸入框在行動裝置上喚出數字鍵盤,方便使用者輸入,但它本身沒有強制輸入內容一定要符合電話號碼的樣式。也就是說,這個欄位目前任何文字都能存進去,打「不知道」三個字,或是打一串英文字母,系統一樣照單全收。這跟訂單明細數量的問題本質相同,都是有欄位卻沒有把關,只是這次把關的對象不是數值大小,而是字元排列樣式。

補上格式規則,讓聯絡電話欄位真正檢查內容是否符合電話號碼常見的樣式,這裡示範兩種常見寫法。第一種是搭配正規表達式規則,直接限制輸入內容只能是數字、加號、括號跟連字號的組合:

TextInput::make('phone')
    ->label('聯絡電話')
    ->tel()
    ->regex('/^[0-9+\-() ]{8,15}$/'),

第二種是 Filament 內建的格式輔助方式,tel() 這個方法本身有一組預設的電話樣式檢查機制,也支援用 telRegex() 換上自訂的格式規則,不需要另外拉一條 regex():

TextInput::make('phone')
    ->label('聯絡電話')
    ->tel()
    ->telRegex('/^[0-9+\-() ]{8,15}$/'),

兩種寫法達到的效果相同,選哪一種比較像是團隊寫作習慣的取捨,telRegex() 語意上更貼合這是一個電話欄位的事實,regex() 則是更通用、也能套用在任何文字欄位上的規則寫法。掛上規則之後,使用者打進一串英文字母或符號,表單會直接擋下,跳出提示要求輸入符合電話格式的內容。

格式規則跟上一段的範圍規則,其實同屬「值本身要合理」這個層次,只是關心的角度不同。

範圍規則關心數值大小落在哪個區間,格式規則關心字元排列成什麼樣子,兩者服務同一個目的,把關必填規則檢查不到的細節。

跨欄位驗證,規則要參照另一個欄位才能判斷

前面兩段處理的都是單欄位規則,欄位只需要知道自己的值就能判斷合不合理,數量夠不夠大、電話格式對不對,都不需要看表單裡其他欄位一眼。

但回頭看上一段留下的那個問題,數量上限用一個固定寫死的 9999 當天花板,對飼料這種常備品可能太小,對冷門商品又可能太大。

真正貼近業務常理的上限,其實是這筆明細選定的那個商品,當下的庫存數量。飼料庫存還有五百件,數量填四百八十件合理,同一個欄位如果選的是庫存只剩十二件的冷門玩具,數量卻填了四百八十件,這張明細本身就不成立。

這正是一個典型的跨欄位驗證情境:quantity 這個欄位是否合法,需要先讀到同一筆明細裡 product_id 欄位目前選的是哪個商品,再回頭查這個商品的 stock 欄位,兩個欄位互相對照,才能判斷數量填得對不對。單欄位規則框架在這裡不夠用,因為 quantity 欄位自己的規則設定,沒有辦法直接知道 product_id 選了誰。

Filament 表單提供 Get 這個工具物件,可以在規則閉包裡讀到表單當下其他欄位的值,寫法上呼應 Laravel Validation 機制裡讀者已經熟悉的閉包規則概念,只是這次閉包裡多了一個能查詢同一份表單其他欄位的入口:

use Filament\Schemas\Components\Utilities\Get;
use Filament\Forms\Components\TextInput;
use \Closure;

TextInput::make('quantity')
    ->label('數量')
    ->numeric()
    ->minValue(1)
    ->default(1)
    ->required()
    ->rules([
        fn (Get $get): Closure => function (string $attribute, $value, Closure $fail) use ($get) {
            $productId = $get('product_id');

            if (! $productId) {
                return;
            }

            $stock = Product::find($productId)?->stock;

            if ($stock !== null && $value > $stock) {
                $fail("數量不能超過所選商品目前的庫存數量({$stock} 件)。");
            }
        },
    ]),

拆解這段程式碼在做的事情。rules() 方法接受的不再是單純的規則字串,而是一個回傳規則的閉包,Filament 會在驗證發生的那一刻,把 Get 這個工具注入進來,讓規則本身有能力查詢表單裡任何一個欄位當下的值,這裡查的是 product_id。拿到商品主鍵之後,直接查一次 Product 這個既有的 Model,取出它的 stock 欄位,跟使用者填入的 quantity 值做比較,超過庫存就透過 $fail() 擋下這筆輸入。整個過程沒有引入任何新的驗證機制,用的就是 Laravel Validation 裡自訂規則、閉包規則這組讀者已有背景知識的既有概念,只是這次規則邏輯上多了一層依賴,判斷 quantity 合不合法之前,得先讀到 product_id 當下填的是什麼。

錯誤訊息的呈現也值得停下來看一眼。上面的寫法裡,$fail() 帶出的訊息明確寫出「數量不能超過所選商品目前的庫存數量」,並且把具體的庫存數字帶進訊息裡。這個細節不是隨手加上去的,跨欄位驗證失敗的時候,如果訊息只含糊地說「輸入不正確」,使用者完全不知道問題出在數量填太多,還是商品選錯了,甚至不知道該回頭改哪一個欄位。訊息裡清楚點名是數量跟庫存這兩者之間的關係不成立,並且附上當下的庫存數字當參考,使用者一看就知道該把數量往下調,這比單欄位規則常見的「此欄位為必填」這類制式訊息,需要多花一點心思去寫,但對使用者體驗的差異很明顯。

表單擋掉了不合理的輸入,但擋不住儲存之後該發生的事

今天補齊的三類驗證規則放在一起看,訂單明細的數量欄位有了合理的範圍限制,先是最基本的 minValue、maxValue,接著用跨欄位驗證換上真正貼近業務常理的動態上限,客戶聯絡電話欄位有了格式限制,不再讓任意字元存進去。這幾類規則合起來,讓表單不再照單全收任何輸入,開場那兩個手滑就能存檔成功的荒謬例子,數量填負五、電話打一串英文字母,今天之後都會被表單直接擋下。

但這裡要誠實點出驗證規則的作用邊界。

驗證只發生在使用者填寫表單、按下儲存之前的那一刻,一旦這筆訂單明細通過驗證、真正被接受並存進資料庫,理應接著發生的另一件事,這筆訂單明細牽動的商品庫存水位該如何跟著變動,驗證規則完全無能為力。剛才那條跨欄位規則查詢商品當下的庫存數量,只是拿現有的庫存數字做比較,數量填四十件、庫存還有五百件,規則判定合法,放行存檔,但存檔之後,商品的庫存數字不會因此自動變成四百六十件,它還是五百件,跟訂單明細裡剛剛存進去的四十件毫無關係。

這正是 Day 12:把商品、訂單、客戶串起來,第一次像個真正的系統 結尾點名過、至今仍未說明清楚的落差,賣出商品理應影響庫存水位,這句話從系列第一天就寫在那裡,到今天為止還只是文字描述,尚未在示範專案裡真正實現。今天處理的是「填進去的值合不合理」,接下來要處理的是「資料存進去之後,理應連動發生什麼事」,性質完全不同,儲存前後可以掛什麼,庫存異動的自動計算,是接下來要處理的內容。


上一篇
Day 12:把商品、訂單、客戶串起來,第一次像個真正的系統
下一篇
Day 14:儲存前後可以掛什麼,庫存異動的自動計算
系列文
Laravel Filament 從入門到實戰 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言