Model Binding
PandaBear tidak menggunakan implicit route model binding milik Laravel. Segment {record} diterima sebagai raw key lalu di-resolve oleh Resource melalui query() yang sama dengan seluruh jalur pembacaan record lainnya. Halaman ini menjelaskan method lookup, asal key, dan bagaimana sebuah page menggunakan record setelah berhasil me-resolve-nya.
Kasus minimal
Tidak ada konfigurasi tambahan. Record page menerima key sebagai string lalu meminta Resource me-resolve record tersebut:
use App\Panels\Admin\Resources\Posts\PostResource;
$post = PostResource::resolveRecord(7); // Model, or ModelNotFoundException
$maybe = PostResource::findRecord(7); // Model|null
$many = PostResource::findRecords([7, 8, 9]); // Collection<int, Model>2
3
4
5
Ketiganya dimulai dari Resource::query(). Artinya, record yang dikeluarkan oleh scope Resource dianggap tidak ada dari perspektif Resource tersebut. Inilah alasan binding tidak diserahkan kepada Laravel: implicit binding melakukan resolve langsung dari model, sehingga berpotensi menjangkau record yang sengaja disembunyikan query milik Panel.
Mengapa tidak menggunakan implicit binding
public static function resolveRecord(int|string $key): Model
{
return static::recordQuery()->findOrFail($key);
}2
3
4
recordQuery() adalah query() dengan satu pengecualian yang sangat sempit: Resource yang mendeklarasikan $softDeletes = true akan melepas SoftDeletingScope, dan tidak ada scope lain yang dilepas. Tenant scope, module scope, permission scope, dan penyempitan lainnya tetap berlaku sama seperti pada live record. Tanpa pelepasan scope ini, deleted record tidak akan pernah dapat dibuka dan akibatnya tidak pernah dapat direstore — padahal satu-satunya route menuju record tersebut disembunyikan oleh default scope.
Index page tidak melepas scope tersebut. Di situlah perbedaan antara list dan lookup: index menampilkan record yang sedang aktif/current, sedangkan record page diminta menjawab satu record tertentu berdasarkan key.
Method lookup
| Method | Signature | Return | Digunakan oleh |
|---|---|---|---|
resolveRecord() | public static function resolveRecord(int|string $key): Model | Record atau melempar ModelNotFoundException yang menjadi 404 | Record page |
findRecord() | public static function findRecord(int|string $key): ?Model | Record atau null | Action endpoint |
findRecords() | public static function findRecords(array $keys): Collection | Collection berisi record yang berhasil di-resolve | Bulk action |
recordQuery() | protected static function recordQuery(): Builder | query() dengan soft-delete scope dilepas ketika dideklarasikan | Ketiga method di atas |
resolveSingularRecord() | public static function resolveSingularRecord(): Model | query()->firstOrFail() | Singular resources |
Bentuk nullable tersedia karena caller-lah yang menentukan arti record yang tidak ditemukan. Record action endpoint menjawab 404, sedangkan bulk operation membandingkan jumlah record yang berhasil ditemukan dengan jumlah key yang dikirim lalu menolak seluruh selection bila jumlahnya berbeda.
// PanelActionController, roughly:
$records = $resource::findRecords($keys);
abort_if($records->count() !== count($keys), 404, 'Some records could not be found.');2
3
4
Dari mana key berasal
| Surface | Key | Shape |
|---|---|---|
| Record page | Segment route {record} | Selalu string |
actions/record, actions/cell, actions/infolist | Field record pada payload | string atau int; selain itu 422 |
actions/bulk | Array records | Entry non-scalar dibuang |
| Nested resource | {parentRecord} dan {record} | Parent di-bind middleware sebelum query Resource berjalan |
Key divalidasi sebagai scalar sebelum masuk ke lookup. Jika array dibiarkan masuk, find() dapat berubah menjadi collection lookup dan secara diam-diam mengubah arti request.
Primary key adalah key yang digunakan Panel. Lookup memakai find() dan whereKey(), sedangkan URL dibangun dari $record->getKey(). Model yang meng-override getRouteKeyName() tidak mengubah value yang ditempatkan Panel dalam URL maupun cara Panel me-resolve record — URL Panel berbasis primary key.
Menggunakan record pada sebuah page
ViewRecord dan EditRecord sudah melakukan resolve otomatis. Custom page menambahkan concern berikut:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Posts\Pages;
use App\Panels\Admin\Resources\Posts\PostResource;
use Inertia\Inertia;
use Inertia\Response;
use PandaPanel\Resources\Concerns\InteractsWithRecord;
use PandaPanel\Resources\Pages\ResourcePage;
final class AuditPost extends ResourcePage
{
use InteractsWithRecord;
protected static string $resource = PostResource::class;
protected static ?string $routePath = '{record}/audit';
public function render(string $record): Response
{
$post = $this->resolveRecord($record);
return Inertia::render('panel/Page', [
'page' => [
'title' => 'Audit',
'heading' => 'Audit',
'subheading' => null,
'breadcrumbs' => [],
'headerActions' => [],
'scope' => self::renderHookScope(),
],
'revisions' => $post->revisions()->count(),
]);
}
}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
PandaPanel\Resources\Concerns\InteractsWithRecord memberikan empat member kepada page:
protected function resolveRecord(int|string|null $key = null): Model;
protected function getRecord(): Model;
protected function hasRecord(): bool;
protected function authorizeRecord(Model $record): bool;2
3
4
resolveRecord()melakukan resolve, authorization, lalu menyimpan record untuk request tersebut. Panggil sekali di bagian awalrender(). Keynullberarti Resource bersifat singular: route tidak membawa{record}karena tidak ada record lain yang perlu dipilih, sehingga Resource me-resolve satu record miliknya sendiri.getRecord()mengembalikan record yang sudah di-resolve. Method ini melemparLogicException, bukan mengembalikan null, jika dipanggil sebelum record di-resolve. Mengakses record terlalu awal adalah programming error, bukan state runtime yang perlu ditangani.hasRecord()menjawab apakah record sudah di-resolve.authorizeRecord()menentukan ability yang digunakan. Default-nya adalahcanView(), karena page yang menampilkan record yang tidak boleh dilihat user tetap merupakan kebocoran walaupun page tersebut melakukan pekerjaan lain.
Record disimpan selama satu request, sehingga page yang membacanya di beberapa lokasi tetap hanya menjalankan satu query.
Meminta ability yang berbeda
EditRecord meng-override ability, dan custom page juga dapat melakukannya:
use Illuminate\Database\Eloquent\Model;
protected function authorizeRecord(Model $record): bool
{
return static::$resource::canEdit($record);
}2
3
4
5
6
Check yang gagal menghasilkan 403 melalui abort_unless() di dalam resolveRecord(), sebelum page merender content apa pun.
Menentukan nama record
Breadcrumb, heading, sub-navigation, dan global search result semuanya menanyakan kepada Resource apa nama satu record:
public static function recordTitle(Model $record): stringDefault membaca $recordTitleAttribute, yang dianggap 'name' ketika Resource tidak menentukan value lain. Jika value attribute bukan scalar, method fallback ke primary key.
final class PostResource extends Resource
{
protected static ?string $recordTitleAttribute = 'title';
}2
3
4
Override method ketika title dibentuk secara dinamis, bukan disimpan dalam satu attribute:
use Illuminate\Database\Eloquent\Model;
public static function recordTitle(Model $record): string
{
return sprintf('#%s — %s', $record->getKey(), $record->getAttribute('title'));
}2
3
4
5
6
Catatan penting
- Record di luar query Resource menghasilkan 404, bukan 403. Bukan berarti "Anda tidak boleh memilikinya"; dari perspektif Resource, record tersebut memang tidak ada. Behavior yang sama berlaku ketika Resource dipersempit melalui per-panel configuration.
resolveRecord()melempar exception,findRecord()tidak.ModelNotFoundExceptionyang berubah menjadi response 404. Custom page yang memakaifindRecord()bertanggung jawab melakukanabort_if()sendiri.- Memanggil
getRecord()sebelumresolveRecord()menghasilkanLogicException, dan pesan error menyebut class page. Lakukan resolve di awalrender(). resolveRecord()pada concern berbeda denganResource::resolveRecord(). Versi concern juga melakukan authorization dan memoization; versi Resource hanya melakukan lookup. Di dalam page, gunakan versi concern.- Custom route key tidak didukung. URL Panel selalu membawa primary key.
- Nested resource membutuhkan parent yang sudah di-bind. Middleware melakukannya dari segment
{parentRecord}; action endpoint menerimanya dari fieldparentpada payload. Lihat Nested resources.