Dasar FormSchema
PandaPanel\Forms\FormSchema adalah deskripsi deklaratif sebuah form: apa yang dirender, apa yang divalidasi, dan apa yang dipersist. Gunakan FormSchema setiap kali resource membutuhkan create/edit page, action membutuhkan dialog berisi input, relation manager membutuhkan form, atau widget membutuhkan filter. Semua context tersebut membangun object yang sama, sehingga seluruh aturan pada halaman ini berlaku untuk semuanya.
Form minimal
Sebuah Resource mendeklarasikan form seperti berikut:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Posts;
use App\Models\Post;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Components\Textarea;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Resources\Resource;
final class PostResource extends Resource
{
protected static string $model = Post::class;
public static function form(FormSchema $schema): FormSchema
{
return $schema
->columns(2)
->schema([
TextInput::make('title')->required()->maxLength(255),
Textarea::make('excerpt')->rows(3)->columnSpanFull(),
]);
}
// table() dan pages() tidak ditampilkan
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Schema yang diterima form() sudah membawa model class dan informasi Page yang sedang dibangun. Method form() hanya perlu menambahkan components lalu mengembalikannya. Itu sudah cukup agar /admin/posts/create dapat dirender, divalidasi, dan disimpan.
Tiga concern yang dipisahkan
Setiap field mendeklarasikan tiga hal berbeda: bagaimana field dirender, bagaimana field divalidasi, dan apakah value-nya dipersist. Pemisahan inilah yang membuat behavior password field dapat dibuat aman:
use PandaPanel\Forms\Components\PasswordInput;
PasswordInput::make('password')
->confirmed()
->rules(['min:8'])
->when(
$schema->getPage() === 'create',
static fn (PasswordInput $field): PasswordInput => $field->required(),
static fn (PasswordInput $field): PasswordInput => $field->optionalWhenFilled(),
);2
3
4
5
6
7
8
9
10
Pada create, password required. Pada edit, password optional tetapi tetap divalidasi ketika diisi, dan tidak dipersist jika kosong. Dengan demikian stored hash tidak pernah tertimpa empty string.
Validation menggunakan Laravel. Marker required di frontend hanya UX; menghapusnya di browser tidak mengubah server rule. Hanya field yang dideklarasikan yang divalidasi, dan hanya field yang di-dehydrate yang dipersist. Extra key pada request dibuang, bukan langsung di-mass-assign.
FormSchema, method per method
FormSchema bersifat final. Setiap method mengembalikan $this kecuali return type menyatakan lain.
| Method | Signature | Fungsi |
|---|---|---|
make() | static make(): self | Membuat schema kosong baru. Satu column, Page create, tanpa model |
schema() | schema(array $components): self | Mengganti top-level components dan melakukan re-index menggunakan array_values() |
columns() | columns(int $columns): self | Membagi root grid. Di-clamp 1–4 |
model() | model(string $modelClass): self | Eloquent class yang digunakan relation-backed field untuk resolution |
forPage() | forPage(string $page): self | Menentukan Page yang sedang dibangun: 'create', 'edit', atau custom key |
getPage() | getPage(): string | Mengembalikan Page. Default 'create' |
getModelClass() | getModelClass(): ?string | Model class, atau null jika belum diset |
fields() | fields(?Model $record = null): array | Seluruh Field yang visible pada Page aktif, di-flatten dari layouts |
getComponents() | getComponents(): array | Top-level components, berguna ketika caller ingin menggabungkan dua schema |
field() | field(string $name): ?Field | Satu visible field berdasarkan nama, atau null |
wizard() | wizard(): ?Wizard | Wizard milik form jika ada |
validationRules() | validationRules(?Model $record = null): array | Seluruh Laravel validation rules |
validationRulesForStep() | validationRulesForStep(int $step, ?Model $record = null): array | Rules yang hanya milik satu Wizard step |
relationshipGroups() | relationshipGroups(): array | Seluruh layout Relationship di tree |
dehydrate() | dehydrate(array $validated, ?Model $record = null): array | Mengubah validated input menjadi attributes untuk write |
saveRelations() | saveRelations(Model $record, array $validated): void | Menulis related records dan pivot rows setelah owner record tersedia |
toArray() | toArray(?Model $record = null): array | ['columns' => int, 'schema' => list<array>], yaitu payload yang dikirim ke frontend |
toArrayWithState() | toArrayWithState(?Model $record, array $state): array | Sama seperti toArray(), tetapi submitted state menimpa field values |
Jika digunakan di luar Resource Page, seluruh siklus dasarnya tetap sederhana:
use App\Models\Post;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\FormSchema;
$schema = FormSchema::make()
->model(Post::class)
->forPage('create')
->schema([TextInput::make('title')->required()]);
$rules = $schema->validationRules(); // ['title' => ['required', 'string', 'max:255']]
$data = validator(request()->all(), $rules)->validate();
$attributes = $schema->dehydrate($data); // ['title' => 'Hello']
$post = Post::query()->create($attributes);
$schema->saveRelations($post, $data);2
3
4
5
6
7
8
9
10
11
12
13
14
15
toArray() menghasilkan struktur yang dikirim Page ke Inertia:
$form = $schema->toArray($record);
// ['columns' => 2, 'schema' => [['component' => 'field', 'name' => 'title', ...]]]2
__call() memberi pesan yang lebih berguna saat method dipanggil pada object yang salah
Jika developer memanggil method milik field pada schema, framework melempar BadMethodCallException yang menjelaskan kesalahan tersebut:
FormSchema::make()->columnSpanFull();
// columnSpanFull() belongs to a field, not to the form schema. Move it onto
// the component you meant: …2
3
Method yang diterjemahkan secara khusus adalah columnSpan, columnSpanFull, hidden, visible, required, dan disabled. Method lain tetap menghasilkan pesan standar “Call to undefined method”, karena memberikan saran yang hanya berupa tebakan lebih buruk daripada tidak memberi saran.
Katalog field
Semua field extends PandaPanel\Forms\Components\Field dan dibuat melalui Field::make(string $name). Nama field menjadi request key, validation rule key, dan—kecuali dehydrateTo() mengatakan lain—nama column tujuan.
| Class | FieldType | Bentuk value | Dokumentasi |
|---|---|---|---|
TextInput | text | ?string | Text |
Textarea | textarea | ?string | Text |
PasswordInput | password | ?string, tidak pernah dikirim kembali | Text |
NumberInput | number | int|float|null | Number |
HiddenInput | hidden | tidak diubah | Disabled and hidden |
Slider | slider | float|int|null | Slider |
ColorPicker | color_picker | ?string | Color |
TagsInput | tags_input | list<string> | Tags |
KeyValue | key_value | array<string, string> | Key value |
Checkbox | checkbox | bool | Checkbox |
Toggle | toggle | bool | Toggle |
Select | select | scalar atau list<string> | Select |
Radio | radio | string|int|null | Radio |
CheckboxList | checkbox_list | list<string> | Checkbox |
ToggleButtons | toggle_buttons | scalar atau list<string> | Toggle |
DatePicker | date | ?string (Y-m-d) | Date |
DateTimePicker | datetime | ?string (Y-m-d\TH:i) | Date |
TimePicker | time | ?string (H:i) | Date |
RichEditor | rich_editor | ?string HTML, sudah disanitasi | Rich editor |
MarkdownEditor | markdown_editor | ?string | Markdown |
CodeEditor | code_editor | ?string | Code editor |
FileUpload | file_upload | path, atau list path | File uploads |
Repeater | repeater | list<array> | Repeater |
Builder | builder | list<array{type, data}> | Builder |
CustomField | custom | apa pun yang di-emit custom component | Custom fields |
Kemampuan yang dimiliki semua field
API berikut berada pada Field sehingga tersedia pada semua field type di atas.
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Enums\ConditionOperator;
TextInput::make('slug')
->label('URL slug') // label(string): static
->placeholder('hello-world') // placeholder(string): static
->helperText('Lowercase, no spaces') // helperText(string): static
->required() // required(bool = true): static
->disabled(false) // disabled(bool = true): static
->default('hello-world') // default(mixed): static
->columnSpan(2) // columnSpan(int): static
->inlineLabel() // inlineLabel(bool = true): static
->rules(['alpha_dash']) // rules(list<mixed>): static
->rulesUsing(static fn (?Model $record): array => [])
->hiddenOn(['create']) // hiddenOn(list<string>): static
->visibleOn(['edit']) // visibleOn(list<string>): static
->disabledOn(['edit']) // disabledOn(list<string>): static
->visible(static fn (?Model $record): bool => true)
->hidden(false) // hidden(Closure|bool = true): static
->visibleWhen('kind', ConditionOperator::Equals, 'page')
->hiddenWhen('locked') // hiddenWhen(string, ConditionOperator = Truthy, mixed = null)
->live(onBlur: true, debounce: 750) // live(bool = false, ?int = null): static
->formatUsing(static fn (mixed $value, ?Model $record): mixed => $value)
->afterStateHydrated(static function (mixed $value, ?Model $record): void {})
->afterStateUpdated(static function (mixed $new, mixed $old, ?Model $record): void {})
->dehydrateStateUsing(static fn (mixed $value, ?Model $record): mixed => $value)
->mutateUsing(static fn (mixed $value, ?Model $record): mixed => $value)
->dehydrateWhen(static fn (mixed $value): bool => $value !== '')
->dehydrated(true) // dehydrated(Closure|bool = true): static
->dehydrateTo('url_slug'); // dehydrateTo(string): static2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Field juga menggunakan trait Illuminate\Support\Traits\Conditionable, sehingga when() dan unless() dapat digunakan untuk configuration berdasarkan Page tanpa memutus fluent chain dengan if statement.
Reader yang biasa digunakan Page atau endpoint:
| Method | Return | Catatan |
|---|---|---|
type() | FieldType | Discriminator yang digunakan frontend untuk memilih renderer |
getName() | string | Diberi prefix relation saat field berada di dalam Relationship |
getAttribute() | string | Nama attribute asli tanpa relation prefix |
getLabel() | string | Str::headline($name) jika tidak ada custom label |
getDehydrateKey() | string | Hasil dehydrateTo(), atau nama field |
isHiddenOn(string $page, ?Model $record = null) | bool | Server-side visibility dari seluruh sumber condition |
isDisabledOn(string $page, ?Model $record = null) | bool | Status disabled |
matchesConditions(array $state) | bool | Jawaban server terhadap browser-side conditions |
isLive() | bool | Apakah field live |
isDehydrated(?Model $record = null) | bool | Apakah field diikutkan dalam dehydration |
shouldDehydrate(mixed $value) | bool | Hasil dari dehydrateWhen() |
formValue(?Model $record) | mixed | Value untuk hydration form |
mutate(mixed $value, ?Model $record) | mixed | Value yang sedang menuju record |
validationRules(?Model $record) | list<mixed> | Rules untuk field |
elementRules() | list<mixed> | Rules untuk field.*; kosong untuk scalar field |
nestedRules(?Model $record = null) | array<string, list<mixed>> | Misalnya items.*.title milik Repeater |
fields() | list<Field> | Field itu sendiri, kecuali pada container |
toArray(?Model $record, string $page) | ?array | null ketika field hidden pada Page tersebut |
Layout
Container membagi sebuah row, sedangkan field menentukan berapa bagian yang digunakannya.
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Components\Textarea;
use PandaPanel\Forms\Layouts\Section;
Section::make('Details')
->columns(3)
->schema([
TextInput::make('first_name'), // satu column
TextInput::make('last_name'),
TextInput::make('title')->columnSpan(2),
Textarea::make('bio')->columnSpanFull(), // seluruh row
]);2
3
4
5
6
7
8
9
10
11
12
Gunakan columnSpanFull() alih-alih columnSpan(3) untuk arti “pakai seluruh row”. Jumlah column adalah concern container. Jika suatu hari Section diubah menjadi empat column, field yang hard-code 3 akan diam-diam hanya menggunakan tiga perempat row. columnSpanFull() dikirim sebagai string 'full' dan dirender menjadi col-span-full.
Column count bersifat responsive. Value yang dideklarasikan adalah count pada layar lebar:
columns(n) | base | md (768px) | lg (1024px) |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 2 | 1 | 2 | 2 |
| 3 | 1 | 2 | 3 |
| 4 | 1 | 2 | 4 |
Span di-clamp terhadap tabel tersebut secara terpisah di setiap breakpoint. columnSpan(3) di dalam columns(4) berarti dua column pada md dan tiga pada lg. Count di atas empat di-clamp ke empat oleh PandaPanel\Support\ColumnCount::clamp() karena resources/js/panel/lib/grid.ts memakai literal Tailwind classes untuk satu sampai empat column. Dynamic grid-cols-${n} tidak dapat diandalkan dalam hasil compile.
Span hanya tersedia pada field dan infolist entries. Memanggil span pada schema menghasilkan error khusus __call() di atas. Memanggilnya pada layout menghasilkan error standar PHP karena hanya FormSchema yang menerjemahkan kesalahan tersebut. Layout sendiri sudah menempati seluruh row di tempat ia berada.
Container dijelaskan lengkap pada Layouts: Section, Grid, Tabs/Tab, Wizard/Step, Callout, EmptyState, Relationship, dan CustomComponent.
Dari mana sebuah schema berasal
| Caller | Cara membangun schema |
|---|---|
| Resource create/edit pages | Resource::form(FormSchema $schema), dengan model() dan forPage() sudah diterapkan |
| Actions | Action::schema(Closure $callback) — Closure(?Model): FormSchema di-resolve per record |
| Relation managers | RelationManager::form(FormSchema $schema, Model $owner), digabung dengan pivot schema oleh RelationForm |
| Widgets | Widget::filterSchema(): ?FormSchema |
| Standalone pages | Page::filterSchema(): ?FormSchema |
Page context penting. forPage('edit') membuat hiddenOn(['edit']) bekerja, dan getPage() memungkinkan schema melakukan branching tanpa Page harus dideklarasikan dua kali.
Catatan
- Dua field dengan nama sama ditolak.
validationRules()dantoArray()menjalankan uniqueness check internal dan melemparPandaPanel\Exceptions\PanelSchemaException. Hanya satu rule dan satu value yang dapat bertahan, sehingga tanpa check field lain dapat dirender, diisi, disubmit, lalu dibuang tanpa penjelasan.Relationshipmemberi namespace kepada child fields, sehinggaprofile.biodanbiotetap dua nama berbeda. - Field name kosong ditolak saat construction.
Field::make('')melemparPanelSchemaException. Nama adalah penghubung antara field, value, rule, dan request key. - Hidden field benar-benar absent, bukan sekadar invisible. Field tidak ada di payload, rules, maupun dehydration. Request yang mengirim key tersebut tidak dapat membuatnya menjadi valid field.
- Layout tidak memengaruhi validation atau persistence. Memindahkan field antar-Section, Tab, atau Wizard Step tidak mengubah apa yang server terima maupun tulis.
toArray()memiliki side effect yang disengaja terhadap schema. Method melakukan hydration relation-backed Select dan mengisi value many-to-many secara idempotent. Karena ituvalidationRules()dandehydrate()juga melakukan resolution yang dibutuhkan sendiri, bukan mengasumsikantoArray()sudah pernah dipanggil.- Serialized value hanya berupa JSON-compatible data. Scalar, array, dan
null. Closure dijalankan di server; hanya hasilnya yang melintasi wire.