ใครเขียน Filament มาสักพักจะคุ้นกับ closure แบบนี้
TextInput::make('discount')
->visible(fn (Get $get): bool => $get('type') === 'member')
->label(fn (?Order $record): string => $record ? 'ส่วนลดเพิ่ม' : 'ส่วนลด')
->afterStateUpdated(fn (Set $set, $state) => $set('total', $state * 2));
เมธอดเกือบทุกตัวของ Filament ทั้ง form, table, infolist และ action รับได้ทั้งค่าตรง ๆ และ closure ถ้าเป็น closure Filament จะเรียกให้ตอนที่ต้องใช้ค่านั้น พร้อมส่งของที่ closure ขอเข้าไปให้ เอกสารของ Filament เรียกกลไกนี้ว่า utility injection (เอกสารฝั่ง form)
โพสต์นี้อธิบายว่า Filament รู้ได้ยังไงว่าจะส่งอะไรให้ ซึ่งช่วยให้เข้าใจ error หลายแบบที่เจอบ่อย อ้างอิงจากโค้ดของ Filament 5.9 (EvaluatesClosures และ Component) ซึ่งเหมือนกับ 4.x
Filament อ่านพารามิเตอร์ของ closure แล้วหาค่าให้ทีละตัว
เมื่อต้องใช้ค่าจาก closure Filament เรียก evaluate() ซึ่งใช้ reflection ของ PHP อ่านรายชื่อพารามิเตอร์ของ closure นั้น แล้วหาค่าให้ทีละตัวตามลำดับนี้
-
ค่าที่เมธอดส่งมาเอง บางเมธอดมีค่าเฉพาะของตัวเอง เช่น
formatStateUsing()ส่งstateที่อ่านมาแล้วเข้ามาตรง ๆ หรือafterStateUpdated()ส่ง$oldมาให้ -
ชื่อที่ Filament รู้จัก component ของ form และ infolist รู้จักชื่อเหล่านี้
ชื่อ ได้อะไร $getฟังก์ชันอ่านค่าฟิลด์อื่นในฟอร์ม $setฟังก์ชันตั้งค่าฟิลด์อื่น $stateค่าของฟิลด์นี้ หลังผ่าน cast แล้ว $rawStateค่าของฟิลด์นี้แบบยังไม่ cast $recordmodel ที่กำลังแก้ ถ้ามี $modelชื่อ class ของ model $operationcreate,editหรือview$livewireLivewire component ของหน้านั้น $componentcomponent ตัวนี้เอง -
type ที่ Filament รู้จัก พารามิเตอร์ที่ประกาศ type เป็น
Get,Setหรือ class ของ model ของ record ได้ค่าเดียวกับข้างบน แม้จะตั้งชื่ออื่น -
service container ของ Laravel ถ้ามี type ที่ไม่ใช่ model Filament จะสร้างจาก container ให้ เช่น
Request $requestหรือ service class ของเราเอง -
ค่า default หรือ null ถ้าพารามิเตอร์มีค่า default หรือรับ null ได้
ถ้าไม่เข้าข้อไหนเลย Filament จะโยน exception ทันที
An attempt was made to evaluate a closure for [Filament\Forms\Components\TextInput], but [$s] was unresolvable.
ชื่อของพารามิเตอร์สำคัญกว่าลำดับ
เพราะ Filament จับคู่ด้วยชื่อ ลำดับของพารามิเตอร์จึงสลับได้ตามใจ fn ($state, Get $get) กับ fn (Get $get, $state) ได้ผลเหมือนกัน แต่ตั้งชื่อเองไม่ได้ fn ($s) ได้ error ข้างบน เพราะ Filament ไม่รู้ว่า $s คืออะไร
ส่วน type ช่วยให้ editor เติมเมธอดให้ และใช้แทนชื่อได้เฉพาะ Get, Set และ model ถ้าเขียน fn (Get $g) ก็ยังได้ฟังก์ชัน get มา แต่เพื่อให้อ่านตรงกับเอกสาร เราใช้ชื่อมาตรฐานควบคู่กับ type เสมอ
$record ควรประกาศให้รับ null ได้ อย่าง ?Order $record เพราะในหน้าสร้างยังไม่มี record ถ้าประกาศเป็น Order $record เฉย ๆ หน้าสร้างจะพังขณะที่หน้าแก้ไขใช้ได้ปกติ
แต่ละพารามิเตอร์มีราคา
Filament ไม่ได้ส่งของทุกอย่างเข้าไปทุกครั้ง จะหาค่าให้เฉพาะพารามิเตอร์ที่ closure ประกาศไว้ และหาด้วยการคำนวณจริง
$stateเรียกgetState()ของ component ซึ่งอ่านค่าแล้วผ่าน cast ทุกตัว$getและ$setสร้าง object ใหม่ให้$recordถามจาก container ของฟอร์ม
closure ที่ประกาศพารามิเตอร์ไว้แต่ไม่ได้ใช้ จึงเสียแรงไปเปล่า ๆ และฟอร์มที่ใช้ live() ให้ Livewire render ใหม่ระหว่างผู้ใช้กรอก ก็เรียก closure เหล่านี้ใหม่ทุกรอบ ใส่เฉพาะตัวที่ใช้จริง ซึ่งพาไปถึงกรณีที่อันตรายที่สุดในหัวข้อถัดไป
กรณีที่เจอได้จริง
->state(fn ($state) …) ทำให้ worker ตายโดยไม่มี log ->state() ของ infolist entry ตั้ง closure ให้เป็นตัวคำนวณ state ของ component นั้น พอ closure ขอ $state Filament ก็เรียก getState() เพื่อหาค่าให้ ซึ่งก็คือเรียก closure ตัวเดิมอีกรอบ วนไม่จบ
// เดิม ขอ $state ซึ่งก็คือผลของ closure ตัวนี้เอง
TextEntry::make('redeem_period')
->state(function ($state, $component, Get $get) { /* คำนวณจาก $get() */ })
// แก้ ขอเฉพาะสิ่งที่ใช้จริง
TextEntry::make('redeem_period')
->state(fn (Get $get) => /* คำนวณจาก $get() */)
ระบบหลังบ้านของลูกค้ารายหนึ่งที่เราดูแลเจอแบบนี้ระหว่างอัปเกรด Filament เวอร์ชันใหญ่ recursion วิ่งผ่านโค้ดภายในของ PHP จน stack ล้น worker ของ PHP-FPM ตายทันทีแบบ segfault ผู้ใช้ได้ 500 แต่ laravel.log ว่างเปล่า มีแค่บรรทัด Connection reset by peer ใน error log ของ nginx และ test ของ Livewire ที่ทำขั้นเดียวกันจบด้วย exit code 139 (SIGSEGV) ที่น่าสังเกตคือโค้ดใน closure ไม่ได้ใช้ $state เลย
->formatStateUsing(fn ($state) => …) ไม่มีปัญหานี้ เพราะเมธอดนี้ส่ง state ที่อ่านเสร็จแล้วเข้ามาเอง (ข้อ 1) Filament จึงไม่ต้องเรียก getState() ถ้าอยากหาจุดเสี่ยงในโปรเจกต์ ค้นบรรทัดที่มี ->state( และ $state อยู่ด้วยกัน แล้วดูทีละที่
$get ใน Repeater อ่านจากแถวของตัวเอง ภายใน Repeater $get('price') อ่านฟิลด์ในแถวเดียวกัน ถ้าจะอ่านฟิลด์นอก Repeater ต้องถอยขึ้นด้วย ../ ตามเอกสารของ Repeater อย่าง $get('../../customer_id') ถ้าได้ null ทั้งที่ฟิลด์มีค่า มักเป็นเรื่อง path
ทั้งหมดนี้มาจากกลไกเดียว คือ Filament ดูชื่อและ type ของพารามิเตอร์แล้วคำนวณค่าให้จริงทุกครั้ง เขียนให้ชื่อตรงกับเอกสาร ประกาศ type ไว้ และขอเฉพาะสิ่งที่ใช้ก็ไม่ต้องเจอเรื่องข้างบน ส่วนเรื่องวันที่ภาษาไทยในฟอร์ม Filament ลองอ่านต่อที่ DatePicker ปี พ.ศ. ได้ครับ






