Pivot Fields
Join row pada relasi many-to-many dapat memiliki column miliknya sendiri — misalnya role, position, atau note — dan relation manager mendeklarasikannya melalui pivotForm(). Field tersebut dirender di samping field milik related record, divalidasi dalam satu pass yang sama, tetapi dipersist ke join table, bukan ke related record. Gunakan pivot field ketika fakta yang ingin disimpan memang milik pasangan relasi, bukan milik salah satu record secara individual.
Manager dengan pivot columns
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Projects\RelationManagers;
use App\Panels\Admin\Resources\Projects\ProjectResource;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Relations\DetachAction;
use PandaPanel\Actions\Relations\EditRelatedAction;
use PandaPanel\Forms\Components\Select;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Resources\RelationManager;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\TableSchema;
final class LabelsRelationManager extends RelationManager
{
protected static string $relationship = 'labels';
protected static ?string $recordTitleAttribute = 'name';
public static function table(TableSchema $table, Model $owner): TableSchema
{
return $table
->columns([
TextColumn::make('name')->searchable()->sortable(),
// Pivot columns read through the same dotted attribute path
// any relation column uses.
TextColumn::make('pivot.role')->label('Role'),
])
->recordActions([
EditRelatedAction::make(ProjectResource::class, self::class, $owner),
DetachAction::make(self::class, $owner),
]);
}
public static function form(FormSchema $schema, Model $owner): FormSchema
{
return $schema->schema([
TextInput::make('name')->required()->maxLength(255),
]);
}
public static function pivotForm(FormSchema $schema, Model $owner): FormSchema
{
return $schema->schema([
Select::make('role')->options([
'primary' => 'Primary',
'secondary' => 'Secondary',
]),
]);
}
}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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
Relation pada owner model harus menyatakan bahwa pivot column tersebut memang ada; jika tidak, Eloquent tidak akan men-select-nya:
public function labels(): BelongsToMany
{
return $this->belongsToMany(Label::class)->withPivot('role');
}2
3
4
Signature
public static function pivotForm(FormSchema $schema, Model $owner): FormSchema;Secara default schema kosong — plain join row tidak membutuhkan field tambahan. Method menerima schema yang page-nya sudah diset sesuai operation (create atau edit), sehingga hiddenOn() dan method sejenis bekerja sama seperti pada resource form.
Namespace pivot.
Setiap field yang dideklarasikan pivotForm() diberi prefix satu kali ketika relation form dirakit:
use PandaPanel\Resources\RelationForm;
RelationForm::PIVOT_PREFIX; // 'pivot'2
3
$field->getName(); // 'pivot.role' — the wire, the rules, the errors
$field->getAttribute(); // 'role' — the column on the join table2
Prefix inilah yang mencegah column role pada join table menimpa column role pada related record. Keduanya menjadi field berbeda dengan nama berbeda dan dapat muncul bersamaan dalam satu form.
Serialized schema menempatkan field milik record terlebih dahulu lalu field pivot:
// GET /admin/relations/form?resource=projects&record=7&relation=labels&operation=create
// form.schema names:
['name', 'pivot.role']2
3
Browser mengirim nested shape yang dapat divalidasi Laravel secara native:
{ "name": "Urgent", "pivot": { "role": "primary" } }Validation error kembali menggunakan key pivot.role, yaitu key yang juga digunakan renderer field.
Di operation mana pivot field muncul
| Operation | Pivot field dirender | Ditulis oleh |
|---|---|---|
create | ya, pada many-to-many | BelongsToMany::attach($key, $pivot) setelah record disimpan |
edit | ya, pada many-to-many | BelongsToMany::updateExistingPivot($key, $pivot) |
attach | ya | BelongsToMany::attach($key, $pivot) |
associate | tidak | — one-to-many tidak memiliki join row |
| operation apa pun pada relasi non-many-to-many | tidak | — |
Mendeklarasikan pivot field pada hasMany adalah kesalahan. Merender field tersebut justru akan menyembunyikan kesalahan karena user melihat input yang tidak pernah tersimpan. Karena itu RelationForm membuang pivot field pada relation shape yang tidak mendukungnya.
Data yang benar-benar dipersist
Hanya field yang dideklarasikan pivotForm(), satu demi satu, melalui tiga pertanyaan yang sama seperti resource form:
$field->shouldDehydrate($value); // false skips the column entirely
$field->getDehydrateKey(); // the join-table column, from dehydrateTo() or the name
$field->mutate($value, null); // dehydrateStateUsing()/mutateUsing(), with no record2
3
Key request yang tidak memiliki field di schema dibuang:
{ "related": "4", "pivot": { "role": "primary", "smuggled": "value" } }
→ the join row has role = primary, and nothing named smuggled is written2
Pivot field tidak menerima record sebagai argument kedua mutate(). Pada saat itu tidak ada pivot model instance yang sedang dipegang, sehingga callback yang membutuhkan pivot model memang tidak memiliki object untuk diberikan.
use PandaPanel\Forms\Components\NumberInput;
NumberInput::make('position')
->integer()
->dehydrateTo('sort_order') // writes to a differently named column
->mutateUsing(static fn (mixed $value): int => (int) $value);2
3
4
5
6
Membaca pivot column kembali
Semua jenis table column dapat membacanya melalui dotted path karena Column::resolveValue() menggunakan data_get():
use PandaPanel\Tables\Columns\BadgeColumn;
use PandaPanel\Tables\Columns\DateColumn;
use PandaPanel\Tables\Columns\TextColumn;
TextColumn::make('pivot.role')->label('Role'),
BadgeColumn::make('pivot.status'),
DateColumn::make('pivot.created_at')->label('Added'),2
3
4
5
6
7
Ini bekerja karena relation table melakukan pagination pada relation itu sendiri, bukan pada builder yang dilepas dari relation. BelongsToMany::paginate() yang melakukan select dan hydrate pivot. Row yang diambil lewat jalur lain akan membuat seluruh pivot column terbaca null. Lihat Relation tables.
Pivot timestamp tetap membutuhkan ->withTimestamps() pada relation seperti penggunaan Eloquent biasa.
Validation
Rule menggunakan nama lengkap field sehingga namespace sudah ikut terbawa:
use PandaPanel\Resources\RelationForm;
use PandaPanel\Support\RelationOperation;
$form = RelationForm::for(LabelsRelationManager::class, $project, RelationOperation::Attach);
array_keys($form->validationRules());
// ['related', 'pivot.role']2
3
4
5
6
7
Semua fitur di Validation tetap berlaku — required(), maxLength(), rules(), rulesUsing() — dan rule dijalankan dalam pass yang sama dengan field milik related record.
Yang tidak didukung
- Pivot field pada relasi selain many-to-many. Field dibuang, bukan dirender.
- Pivot form pada
associate. Tidak ada join row. - Sort/search pivot column secara otomatis.
TextColumn::make('pivot.role')->sortable()akan mencoba mengurutkan column literal bernamapivot.role. Gunakan column nyata, misalnyasortable(column: 'label_project.role'). - Mengedit pivot beberapa row sekaligus. Bulk action bawaan hanya detach, restore, dan force delete; pivot write dilakukan per row.
- Membaca pivot column yang tidak dideklarasikan relation.
withPivot()adalah yang memasukkannya ke hasil select.
Hal yang perlu diperhatikan
withPivot()wajib untuk membaca, bukan untuk menulis. Attach tetap dapat menulis column yang dideklarasikan, tetapi table menampilkannulljika relation tidak pernah men-select-nya. Gejalanya: value ada di database tetapi tidak terlihat di UI.- Edit hanya menyentuh pivot ketika ada sesuatu yang perlu ditulis.
updateExistingPivot()berjalan ketika pivot attributes tidak kosong. Jika seluruh pivot field ter-dehydrate menjadi kosong, join row dibiarkan apa adanya. - Dua field tidak boleh memiliki nama sama di dalam bagian pivot setelah prefixing.
namepada record danrolepada pivot menjadinamedanpivot.role, sehingga tidak berbenturan. Tetapi dua fieldroledipivotForm()tetap collision. - Custom pivot model (
->using()) tidak diakses langsung oleh framework. Attach/update tetap lewat method relation, sehingga cast/event custom pivot model berlaku persis seperti saat menggunakanattach()danupdateExistingPivot()di luar Panel. - Pivot value tetap divalidasi walaupun record half tidak dirender. Attach form hanya berisi select
relatedditambah pivot field, tetapi seluruh pivot rule tetap berjalan.