Relation Forms
Relation form adalah dialog di balik satu relation operation beserta write yang terjadi setelah dialog tersebut disubmit. Secara konseptual form ini terdiri dari dua schema berdampingan — field milik related record dan field milik pivot — yang digabung untuk rendering dan validation tetapi tidak pernah digabung untuk persistence, karena keduanya mewakili dua row di dua table berbeda. Kedua bagian dideklarasikan pada manager, lalu PandaPanel\Resources\RelationForm merakitnya.
Relation form minimal
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Forms\Components\Select;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\Components\Textarea;
use PandaPanel\Forms\FormSchema;
public static function form(FormSchema $schema, Model $owner): FormSchema
{
return $schema->schema([
TextInput::make('title')->required()->maxLength(255),
Textarea::make('body')->rows(6),
Select::make('status')->options([
'draft' => 'Draft',
'published' => 'Published',
]),
]);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Itulah seluruh deklarasinya. Create dialog, edit dialog, validation rule, dan write semuanya diturunkan dari schema tersebut. Perhatikan yang tidak ada: foreign key field. Record baru disimpan melalui relation, sehingga owner key ditentukan oleh relation, bukan oleh input user.
Empat operation
PandaPanel\Support\RelationOperation adalah closed set. Operation menentukan schema mana yang dibangun, ability mana yang diperiksa, dan write mana yang dijalankan. Value yang tidak dikenal server menghasilkan 404, bukan fallback.
| Case | Value | Schema | Write |
|---|---|---|---|
RelationOperation::Create | create | form() + pivotForm() | Record baru disimpan melalui relation |
RelationOperation::Edit | edit | form() + pivotForm() | forceFill()->save(), ditambah updateExistingPivot() |
RelationOperation::Attach | attach | select related + pivotForm() | BelongsToMany::attach() |
RelationOperation::Associate | associate | select related | HasOneOrMany::save() pada child yang dipilih |
use PandaPanel\Support\RelationOperation;
RelationOperation::tryFromRequest('attach'); // RelationOperation::Attach
RelationOperation::tryFromRequest('obliterate'); // null → 404
RelationOperation::Edit->needsRelatedRecord(); // true; the others are false
RelationOperation::Attach->isAuthorized(LabelsRelationManager::class, $project);2
3
4
5
6
7
isAuthorized() berada pada enum karena tiga endpoint membutuhkan mapping yang sama — relation form, searchable option milik form tersebut, dan file upload miliknya. Menyalin mapping itu ke tiga tempat berarti tiga peluang salah satu jalur menjadi lebih permisif dari yang lain.
Dua endpoint
GET /{panel}/relations/form?resource=&record=&relation=&operation=[&related=]
POST /{panel}/relations/form?resource=&record=&relation=&operation=[&related=]2
Route name: panel.{panel}.relations.form dan panel.{panel}.relations.save.
Context dikirim melalui query string, bukan request body. Body hanya berisi value form. Dengan demikian field bernama resource di dalam form tidak dapat mengarahkan request ke Resource lain.
GET mengembalikan JSON. Me-render ulang seluruh page hanya untuk menjawab "form dialog ini seperti apa?" akan membuang table state yang sedang dilihat user:
| Key | Type | Arti |
|---|---|---|
title | string | New Posts, Edit Posts, Attach Labels, Associate Tasks |
submitLabel | string | Create, Save, Attach, Associate |
form | array | FormSchema::toArray(), sudah di-fill dari related record |
submitUrl | string | URL tempat dialog melakukan POST |
method | 'post' | Selalu POST |
optionsUrl | string | URL searchable select mengambil option tambahan |
uploadUrl | string | URL file field menyimpan upload |
POST melakukan redirect kembali, bukan mengembalikan JSON. Relation table hidup di dalam sebuah page, sehingga page perlu dirender ulang agar row baru muncul. Validation failure kembali melalui mekanisme yang sama sehingga dialog menampilkan error di sebelah field tanpa membutuhkan format error kedua.
curl -H 'Accept: application/json' \
'/admin/relations/form?resource=users&record=3&relation=posts&operation=create'2
Membuat RelationForm secara manual
use PandaPanel\Resources\RelationForm;
use PandaPanel\Support\RelationOperation;
$form = RelationForm::for(
PostsRelationManager::class,
$user,
RelationOperation::Edit,
$post,
);2
3
4
5
6
7
8
9
| Method | Signature | Return |
|---|---|---|
for() | static for(string $manager, Model $owner, RelationOperation $operation, ?Model $related = null): self | Form yang sudah dirakit |
schema() | schema(): FormSchema | Kedua bagian schema yang sudah digabung |
validationRules() | validationRules(?Model $related = null): array<string, list<mixed>> | Rule dengan key nama field |
toArray() | toArray(?Model $related = null): array | Serialized form yang sudah di-fill |
title() | title(): string | Heading dialog |
submitLabel() | submitLabel(): string | Label tombol submit |
relatedKey() | relatedKey(array $validated): int|string|null | Key yang ditunjuk operation attach/associate |
save() | save(array $validated, ?Model $related = null): void | Menjalankan operation |
$rules = $form->validationRules($post); // ['title' => [...], 'pivot.role' => [...]]
$validated = validator($request->all(), $rules)->validate();
$form->save($validated, $post);2
3
4
save() mengasumsikan caller sudah meng-authorize operation dan sudah membuka transaction. PanelRelationController melakukan keduanya dan membungkus write menggunakan PandaPanel\Support\DatabaseTransaction.
Dua constant memberi nama bagian form:
RelationForm::RELATED_FIELD; // 'related' — the select naming the record to join
RelationForm::PIVOT_PREFIX; // 'pivot' — the namespace pivot fields live under2
Apa yang ditulis setiap operation
Create membuat related instance baru, melakukan forceFill() terhadap attribute yang ter-dehydrate, lalu menyimpan record melalui relation:
$relation->save($related); // HasOneOrMany
$related->save() and $relation->attach($key, $pivot); // BelongsToMany2
Menyimpan melalui relation mengisi foreign key — dan morph type jika ada — sehingga form tidak perlu mendeklarasikannya. project_id yang disisipkan pada request body dibuang karena field tersebut tidak pernah ada pada schema:
POST .../relations/form?...&operation=create
{ "name": "Written", "project_id": 99 }
→ the record is created under the owner, not under 992
3
Edit menulis related record dan, bila relation many-to-many serta manager mendeklarasikan pivot field, memperbarui pivot row juga:
$related->forceFill($attributes)->save();
$relation->updateExistingPivot($related->getKey(), $pivot);2
Attach dan Associate tidak menulis apa pun pada related record. Select related adalah addressing, bukan data: field itu menentukan row mana yang dihubungkan, bukan value apa yang ditulis ke row tersebut. Lihat Attach dan detach serta Associate dan dissociate.
Select related
Untuk attach atau associate, RelationForm membangun select ini sendiri:
Select::make('related')
->label($manager::title())
->required()
->searchable()
->options($manager::attachableOptions($owner))
->existsIn($related->getTable(), $related->getKeyName());2
3
4
5
6
Option berasal dari record yang belum berada pada relation. Validity menggunakan exists pada related table, bukan membership terhadap option list, karena list tersebut hanya satu bounded page dan key valid yang berada di luar limit tetap valid. Controller memeriksa membership secara terpisah dan mengembalikan 422 jika record sudah berada di relation:
POST .../operation=attach { "related": "5" } → 422 "That record is already in this relation."
POST .../operation=attach { "related": "999" } → validation error on `related`2
Searchable select pada relation form
optionsUrl dibangun untuk context relation dan dikirim bersama form, sehingga Vue tidak membangun URL Panel sendiri:
GET /{panel}/options?resource=users&record=3&relation=posts&operation=create&field=author&search=leeClient hanya menambahkan field dan search. Context lainnya merupakan pernyataan server tentang form mana yang sedang dibuka dan keystroke user tidak boleh dapat mengubahnya. Endpoint:
- Me-resolve manager melalui
Resource::relationManager()— 404 bila Resource tidak pernah mendeklarasikannya. - Me-resolve operation — 404 untuk value yang tidak ada pada enum.
- Memeriksa
RelationManager::canViewAny($owner)danRelationOperation::isAuthorized()— 403 jika salah satu gagal. - Untuk
field=related, mengembalikanRelationManager::attachableOptions($owner, $search, 50). - Untuk field lain, me-resolve field dari schema form ini — 404 jika field tidak dideklarasikan, 400 jika field bukan select.
Limit 50 result merupakan bound yang tidak dapat dinaikkan request. Lihat Options endpoints.
File upload pada relation form
uploadUrl membawa context yang sama dengan options URL. Karena itu file field di dalam relation form di-authorize menggunakan ability milik relation operation, bukan hanya ability owner Resource. Behavior FileUpload lainnya tetap sama; lihat File uploads.
Validation
Rule berasal dari merged schema sehingga record field dan pivot field divalidasi dalam satu pass, dan error kembali menggunakan key yang juga dirender field:
$form->validationRules($post);
// ['title' => ['required', 'string', 'max:255'], 'pivot.role' => ['nullable', 'string', 'max:50']]2
Hanya declared field yang divalidasi dan dipersist. Extra request key dibuang sama seperti resource form:
{ "title": "Hello", "is_admin": true } → is_admin never reaches the modelHal yang perlu diperhatikan
- Manager tanpa
form()menghasilkan create dialog kosong.form()secara default mengembalikan schema tanpa perubahan. Itu benar untuk manager yang hanya attach/detach dan salah untuk manager yang sebenarnya ingin membuat record. Gejalanya adalah dialog dengan tombol submit tetapi tidak ada field. - Bagian record pada attach form tidak dirender. Attach menunjuk existing record; field milik record tersebut diedit melalui flow edit miliknya sendiri.
pivotForm()diabaikan padaAssociatedan seluruh relation non-many-to-many. Hanya join row yang dapat menyimpan pivot column. Lihat Pivot fields.- Attribute ditulis menggunakan
forceFill(). Daftar attribute berasal dari schema, bukan dari raw request. Inilah yang membuat bypass$fillabletetap aman — alasan yang sama seperti resource form. - Form di-fetch saat dialog dibuka, bukan dikirim bersama setiap row. Page berisi dua puluh record hanya membawa dua puluh tombol, bukan dua puluh form terisi. Form selalu dibangun dari state record terkini.
methodselalupost. Fetch dan submit menggunakan path yang sama; HTTP verb membedakan keduanya.- Semua write berada dalam satu transaction. Related record dan pivot row ditulis bersama sehingga kegagalan pivot write tidak meninggalkan record setengah jadi.