Utility injection ของ Filament คืออะไร และวิธีที่ Filament ส่ง $get, $set, $state และ $record เข้า closure

เกือบทุกเมธอดของ Filament รับ closure ได้ แล้ว Filament จะส่งค่าเข้าไปให้ตามชื่อและ type ของพารามิเตอร์ ไม่ได้ดูจากลำดับ เข้าใจกลไกนี้แล้วจะรู้ว่าทำไม $s ใช้แทน $state ไม่ได้ ทำไม $record เป็น null ในหน้าสร้าง และทำไม ->state(fn ($state) …) ถึงทำให้ worker ของ PHP ตาย

เผยแพร่
เวลาอ่าน
4 นาที
ในโพสต์นี้ 4 หัวข้อ
  1. Filament อ่านพารามิเตอร์ของ closure แล้วหาค่าให้ทีละตัว
  2. ชื่อของพารามิเตอร์สำคัญกว่าลำดับ
  3. แต่ละพารามิเตอร์มีราคา
  4. กรณีที่เจอได้จริง

ใครเขียน 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 นั้น แล้วหาค่าให้ทีละตัวตามลำดับนี้

  1. ค่าที่เมธอดส่งมาเอง บางเมธอดมีค่าเฉพาะของตัวเอง เช่น formatStateUsing() ส่ง state ที่อ่านมาแล้วเข้ามาตรง ๆ หรือ afterStateUpdated() ส่ง $old มาให้

  2. ชื่อที่ Filament รู้จัก component ของ form และ infolist รู้จักชื่อเหล่านี้

    ชื่อ ได้อะไร
    $get ฟังก์ชันอ่านค่าฟิลด์อื่นในฟอร์ม
    $set ฟังก์ชันตั้งค่าฟิลด์อื่น
    $state ค่าของฟิลด์นี้ หลังผ่าน cast แล้ว
    $rawState ค่าของฟิลด์นี้แบบยังไม่ cast
    $record model ที่กำลังแก้ ถ้ามี
    $model ชื่อ class ของ model
    $operation create, edit หรือ view
    $livewire Livewire component ของหน้านั้น
    $component component ตัวนี้เอง
  3. type ที่ Filament รู้จัก พารามิเตอร์ที่ประกาศ type เป็น Get, Set หรือ class ของ model ของ record ได้ค่าเดียวกับข้างบน แม้จะตั้งชื่ออื่น

  4. service container ของ Laravel ถ้ามี type ที่ไม่ใช่ model Filament จะสร้างจาก container ให้ เช่น Request $request หรือ service class ของเราเอง

  5. ค่า 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 ปี พ.ศ. ได้ครับ

อ่านต่อ

ล่าสุด

ดูทั้งหมด →