Lifecycle State Field
Setiap value field melakukan perjalanan yang sama dua kali: pertama dari record menuju control, lalu dari request kembali menuju record. Ada lima hook di sepanjang perjalanan tersebut. Gunakan hook-hook ini ketika field pada form dan column database tidak memiliki nama, type, atau bentuk value yang sama. Halaman ini menjelaskan urutan hook dan cara menggunakannya. Hydration and dehydration membahas konversi value untuk setiap field type.
Contoh minimal
Toggle verified yang membaca dan menulis timestamp email_verified_at:
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Date;
use PandaPanel\Forms\Components\Toggle;
Toggle::make('verified')
->label('Email verified')
->formatUsing(static fn (mixed $value, ?Model $record): bool => $record instanceof User
&& $record->email_verified_at !== null)
->dehydrateTo('email_verified_at')
->mutateUsing(static fn (mixed $value, ?Model $record): mixed => $value === true
? ($record?->email_verified_at ?? Date::now())
: null);2
3
4
5
6
7
8
9
10
11
12
13
Tiga deklarasi tersebut menjawab tiga pertanyaan berbeda: apa yang ditampilkan control, ke column mana value akan ditulis, dan dalam bentuk apa value tersebut ditulis.
Perjalanan value masuk ke form
Field::formValue(?Model $record): mixed menjalankan seluruh proses hydration dengan urutan berikut:
- Read.
$record === null ? $this->default : data_get($record, $this->name). Pada create page tidak ada record, sehingga value berasal daridefault(). - Shape. Jalankan
formatUsing()jika field mendeklarasikannya. Jika tidak, gunakancastForForm()bawaan field type. - Observe. Jalankan
afterStateHydrated(). Return value dari hook ini diabaikan.
formatUsing() menggantikan castForForm(), bukan berjalan setelahnya. Karena itu DatePicker yang memiliki formatUsing() harus sendiri menghasilkan string Y-m-d yang dipahami control.
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Forms\Components\TextInput;
TextInput::make('name')
->default('Untitled')
->formatUsing(static fn (mixed $value, ?Model $record): string => mb_strtoupper((string) $value))
->afterStateHydrated(static function (mixed $value, ?Model $record): void {
logger()->debug('name hydrated', ['value' => $value]);
});2
3
4
5
6
7
8
9
Perjalanan value keluar dari form
FormSchema::dehydrate(array $validated, ?Model $record = null): array berjalan melalui visible fields dan menanyakan beberapa hal secara berurutan. Jawaban “tidak” pada salah satu tahap membuat field dilewati secara silent:
| Langkah | Diperiksa pada | Field dilewati ketika |
|---|---|---|
| 1 | Field::isHiddenOn() | Field tidak visible pada Page ini |
| 2 | Field::isDehydrated($record) | dehydrated(false) |
| 3 | Relationship groups | Field berada di dalam Relationship dan akan ditulis setelah owner |
| 4 | $validated | Key tidak tersedia pada validated data |
| 5 | Field::shouldDehydrate($value) | dehydrateWhen() mengembalikan false |
| 6 | Select::writesToPivot() | Field adalah many-to-many Select dan akan di-sync setelah owner tersimpan |
| 7 | — | Jika lolos seluruh tahap, $attributes[$key] = $field->mutate($value, $record) |
Key tujuan berasal dari Field::getDehydrateKey()—hasil dehydrateTo() atau nama field—kecuali BelongsTo Select yang menggunakan foreign key relation.
Lima hook state field
| Hook | Signature | Dijalankan | Return |
|---|---|---|---|
formatUsing() | Closure(mixed $value, ?Model $record): mixed | Saat hydration, menggantikan type cast bawaan | Value yang dipakai control |
afterStateHydrated() | Closure(mixed $value, ?Model $record): mixed | Setelah hydrated value selesai dibentuk | Diabaikan |
afterStateUpdated() | Closure(mixed $new, mixed $old, ?Model $record): void | Saat field live() berubah | Tidak ada |
dehydrateStateUsing() | Closure(mixed $value, ?Model $record): mixed | Saat menuju record | Value yang akan ditulis |
mutateUsing() | Closure(mixed $value, ?Model $record): mixed | Saat menuju record | Value yang akan ditulis |
dehydrateStateUsing() dan mutateUsing() adalah konsep yang sama dengan dua nama—nama yang familiar dari Filament dan nama framework ini. Keduanya menggunakan implementation yang sama; jika keduanya dideklarasikan, dehydrateStateUsing() memiliki prioritas.
formatUsing()
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Forms\Components\TagsInput;
// Column menyimpan "a,b,c" tetapi form menampilkan tiga tag.
TagsInput::make('keywords')->formatUsing(
static fn (mixed $value, ?Model $record): array => is_string($value)
? array_values(array_filter(explode(',', $value)))
: [],
);2
3
4
5
6
7
8
9
afterStateHydrated()
Hook ini adalah observer, bukan transformer. Return value diabaikan, sehingga hook yang digunakan untuk side effect tidak dapat secara tidak sengaja mengosongkan field hanya karena tidak mengembalikan sesuatu.
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Forms\Components\TextInput;
TextInput::make('name')->afterStateHydrated(
static function (mixed $value, ?Model $record): void {
// Hanya side effect. Gunakan formatUsing() untuk mengubah value.
},
);2
3
4
5
6
7
8
afterStateUpdated()
Hook ini hanya berjalan untuk field yang mendeklarasikan live(), apa pun yang diklaim request sebagai field yang berubah, dan hanya melalui endpoint form-state. Lihat Live fields.
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Forms\Components\Select;
Select::make('country')
->options(['id' => 'Indonesia', 'sg' => 'Singapore'])
->live()
->afterStateUpdated(
static function (mixed $new, mixed $old, ?Model $record): void {
// Untuk side effect atau menentukan bagaimana field lain dibangun ulang.
},
);2
3
4
5
6
7
8
9
10
11
Untuk test, Anda dapat memanggil Field::handleStateUpdated(mixed $state, mixed $previous, ?Model $record = null): void secara langsung. Method tersebut menjalankan hook tanpa memeriksa apakah value benar-benar berubah karena caller sudah menentukan bahwa perubahan memang terjadi.
dehydrateStateUsing() dan mutateUsing()
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
use PandaPanel\Forms\Components\TextInput;
TextInput::make('name')->dehydrateStateUsing(
static fn (mixed $value, ?Model $record): string => mb_strtoupper((string) $value),
);
TextInput::make('api_token')->mutateUsing(
static fn (mixed $value, ?Model $record): string => Hash::make((string) $value),
);2
3
4
5
6
7
8
9
10
11
Menentukan apakah sebuah value ditulis
Ada tiga declaration berbeda karena masing-masing menjawab pertanyaan berbeda.
| Method | Signature | Pertanyaan |
|---|---|---|
dehydrated() | dehydrated(Closure(?Model): bool|bool $condition = true): static | Apakah field ini boleh mencapai record sama sekali? |
dehydrateWhen() | dehydrateWhen(Closure(mixed): bool $callback): static | Apakah value ini boleh mencapai record? |
dehydrateTo() | dehydrateTo(string $attribute): static | Attribute mana yang menjadi tujuan write? |
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Forms\Components\Checkbox;
use PandaPanel\Forms\Components\PasswordInput;
use PandaPanel\Forms\Components\TextInput;
// Dirender dan divalidasi, tetapi tidak pernah ditulis.
Checkbox::make('accepted_terms')->required()->dehydrated(false);
// Hanya ditulis pada create.
TextInput::make('reference')->dehydrated(
static fn (?Model $record): bool => $record === null,
);
// Hanya ditulis jika user benar-benar mengetik value.
// Ini sama dengan behavior PasswordInput::optionalWhenFilled().
PasswordInput::make('password')
->required(false)
->dehydrateWhen(static fn (mixed $value): bool => is_string($value) && $value !== '');
// Ditulis ke column berbeda.
TextInput::make('slug')->dehydrateTo('url_slug');2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
dehydrated(false) dan validation adalah concern terpisah. Sebuah field dapat required dan tetap tidak pernah ditulis ke database.
Posisi Page lifecycle hooks
PandaPanel\Resources\Concerns\HasLifecycleHooks membungkus field lifecycle menggunakan Page-level hooks. Pada create page, urutannya:
beforeFill()
FormSchema::toArray() ← formatUsing, afterStateHydrated berjalan di sini
mutateFormDataBeforeFill($data)
afterFill($data)
--- user mengisi form lalu submit ---
beforeValidate($input)
validator($input, $schema->validationRules())->validate()
afterValidate($data)
beforeCreate()
mutateFormDataBeforeCreate($data)
mutateFormDataBeforeSave($data, null)
beforeSave(null)
FormSchema::dehydrate($data) ← dehydrateWhen, mutateUsing berjalan di sini
handleRecordCreation($attributes)
FormSchema::saveRelations($record, $data)
afterCreate($record)
afterSave($record)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Edit page menggunakan urutan yang sama tanpa beforeCreate(), mutateFormDataBeforeCreate(), dan afterCreate(), serta menggunakan handleRecordUpdate() menggantikan handleRecordCreation(). Jika $hasDatabaseTransactions aktif, owner write, relation writes, dan after-hooks berada dalam transaction yang sama.
Perbedaan yang perlu dipertahankan: field hooks bekerja terhadap satu value dan mengetahui konteks field; Page hooks bekerja terhadap seluruh array dan mengetahui request. Jika logic membutuhkan record sekaligus value field tertentu, gunakan field hook.
Catatan
formatUsing()menggantikan type cast bawaan.DatePicker,DateTimePicker, atauTimePickerdengan format hook harus menghasilkan string yang memang dipahami control masing-masing.- Return value
afterStateHydrated()diabaikan. Hook yang menghitung value tetapi tidak menggunakanformatUsing()akan terlihat seperti tidak bekerja. afterStateUpdated()tidak pernah berjalan saat submit. Hook ini hanya milik endpointform-state, yang tidak melakukan validation maupun write.PasswordInput::formValue()selalunull. Stored hash tidak pernah dirender kembali menjadi form value.FileUploadmengoverridemutate(). Path yang tidak mungkin dihasilkan field dibuang sebelum custommutateUsing()menerima value.RichEditorjuga mengoverridemutate(), dengan melakukan sanitization sebelum value mencapai record agar semua pembacaan stored HTML berikutnya lebih aman.RepeaterdanBuildermen-dehydrate child fields di dalammutate(), sehingga key yang tidak pernah dideklarasikan sub-schema dibuang seperti pada top-level form.