Relation Tables
Relation table adalah TableSchema yang dideklarasikan sebuah relation manager lalu diserialisasi untuk record page yang membawanya. Bentuk payload dasarnya sama seperti resource index — definition, applied state, rows, pagination — ditambah identity relasi, endpoint tempat write dikirim, dan namespace query string tempat state relation disimpan. Gunakan halaman ini ketika Anda membutuhkan column, filter, sorting, atau pagination pada relation dan ingin mengetahui bagian mana dari Table API yang tetap berlaku.
Relation table minimal
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Relations\DeleteRelatedAction;
use PandaPanel\Actions\Relations\EditRelatedAction;
use PandaPanel\Tables\Columns\BadgeColumn;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\Filters\SelectFilter;
use PandaPanel\Tables\TableSchema;
public static function table(TableSchema $table, Model $owner): TableSchema
{
return $table
->columns([
TextColumn::make('title')->searchable()->sortable(),
BadgeColumn::make('status'),
])
->filters([
SelectFilter::make('status')->options([
'draft' => 'Draft',
'published' => 'Published',
]),
])
->defaultSort('title')
->recordActions([
EditRelatedAction::make(UserResource::class, self::class, $owner),
DeleteRelatedAction::make(self::class, $owner),
])
->emptyState(
heading: 'No posts yet',
description: 'Posts written by this user will appear here.',
);
}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
Seluruh behavior pada Columns, Filters, Sorting, Search, Grouping, dan Summaries berlaku tanpa perubahan. Relation table tetap menggunakan class TableSchema yang sama dan dibangun oleh TableQuery yang sama seperti resource index.
State memiliki namespace sendiri
Beberapa relation table dapat hidup pada satu record page, sehingga masing-masing membaca state dari bagian query string miliknya sendiri:
/admin/users/3?relations[posts][page]=2&relations[posts][sort]=title&relations[comments][search]=helloSorting pada satu relation tidak mengubah relation lain. Namespace-nya adalah relations.{key} dan dikirim ke frontend sebagai stateKey, sehingga browser tidak perlu membangun ulang namespace tersebut sendiri:
use PandaPanel\Resources\RelationTable;
RelationTable::stateKey('posts'); // 'relations.posts'2
3
| Parameter | Shape | Arti |
|---|---|---|
relations[key][page] | int | Nomor page |
relations[key][perPage] | int | Jumlah row per page, dari perPageOptions() |
relations[key][search] | string | Global search term |
relations[key][sort] | string | Nama column yang dideklarasikan sortable |
relations[key][direction] | asc / desc | Arah sort |
relations[key][filters][name] | filter value | Value satu filter |
relations[key][columnSearch][name] | string | Search term milik individually searchable column |
relations[key][columns][visible] | list | Visibility column manager |
relations[key][columns][order] | list | Urutan column manager |
Setiap value divalidasi terhadap schema. Sort yang menyebut column yang tidak pernah dideklarasikan relation table diabaikan, bukan diterjemahkan menjadi order by menggunakan nama dari request:
?relations[tasks][sort]=project_id → state.sort is nullSession persistence juga tetap bekerja — persistSortInSession(), persistFiltersInSession(), persistSearchInSession(), persistColumnsInSession() — menggunakan key yang memasukkan manager, sehingga dua relation pada satu page tidak pernah berbagi state yang diingat:
panel.{panelId}.table.{resourceSlug}.{relationKey}Pagination dilakukan melalui relation
public static function relationForTable(Model $owner): Relation;Table melakukan pagination terhadap relationForTable(), bukan query(). Pada many-to-many, pivot di-hydrate di dalam BelongsToMany::paginate(). Builder yang dilepas dari relation menghasilkan row dengan pivot column yang semuanya terbaca null. Inilah alasan pivot column dapat bekerja dengan benar — lihat Pivot fields.
Pagination metadata dikirim sebagai:
{ "page": 2, "perPage": 10, "total": 34, "lastPage": 4, "from": 11, "to": 20 }Header action di-resolve, bukan dideklarasikan
Create, attach, dan associate ditambahkan oleh RelationTable sendiri:
CreateRelatedAction::make($resource, $manager, $owner);
AttachAction::make($resource, $manager, $owner);
AssociateAction::make($resource, $manager, $owner);2
3
Ketiganya merupakan jawaban terhadap bentuk relation. Jika manager harus menuliskan action tersebut sendiri, developer dapat secara tidak sengaja menawarkan attach pada hasMany. Setiap action dibuang dari payload ketika hidden atau unauthorized, sehingga hasMany tidak pernah merender attach button dan user tanpa ability create tidak pernah merender create button.
TableSchema::headerActions() adalah daftar terpisah milik schema table biasa dan tidak dirender oleh relation table. Taruh pekerjaan per-row pada recordActions() dan pekerjaan terhadap set pada bulkActions().
Record actions
->recordActions([
EditRelatedAction::make(UserResource::class, self::class, $owner),
DetachAction::make(self::class, $owner),
RestoreAction::make(self::class, $owner),
ForceDeleteAction::make(self::class, $owner),
DeleteRelatedAction::make(self::class, $owner),
])2
3
4
5
6
7
Row action melakukan POST ke relation action endpoint milik Panel:
POST /{panel}/relations/action
{ "resource": "users", "record": 3, "relation": "posts", "action": "delete", "related": 12 }2
Controller me-resolve manager melalui Resource::relationManager(), me-load related record melalui RelationManager::resolveRecord(), lalu memeriksa isAuthorizedFor($related) milik Action sebelum handler dijalankan. Related key milik owner lain menghasilkan 404; nama Action yang tidak dideklarasikan table menghasilkan 404; Action tanpa handler menghasilkan 400.
Custom Action bekerja seperti pada surface lain — buat PandaPanel\Actions\Action dan tambahkan ke recordActions():
use PandaPanel\Actions\Action;
use PandaPanel\Actions\Enums\ActionVariant;
// Inside the manager's table(), where $owner and self:: are both in scope.
Action::make('publish')
->label('Publish')
->icon('send')
->variant(ActionVariant::Outline)
->requiresConfirmation(heading: 'Publish this post?')
->successMessage('Post published.')
->authorize(static fn (?Model $record): bool => $record !== null
&& self::canEdit($owner, $record))
->action(static function (Model $record): void {
$record->update(['published_at' => now()]);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
Authorization custom action adalah tanggung jawab Anda. Action tanpa ->authorize() dianggap diizinkan bagi setiap user yang sudah boleh membaca relation.
Bulk actions
->bulkActions([
DetachBulkAction::make(self::class, $owner),
RestoreBulkAction::make(self::class, $owner),
ForceDeleteBulkAction::make(self::class, $owner),
])2
3
4
5
POST /{panel}/relations/bulk
{ "resource": "users", "record": 3, "relation": "posts", "action": "detach", "records": [1, 2, 3] }2
| Rule | Behavior |
|---|---|
records | array wajib, 1 sampai 500 key, dideduplicate |
| Ada key di luar relation | seluruh request 404 — jumlah record yang berhasil di-load harus sama dengan jumlah key |
| Collective authorization | isAuthorizedFor(null) sebelum record di-load |
| Authorization per record | handler Action memeriksa ulang setiap record dan melempar 403 untuk seluruh set |
Key di luar relation menghilang secara diam-diam dari query(). Count check-lah yang mengubah kondisi tersebut menjadi failure eksplisit, bukan partial operation.
Summary dan grouping
->groups([Group::make('status')])
->defaultGroup('status')2
Summary dan group summary dihitung terhadap relationForTable()->getQuery() dan records pada page, lalu dikirim sebagai summaries dan groupSummaries. Keduanya berupa array kosong ketika schema tidak mendeklarasikannya. Lihat Summaries dan Grouping.
Serialized payload
PandaPanel\Resources\RelationTable::toArray() menghasilkan:
| Key | Type | Catatan |
|---|---|---|
key | string | RelationManager::key() |
title | string | RelationManager::title() |
icon | string|null | RelationManager::icon() |
stateKey | string | relations.{key} |
table | array | TableSchema::toArray() |
state | array | State yang benar-benar diterapkan server |
rows | list<array> | Satu serialized row per record |
summaries | array | Kosong jika schema tidak mendeklarasikan summary |
groupSummaries | array | Kosong ketika tidak ada active group |
pagination | array | page, perPage, total, lastPage, from, to |
headerActions | list<array> | Create, attach, associate — hanya yang lolos authorization |
endpoints | array | form, save, action, bulk |
Seluruh payload menjadi null ketika RelationManager::canViewAny($owner) menolak. Manager unauthorized tidak menjalankan query dan tidak pernah muncul di UI.
API RelationTable
| Method | Signature | Return |
|---|---|---|
stateKey() | static stateKey(string $relationKey): string | relations.{key} |
toArray() | toArray(Request $request): ?array | Payload di atas atau null |
forRecord() | static forRecord(string $resource, Model $owner, Request $request): list<array> | Semua manager yang dideklarasikan Resource |
forManager() | static forManager(string $resource, string $manager, Model $owner, Request $request): ?array | Satu manager tertentu |
actionFor() | static actionFor(string $manager, Model $owner, string $name): ?Action | Record action berdasarkan nama |
bulkActionFor() | static bulkActionFor(string $manager, Model $owner, string $name): ?Action | Bulk action berdasarkan nama |
use PandaPanel\Resources\RelationTable;
// What ViewRecord and EditRecord put in their `relations` prop.
$relations = RelationTable::forRecord(UserResource::class, $user, $request);
// What a ManageRelatedRecords page puts in its `relation` prop.
$relation = RelationTable::forManager(
UserResource::class,
PostsRelationManager::class,
$user,
$request,
);2
3
4
5
6
7
8
9
10
11
12
Resource page dapat mempersempit daftar dengan override ResourcePage::relationTables():
protected function relationTables(Request $request, Model $record): array
{
return array_values(array_filter(
parent::relationTables($request, $record),
static fn (array $relation): bool => $relation['key'] !== 'posts',
));
}2
3
4
5
6
7
Yang tidak dilakukan relation table
Relation manager memiliki empat endpoint: form, save, action, dan bulk. Surface table yang pada Resource biasa melakukan POST ke endpoint Resource tidak memiliki padanan relation. Mendeklarasikannya akan merender control yang tidak bekerja:
- Toolbar actions (
toolbarActions()) dan table actions. - Empty-state actions — heading, description, dan icon empty state tetap dirender, tetapi Action-nya tidak berjalan.
- Editable columns — relation cell bersifat read-only.
- Drag reordering (
reorderable()). - Filter tabs — fitur tersebut milik
ListRecords, bukanTableSchema.
Column visibility, filters, search, sorting, grouping, summaries, pagination, row selection, record actions, dan bulk actions tetap bekerja.
Hal yang perlu diperhatikan
- Sort/search pivot column tidak bekerja secara default.
TextColumn::make('pivot.role')->sortable()akan mencoba mengurutkan column literalpivot.role. Berikan column nyata sepertisortable(column: 'label_project.role'), atau jangan aktifkan sorting. - Relation table dirender di dalam card dengan border sendiri. Table adalah satu object di antara beberapa object pada record page, sehingga memiliki edge sendiri, bukan menyatu menjadi satu surface besar seperti toolbar + rows + pagination pada resource index.
- Method
table()dapat berjalan lebih dari sekali per request. Method dipanggil saat serialization dan dipanggil lagi ketika endpoint action/bulk perlu me-resolve Action berdasarkan nama. Jangan menaruh side effect di dalamnya. canViewAny()berjalan sebelum query. Manager yang ditolak tidak memiliki biaya query. Manager yang query dulu lalu menyembunyikan row tetap sudah membaca data.- Key yang tidak berada pada relation dianggap tidak terlihat, bukan forbidden. Ini disengaja: 404 adalah jawaban yang benar untuk record yang owner ini memang tidak pernah miliki.