Nested Resources
Nested Resource tidak memiliki index global miliknya sendiri: seluruh page berada di bawah satu parent record dan query-nya di-scope ke relation milik parent tersebut. Gunakan nested Resource ketika sebuah model hanya memiliki arti di dalam konteks owner — misalnya task milik project atau line item milik order — dan child membutuhkan page penuh miliknya sendiri, bukan sekadar table di samping parent.
Nested Resource minimal
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Tasks;
use App\Models\Task;
use App\Panels\Admin\Resources\Projects\ProjectResource;
use App\Panels\Admin\Resources\Tasks\Pages\EditTask;
use App\Panels\Admin\Resources\Tasks\Pages\ListTasks;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Resources\Resource;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\TableSchema;
final class TaskResource extends Resource
{
protected static string $model = Task::class;
protected static ?string $parentResource = ProjectResource::class;
protected static ?string $parentRelationship = 'tasks';
protected static ?string $recordTitleAttribute = 'name';
public static function table(TableSchema $table): TableSchema
{
return $table->columns([
TextColumn::make('name')->searchable()->sortable(),
]);
}
public static function form(FormSchema $schema): FormSchema
{
return $schema->schema([
TextInput::make('name')->required()->maxLength(255),
]);
}
/**
* @return array<string, class-string>
*/
public static function pages(): array
{
return [
'index' => ListTasks::class,
'edit' => EditTask::class,
];
}
}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
Page-nya tetap subclass ListRecords dan EditRecord biasa — class page tidak perlu mengetahui bahwa Resource-nya nested. Jika parent dan child Resource sama-sama diregistrasikan pada Panel, URL berikut tersedia:
/admin/projects/7/tasks
/admin/projects/7/tasks/12/edit2
sedangkan /admin/tasks tidak ada.
Dua deklarasi utama
protected static ?string $parentResource = ProjectResource::class;
protected static ?string $parentRelationship = 'tasks';2
3
| Property | Type | Default | Arti |
|---|---|---|---|
$parentResource | ?class-string<Resource> | null | Resource yang menjadi parent. Mendeklarasikannya adalah seluruh proses opt-in menjadi nested Resource |
$parentRelationship | ?string | Camel case dari default slug Resource ini | Relation pada parent model yang menyimpan child record |
public static function isNested(): bool;
public static function parentResource(): ?string;
public static function parentRelationship(): string;2
3
Default relationship diturunkan dari slug milik child. TaskResource secara default mencari $project->tasks(). Deklarasikan $parentRelationship ketika nama relation berbeda; inilah yang memungkinkan PostResource berada di bawah relation bernama author daripada posts.
Parent adalah scope
public static function query(): Builder
{
// for a nested resource, roughly:
return static::parentRelation()->getQuery()->with(static::$with);
}2
3
4
5
Query dimulai dari $parent->{relationship}(), bukan dari model child secara global. Karena seluruh page, Action, dan lookup sudah melewati query(), record milik parent lain otomatis menghasilkan 404 tanpa setiap page perlu menulis check:
GET /admin/projects/7/tasks/12/edit → 404 when task 12 belongs to project 8
PUT /admin/projects/7/tasks/12/edit → 404, and nothing is written2
Nested Resource tidak perlu meng-override query() hanya untuk melakukan parent scoping. Override tambahan tetap diperbolehkan dan tetap harus memanggil parent::query().
Routing
Route group diberi prefix slug parent, wildcard {parentRecord}, lalu slug child Resource:
projects/{parentRecord}/tasksRoute name tidak berubah. panel.admin.resources.tasks.index tetap menjadi route name dan {parentRecord} hanya menjadi parameter tambahan yang wajib diberikan:
route('panel.admin.resources.tasks.index', ['parentRecord' => 7], absolute: false);
// /admin/projects/7/tasks2
PandaPanel\Http\Middleware\ResolveParentRecord dipasang pada seluruh route group, bukan diserahkan kepada masing-masing page, karena setiap route membutuhkan scope parent. Jika satu page saja lupa melakukan resolve, query dapat berjalan tanpa scope. Middleware melakukan empat langkah:
- Membaca segment
{parentRecord}. - Me-resolve parent melalui
query()milik parent Resource. - Meng-authorize parent menggunakan
canView()milik parent. - Mem-bind record tersebut untuk request atau menghasilkan 404.
Langkah 2 dan 3 memastikan /admin/projects/7/tasks tidak dapat menjadi jalan membaca child milik project 7 ketika /admin/projects/7 sendiri tidak dapat diakses.
Bound parent tersedia melalui PandaPanel\Support\ParentRecord:
use PandaPanel\Support\ParentRecord;
ParentRecord::current(); // ?Model
ParentRecord::require(TaskResource::class); // Model, or PanelRegistrationException
ParentRecord::routeParameter(); // 'parentRecord'2
3
4
5
Membaca parent hanya melalui query() adalah hal yang membuat scope sulit terlupa. Jika setiap page mengambil parent secara manual, akan ada lebih dari satu tempat yang dapat kehilangan security boundary tersebut.
URLs
Resource::url() otomatis menggunakan parent yang sedang ter-bind dari request, sehingga link antar-page di dalam nested Resource tidak memerlukan argument tambahan:
TaskResource::url(); // /admin/projects/7/tasks
TaskResource::url('edit', $task); // /admin/projects/7/tasks/12/edit
TaskResource::url(parent: $otherProject); // /admin/projects/8/tasks2
3
public static function url(
string $page = 'index',
Model|int|string|null $record = null,
Panel|string|null $panel = null,
Model|int|string|null $parent = null,
): string2
3
4
5
6
Argument $parent menerima model atau key dan wajib diberikan ketika tidak ada bound parent yang dapat digunakan sebagai fallback — misalnya console command, queued job, atau link yang dibangun dari bagian lain pada Panel. Tanpa parent, ParentRecord::require() melempar exception daripada menghasilkan URL tanpa scope.
Navigation dan breadcrumbs
Nested Resource tidak memiliki sidebar entry. navigationItem() mengembalikan null karena seluruh page hanya tersedia di bawah parent record, sedangkan sidebar tidak memiliki parent tertentu yang sedang aktif. Tidak ada destination global "all tasks" yang dapat dibuka.
Breadcrumb membawa trail milik parent:
Dashboard › Projects › Apollo › TasksBreadcrumb parent menjadi link ke view page ketika parent Resource memiliki view page dan user boleh membukanya. Jika tidak, crumb menjadi plain text — tidak pernah menjadi link yang akhirnya menjawab 403.
Actions
Action endpoint hanya satu per Panel dan URL endpoint tersebut tidak memiliki parent segment. Karena itu table pada nested Resource mengirim parent bersama setiap Action request. Page mengirimkannya sebagai resource.parentKey, lalu endpoint me-resolve dan meng-authorize parent sama seperti route middleware.
POST /admin/actions/record
{ "resource": "nested-tasks", "action": "delete", "record": 12, "parent": 7 }2
| Payload | Response |
|---|---|
parent tidak ada pada nested Resource | 422 |
parent menunjuk record yang tidak boleh dilihat user | 404 |
record sebenarnya milik parent berbeda | 404 |
Path collision
Laravel mencocokkan route pertama yang memiliki path shape sama dan secara diam-diam membuat route berikutnya tidak pernah terjangkau. Karena itu dua Resource/Page yang mengklaim satu shape harus ditolak. ManageRelatedRecords pada projects/{record}/tasks dan nested Resource pada projects/{parentRecord}/tasks adalah path yang sama dari perspektif router — nama parameter dihapus saat shape dibandingkan.
Registration menolak kondisi tersebut:
PanelRegistrationException: The path [projects/{parentRecord}/tasks] is registered by both
[App\...\ProjectResource] and [App\...\TaskResource]. Only the first would ever match.
Give one of them a different slug or route path.2
3
Berikan slug berbeda pada salah satunya. Fixture framework melakukan hal yang sama: nested Resource memakai slug nested-tasks sehingga tidak menutupi relation page pada projects/{record}/tasks.
Parent Resource juga harus diregistrasikan pada Panel yang sama. Parent dari Panel lain dapat menghasilkan path yang dibangun dari default slug tetapi route-nya tidak ada pada Panel ini. Framework memilih gagal saat boot daripada membiarkan dead link dipublish.
Nested Resource atau Relation Manager
Keduanya menampilkan child milik satu record, tetapi pengalaman yang diberikan berbeda.
| Nested Resource | Relation Manager | |
|---|---|---|
| Pages | Memiliki list, create, view, edit sendiri | Table di samping owner dengan modal |
| URL | /projects/7/tasks/12/edit | Menggunakan URL owner |
| Dideklarasikan oleh | $parentResource pada child | relationManagers() pada owner |
| Sidebar | Tidak ada | Tidak ada |
| Cocok untuk | Child dengan kedalaman nyata — filter, form, Action, dan page sendiri | Beberapa row yang cukup dibaca dan diedit langsung di tempat |
Keduanya tidak saling eksklusif. Resource dapat menjadi nested sekaligus memiliki relation manager miliknya sendiri. Lihat Nested vs relation manager.
Catatan penting
- Nested Resource tidak memiliki index global secara sengaja.
/admin/tasksmenghasilkan 404. Itulah tujuan desainnya: tidak ada "all tasks" yang bermakna di luar project. $parentRelationshipdiperiksa saat query digunakan, bukan saat boot. Nama yang bukan method pada parent model atau method yang tidak mengembalikanRelationmenghasilkanPanelRegistrationExceptionyang menyebut parent model, relation, dan child Resource.- Scope milik parent Resource ikut berlaku. Project yang dikeluarkan
query()milik parent menghasilkan 404 untuk seluruh task di bawahnya. Resource::url()di luar request membutuhkan parent eksplisit. Tidak ada bound parent yang dapat digunakan sebagai fallback.- Policy child tetap mengontrol child.
canView()milik parent menentukan apakah nested URL dapat dimasuki; ability milik child menentukan operasi apa yang boleh dilakukan setelah berada di dalamnya.