Day 08:Action 是什麼,表格列、頁首、表單內三種掛載位置 結尾留下一句話,商品表單裡目前七個既有欄位,加上圖片欄位,說到底都是商品自己身上的屬性,價格是商品的價格,庫存是商品的庫存,沒有一個欄位牽涉到別的模組。
但一套真正會上線的後台系統,遲早要處理「這筆資料跟另一個模組的資料有關聯」的情境。今天要處理的正是這件事。
回頭看 Day 01:為什麼是 Filament,跟手刻後台與其他套件比起來差在哪 所說的業務邏輯,這間批發商的客戶是各地的寵物用品店與寵物協會,訂單量不算巨大但頻率高,商品會出現在訂單裡。這幾句話從第一天就寫在那裡。
但商品之外的兩個模組,客戶跟訂單,到今天為止都還只是文字描述,沒有任何 Model,也沒有任何 Resource。
這八天走過來,商品模組已經被反覆打磨過,包含欄位型別、版面配置、表格呈現、篩選排序、操作按鈕,一路加深到 Day 8 收尾。但整個示範專案如果只有商品,說到底還是一個孤島,一間批發商真正經營的日常,不會只是管理一份商品清單,而是不斷有客戶下訂單、訂單牽動商品與庫存。今天的任務,是先讓客戶模組從無到有建立起來,再讓訂單模組登場,並在訂單表單裡示範怎麼選擇客戶、怎麼選擇商品。
這是全系列第一次處理「表單裡選另一個模組的資料」這件事,也是接下來四天資料關聯階段要一路展開的起點。
在動手做訂單之前,得先確認一件事:訂單如果沒有客戶可選,這張訂單就沒有意義。這個判斷邏輯跟 Day 3 讓商品先於其他模組動手的理由如出一轍,商品幾乎不欠任何人,卻被其他模組欠著;客戶同樣不欠任何人,卻是訂單成立的前提。
所以順序很清楚,客戶要先於訂單存在。
客戶模組是本篇新增的模組,這邊欄位設計刻意保持精簡,只服務今天到 Day 12 這四天的關聯示範需求,不做成一套完整的客戶關係管理系統。對應寵物用品店與寵物協會這類機構型客戶的業務語意,決定以下四個欄位:
name,店名或協會名稱,例如「旺旺寵物用品行」或「台北市寵物協會」。contact_person,聯絡人。phone,聯絡電話。region,所屬地區。建立順序沿用 Day 3 已經走過的路徑,先確認 Model 跟 Migration 存在,Resource 才有地基可蓋:
php artisan make:model Customer -m
Schema::create('customers', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('contact_person')->nullable();
$table->string('phone')->nullable();
$table->string('region')->nullable();
$table->timestamps();
});
跑過 php artisan migrate,資料表就真實存在了,接著才是今天要生成的第一個新 Resource:
php artisan make:filament-resource Customer --view
欄位型別的選擇沿用 Day 4 已經建立的判斷邏輯,不重新推導一次。店名、聯絡人、聯絡電話都是單行、篇幅短的文字,用 TextInput 處理;所屬地區考慮到批發商實際服務的地區範圍有限,這裡選擇用 Select 列出固定的縣市清單,理由跟 Day 4 商品分類欄位一致,可能值是一個有限且明確的集合,不需要讓使用者自己打字:
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
TextInput::make('name')
->label('店名或協會名稱')
->required()
->maxLength(255),
TextInput::make('contact_person')
->label('聯絡人'),
TextInput::make('phone')
->label('聯絡電話')
->tel(),
Select::make('region')
->label('所屬地區')
->options([
'north' => '北部',
'central' => '中部',
'south' => '南部',
'east' => '東部',
]),
客戶模組今天的任務僅止於讓它存在並可操作,列表頁的格式化、篩選這類進階呈現技巧,Day 6、Day 7 已經在商品身上示範過同樣的做法,可以直接類推,這裡不重複做一次。指令跑完,重新整理後台,側邊選單會多出客戶這個項目,這是整個系列第一次,客戶不再只是業務語意裡的一句描述,而是螢幕上可以點開、可以新增一筆資料的真實介面。
客戶有了,訂單才輪得到登場。訂單模組同樣是本篇新增,欄位設計呼應「訂單量不大但頻率高」這句業務語意,不做成含出貨、金流等完整流程的訂單系統,先聚焦在最基本的四個欄位:
order_number,訂單編號,用來識別這是哪一筆訂單。customer_id,所屬客戶,這是今天的關聯主戲。status,訂單狀態。ordered_at,訂單日期。Model 跟 Migration 一樣先確認:
php artisan make:model Order -m
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('order_number')->unique();
$table->foreignId('customer_id')->constrained();
$table->string('status')->default('pending');
$table->date('ordered_at');
$table->timestamps();
});
這裡值得多看一下 foreignId('customer_id')->constrained() 這一行,這正是訂單跟客戶之間關聯的資料庫層定義,一個客戶可以有多筆訂單,一筆訂單只屬於一個客戶,對應到 Eloquent 就是熟悉的 belongsTo、hasMany 這組既有概念:
// Order.php
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
// Customer.php
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
這段程式碼本身沒有任何新東西,讀者對 Eloquent 關聯的熟悉程度早就足以看懂它在做什麼。Filament 表單接下來要做的事情,是把這層既有的關聯定義包裝成使用者能在畫面上操作的欄位,這正是今天真正要拆解的核心。
生成訂單的 Resource:
php artisan make:filament-resource Order --view
有一件事要先講清楚,訂單今天先當成一筆獨立資料處理,只處理「這筆訂單屬於哪個客戶」這一層關聯。一張訂單底下有哪些商品、各自買了多少數量,這種一對多的訂單明細情境,今天不展開,留給後面才處理。
訂單表單裡要放進去的第一個欄位,是選擇客戶。視覺上它看起來跟 Day 4 用過的商品分類下拉選單長得一模一樣,都是點下去彈出一份清單,選一項,關起來。但這兩者背後的運作機制完全是兩件事。
先看程式碼怎麼寫:
use Filament\Forms\Components\Select;
Select::make('customer_id')
->label('所屬客戶')
->relationship('customer', 'name')
->searchable()
->required(),
同樣是 Select,但這裡多了一行 relationship('customer', 'name'),這一行正是關聯欄位跟固定選項欄位分道揚鑣的地方。回頭對照 Day 4 商品分類欄位的寫法:
Select::make('category')
->options([
'food' => '寵物飼料',
'toy' => '寵物玩具',
'cleaning' => '清潔用品',
]),
商品分類的選項是寫死在 options() 陣列裡的固定清單,這份清單什麼時候會變,取決於哪一天你回來改程式碼。訂單選客戶的欄位完全不是這樣運作,relationship('customer', 'name') 告訴 Filament 兩件事,第一件是透過剛才定義的 customer 這個 Eloquent 關聯,即時查詢客戶資料表,把目前所有客戶都撈出來當作選項;第二件是選項顯示給使用者看的文字,用客戶的 name 欄位,也就是店名或協會名稱。客戶資料表裡今天新增一筆客戶,明天再打開訂單表單,這份下拉選單裡就會多出一個選項,不需要改任何一行程式碼。
如果借用一個比喻,固定選項的下拉選單更像是一份印好的問卷,題目跟選項在印刷的那一刻就固定了,之後怎麼填都不會多出新選項。關聯欄位的下拉選單則像是即時翻著另一本名冊找人,名冊本身隨時可能多一筆、少一筆,你翻開的永遠是當下最新的那一頁。
儲存的值同樣是兩套邏輯。商品分類欄位存進資料庫的是 food、toy 這類選項本身定義的字串,使用者選了「寵物飼料」,資料庫裡躺著的是 food 這個字。關聯欄位存進去的則是那一筆客戶資料的主鍵,使用者在畫面上看到的是「旺旺寵物用品行」這個有意義的名稱,但實際寫進 customer_id 這個外鍵欄位的,是這間店在客戶資料表裡的 id,例如 7。畫面上顯示的文字跟資料庫裡儲存的值,在關聯欄位這裡第一次徹底分裂成兩件事,relationship() 的第二個參數,name,管的正是顯示給人看的部分,實際存進資料庫的主鍵,則是 Filament 自動處理,不需要額外指定。
還有一個細節值得補上,searchable() 這個方法。客戶筆數還只有個位數的時候,把所有客戶一次列出來讓使用者慢慢找,感覺不出什麼問題,但客戶累積到幾十家、上百家批發往來對象之後,一次全部塞進下拉選單,使用者得像大海撈針一樣往下滑找。
searchable() 讓這個欄位變成打幾個字就能即時篩選比對客戶名稱的搜尋型選單,這其實是 Day 07:讓大量資料可用,篩選、排序與搜尋 已經處理過的那個認知,資料量變多之後需要搜尋機制,延伸套用到關聯欄位上的具體體現。

客戶的關聯欄位處理完之後,訂單表單還缺一個東西,這張訂單究竟牽涉到哪個商品。做法跟選客戶完全相同,差別只在於查詢的對象換成商品資料表:
Select::make('product_id')
->label('商品')
->relationship('product', 'name')
->searchable()
->required(),
選項顯示商品名稱,實際儲存的是商品主鍵,這套邏輯剛才已經拆解過一次,這裡不再重複解釋,能夠直接套用,正是關聯欄位機制一旦搞懂就可以到處複用的地方,不需要每換一個關聯對象就重新學一次。
這裡要誠實點出一個目前刻意先不解決的限制。今天的訂單表單只能選一個商品,但業務常理上,一張訂單通常包含好幾個商品,各自不同的數量,寵物用品店叫貨通常不會只叫一種東西。這個限制不是技術做不到,而是今天的任務範圍刻意先聚焦在單一關聯欄位的選擇機制上。一張訂單底下有多筆商品明細,是一對多的巢狀關聯情境,跟今天處理的「訂單選一個客戶」「訂單選一個商品」這種單一欄位的關聯性質不一樣,需要另一種更適合管理巢狀資料的方式,這件事目前先留著,之後會有專門的篇幅處理。
今天做的事情比想像中多一些。客戶模組從無到有建立起來,訂單模組跟著登場,訂單表單裡現在能選客戶、選商品,這兩個關聯欄位背後查的是另一張資料表,存進資料庫的是主鍵而不是文字,這跟 Day 4 那種選項寫死在程式碼裡的固定下拉選單,是完全不同的兩套機制。
但訂單建立完成後,回到訂單列表頁看一眼,會發現一個新的落差出現。
目前訂單表格上如果顯示客戶欄位,看到的只會是一串客戶主鍵的數字,7、12、3,使用者完全看不出這張訂單屬於哪個客戶。這個問題在表單端已經處理好了,選客戶的時候看到的是店名,但表格端還完全沒有處理。
怎麼在表格裡顯示關聯資料,讓我們一眼看出這張訂單屬於哪個客戶,是接下來要處理的內容。