Singular Resources
Singular Resource adalah Resource yang hanya memiliki tepat satu record: application settings, current tenant, atau singleton configuration row. Page miliknya tidak membawa {record} karena tidak ada pilihan record lain, dan record di-resolve oleh Resource, bukan oleh URL. Gunakan pendekatan ini ketika sebuah model memiliki table tetapi secara desain hanya memiliki satu row. Jika tidak ada model sama sekali, gunakan standalone page.
Singular Resource yang berfungsi
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\AppSettings;
use App\Models\AppSetting;
use App\Panels\Admin\Resources\AppSettings\Pages\EditAppSettings;
use App\Panels\Admin\Resources\AppSettings\Pages\ViewAppSettings;
use PandaPanel\Forms\Components\TextInput;
use PandaPanel\Forms\FormSchema;
use PandaPanel\Resources\Resource;
use PandaPanel\Tables\TableSchema;
final class AppSettingResource extends Resource
{
protected static string $model = AppSetting::class;
protected static ?string $slug = 'app-settings';
protected static bool $singular = true;
protected static ?string $navigationLabel = 'Application settings';
protected static ?string $navigationIcon = 'settings';
public static function table(TableSchema $table): TableSchema
{
return $table;
}
public static function form(FormSchema $schema): FormSchema
{
return $schema->schema([
TextInput::make('support_email')->required()->email(),
]);
}
/**
* @return array<string, class-string>
*/
public static function pages(): array
{
return [
'index' => ViewAppSettings::class,
'edit' => EditAppSettings::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
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\AppSettings\Pages;
use App\Panels\Admin\Resources\AppSettings\AppSettingResource;
use PandaPanel\Resources\Pages\ViewRecord;
final class ViewAppSettings extends ViewRecord
{
protected static string $resource = AppSettingResource::class;
}2
3
4
5
6
7
8
9
10
11
12
13
EditAppSettings extends EditRecord memiliki pola tiga baris yang sama. Hasilnya /admin/app-settings menjadi read screen dan /admin/app-settings/edit menjadi form, sidebar memiliki entry, dan tidak ada record key pada URL mana pun.
Deklarasi
protected static bool $singular = false;
public static function isSingular(): bool;2
3
Hanya satu property. Seluruh behavior di bawah mengikuti deklarasi tersebut dan tidak membutuhkan konfigurasi tambahan.
Perubahan pada routes
PanelRouteRegistrar menghapus {record} dari seluruh path yang didaftarkan singular Resource. Route name tidak berubah.
| Page key | Path Resource biasa | Path singular Resource | Route name |
|---|---|---|---|
index | / | / | .index |
create | create | create | .create, .store, .validateCreateStep |
view | {record} | / | .view |
edit | {record}/edit | edit | .edit, .update, .validateEditStep |
| Custom key | ResourcePage::routePath($key) | Path yang sama setelah {record} dihapus | .{key} |
use Illuminate\Support\Facades\Route;
Route::has('panel.admin.resources.app-settings.edit'); // true
route('panel.admin.resources.app-settings.edit', absolute: false); // '/admin/app-settings/edit'2
3
4
Custom page path yang hanya berisi {record} berubah menjadi /, sedangkan {record}/audit berubah menjadi audit.
Dari mana record berasal
public static function resolveSingularRecord(): Model
{
return static::query()->firstOrFail();
}2
3
4
Lookup tetap melalui query() seperti seluruh Resource lain. Tenant scope, module scope, permission scope, dan constraint lainnya tetap berlaku. Singular Resource adalah singleton di dalam scope, bukan singleton global. Jika query tidak memiliki row, firstOrFail() melempar ModelNotFoundException yang dirender Laravel sebagai 404.
Page mengaksesnya melalui concern yang memang sudah digunakan:
protected function resolveRecord(int|string|null $key = null): Model
{
$resource = static::$resource;
$record = $key === null || $resource::isSingular()
? $resource::resolveSingularRecord()
: $resource::resolveRecord($key);
abort_unless($this->authorizeRecord($record), 403);
return $this->record = $record;
}2
3
4
5
6
7
8
9
10
11
12
Ada dua kondisi dan kondisi kedua yang paling penting: singular Resource selalu me-resolve record miliknya sendiri meskipun suatu key secara tidak sengaja dikirim. Tidak ada URL yang dapat memilih row lain.
Membuat row pada kunjungan pertama
Override method-nya. Ini ordinary static method dan tidak membutuhkan wiring tambahan:
use App\Models\AppSetting;
use Illuminate\Database\Eloquent\Model;
public static function resolveSingularRecord(): Model
{
return static::query()->firstOr(static fn (): AppSetting => AppSetting::query()->create([
'support_email' => config('mail.from.address'),
]));
}2
3
4
5
6
7
8
9
Atau, jika row ditentukan oleh key tertentu dan bukan sekadar "row pertama":
public static function resolveSingularRecord(): Model
{
return static::query()->firstOrCreate(['key' => 'global']);
}2
3
4
Keduanya tetap melewati query(), sehingga row yang dibuat berada di dalam scope yang ditentukan Resource.
URLs
Resource::url() mengabaikan record argument untuk singular Resource sehingga caller tidak perlu mengetahui apakah Resource bersifat singular:
use App\Panels\Admin\Resources\AppSettings\AppSettingResource;
AppSettingResource::url(); // '/admin/app-settings'
AppSettingResource::url('edit'); // '/admin/app-settings/edit'
AppSettingResource::url('edit', $record); // '/admin/app-settings/edit' — the record is ignored2
3
4
5
Bagian pentingnya hanya satu kondisi di dalam Resource::url():
if ($record !== null && ! static::isSingular()) {
$parameters['record'] = $record instanceof Model ? $record->getKey() : $record;
}2
3
Inilah yang membuat EditRecord tetap dapat digunakan tanpa perubahan. Page membangun submit URL sebagai Resource::url('update', $model), dan pada singular Resource model argument tersebut memang tidak dibutuhkan. Lihat URLs and route names.
Page yang sebaiknya dideklarasikan
index dan view sama-sama berubah menjadi root Resource. Jika keduanya dideklarasikan, dua GET route akan memiliki path yang sama dan route kedua menggantikan yang pertama. Akibatnya route name index hilang, sedangkan Resource::url() default-nya meminta 'index', sehingga menghasilkan RouteNotFoundException. Deklarasikan salah satu, bukan keduanya.
Resource::url() juga tidak hanya dipanggil code aplikasi Anda. ResourcePage::baseBreadcrumbs() memanggilnya pada setiap record page:
Breadcrumb::make($resource::pluralLabel())->url($resource::url());Karena itu singular Resource tetap membutuhkan key index. Tanpa index, edit page dapat 500 saat membangun breadcrumb sebelum form sempat dirender. Dua bentuk berikut valid:
// Read screen at /admin/app-settings, form at /admin/app-settings/edit.
return [
'index' => ViewAppSettings::class,
'edit' => EditAppSettings::class,
];
// A one-row table at /admin/app-settings, form at /admin/app-settings/edit.
return [
'index' => ListAppSettings::class,
'edit' => EditAppSettings::class,
];2
3
4
5
6
7
8
9
10
11
Subclass ResourcePage apa pun dapat ditempatkan di bawah key index: key menentukan route, sedangkan class menentukan content yang dirender. Yang tidak bekerja adalah menaruh EditRecord pada index dan tidak mendeklarasikan edit, karena form tetap melakukan submit ke route update yang hanya dibuat oleh key edit.
Page create jarang dibutuhkan. Resource yang secara desain hanya memiliki satu record biasanya mendapat row dari migration/seed atau dibuat oleh resolveSingularRecord(). Create page justru menawarkan pembuatan record kedua.
Sidebar entry
Resource::navigationItem() mengembalikan null jika pages() tidak memiliki key index. href sidebar entry adalah index URL; membangun link ke route yang tidak pernah didaftarkan akan gagal saat sidebar dirender dan dapat menjatuhkan seluruh page pada Panel.
Dengan demikian key index juga menjadi syarat agar singular Resource dapat masuk sidebar. Setelah itu seluruh deklarasi navigation biasa tetap berlaku:
protected static ?string $navigationLabel = 'Application settings';
protected static ?string $navigationIcon = 'settings';
protected static string|BackedEnum|null $navigationGroup = 'System';
protected static int $navigationSort = 90;2
3
4
5
6
7
Singular Resource yang sengaja tidak ingin ditampilkan di sidebar dapat menggunakan protected static bool $shouldRegisterNavigation = false; lalu dibuka dari destination lain. Lihat Labels and navigation.
Authorization
Tidak ada behavior khusus. Record page menanyakan ability yang sama seperti Resource biasa:
| Page | Ability |
|---|---|
index | Resource::canViewAny() |
view dan custom record page | Resource::canView($record) |
edit | Resource::canEdit($record) |
create | Resource::canCreate() |
Policy tetap menerima satu record seperti biasa sehingga tidak memerlukan logic khusus untuk singular Resource:
final class AppSettingPolicy
{
public function viewAny(User $user): bool
{
return $user->is_admin;
}
public function view(User $user, AppSetting $record): bool
{
return $user->is_admin;
}
public function update(User $user, AppSetting $record): bool
{
return $user->is_admin;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Lihat Resource authorization.
Catatan penting
- Singular behavior berasal dari deklarasi, bukan autodetection. Table yang kebetulan hanya memiliki satu row tetap dianggap ordinary Resource sampai
$singular = truedinyatakan. indexdanviewtidak boleh sama-sama dideklarasikan. Keduanya menjadi path yang sama dan salah satunya akan menggantikan yang lain.- Setiap singular Resource membutuhkan page
index.ResourcePage::baseBreadcrumbs()membangunResource::url(), yaitu index route. Record page tanpa route tersebut gagal saat membuat breadcrumb. Sidebar lebih aman: ia hanya menghilangkan entry. resolveSingularRecord()menggunakanfirstOrFail(), bukanfind(1). "Row pertama" mengikuti ordering padaquery(). TambahkanorderByjika lebih dari satu row secara teknis dapat ada.- Record argument pada
url()diabaikan, bukan dianggap error. Memberikan record tetap aman dan membuat shared page code dapat digunakan pada ordinary maupun singular Resource. - Table, filter, dan bulk Action tetap dapat digunakan. Singular Resource dengan
ListRecordsindex tetap merupakan table dengan satu row. Jarang dibutuhkan, tetapi framework tidak melarangnya. - Global search tetap berlaku jika
$globalSearchAttributesdideklarasikan. Result URL menggunakan view page bila ada, atau index sebagai fallback.