Custom Field
Contoh control rating bintang pada form product dari Product Resource. Control dirender oleh Vue component milik aplikasi, divalidasi di server seperti field lain, lalu ditampilkan kembali pada view page melalui infolist entry yang sesuai. Gunakan halaman ini ketika built-in control tidak cukup — misalnya rating, map picker, color ramp, atau card khusus di dalam form. Tidak ada generator untuk fitur ini: sebuah custom field hanya membutuhkan satu deklarasi PHP dan satu file .vue, sehingga stub generator justru akan lebih panjang daripada implementasinya.
Contoh minimal yang berfungsi
Tambahkan kolom yang akan ditulis field:
// database/migrations/xxxx_xx_xx_xxxxxx_add_rating_to_products_table.php
Schema::table('products', function (Blueprint $table): void {
$table->unsignedTinyInteger('rating')->nullable();
});2
3
4
5
Deklarasikan field:
use PandaPanel\Forms\Components\CustomField;
CustomField::make('rating')
->label('Editorial rating')
->component('Panels/Admin/Fields/StarRating')
->config(['max' => 5])
->rules(['integer', 'between:1,5']);2
3
4
5
6
7
Buat component pada resources/js/pages/Panels/Admin/Fields/StarRating.vue:
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
modelValue: unknown;
config: Record<string, unknown>;
disabled?: boolean;
error?: string;
}>();
const emit = defineEmits<{ 'update:modelValue': [value: number] }>();
/** The value crosses as JSON, so it is narrowed rather than asserted. */
const value = computed(() =>
typeof props.modelValue === 'number' ? props.modelValue : 0,
);
const max = computed(() =>
typeof props.config.max === 'number' ? props.config.max : 5,
);
</script>
<template>
<div class="flex gap-1">
<button
v-for="star in max"
:key="star"
type="button"
:disabled="disabled"
:aria-label="`${star} of ${max}`"
class="text-lg leading-none"
:class="star <= value ? 'text-primary' : 'text-muted-foreground'"
@click="emit('update:modelValue', star)"
>
★
</button>
</div>
</template>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
Build ulang agar component masuk ke registry:
npm run build # or: npm run devPosisi custom field pada form
CustomField tetap merupakan Field, sehingga dapat ditempatkan di mana pun field biasa dapat digunakan:
// app/Panels/Admin/Resources/Products/Forms/ProductForm.php
use PandaPanel\Forms\Components\CustomField;
use PandaPanel\Forms\Layouts\Section;
Section::make('Editorial')
->columns(2)
->schema([
CustomField::make('rating')
->label('Editorial rating')
->helperText('One to five. Leave blank if the product has not been reviewed.')
->component('Panels/Admin/Fields/StarRating')
->config(['max' => 5])
->rules(['integer', 'between:1,5'])
->columnSpan(2),
]);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Tambahkan rating ke $fillable milik model. Setelah itu field mengikuti lifecycle create dan edit biasa. Tidak dibutuhkan wiring tambahan.
CustomField
| Member | Signature | Default |
|---|---|---|
make() | static make(string $name): static | diwarisi dari Field |
type() | type(): FieldType | FieldType::Custom, diserialisasi sebagai custom |
component() | component(string $component): self | '' |
config() | config(array $config): self | [] |
Node yang diserialisasi membawa componentName dan config di samping seluruh key yang dimiliki field biasa:
CustomField::make('rating')
->component('Panels/Admin/Fields/StarRating')
->config(['max' => 5])
->toArray(null, 'create');
// ['type' => 'custom', 'componentName' => 'Panels/Admin/Fields/StarRating', 'config' => ['max' => 5], …]2
3
4
5
config()
/**
* @param array<string, mixed> $config
*/
public function config(array $config): self2
3
4
Berisi setting yang dibaca component dan dikirim apa adanya melalui serialization. Gunakan scalar, array, dan null saja. Ini adalah configuration, bukan behavior; closure tidak dapat bertahan melewati boundary JSON dan framework juga tidak pernah mencoba menginterpretasikannya.
CustomField::make('rating')
->component('Panels/Admin/Fields/StarRating')
->config([
'max' => 5,
'allowHalf' => false,
'labels' => ['Poor', 'Fair', 'Good', 'Great', 'Excellent'],
]);2
3
4
5
6
7
Pemanggilan terakhir menang. config() melakukan replace, bukan merge.
Semua kemampuan field biasa tetap tersedia
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Forms\Enums\ConditionOperator;
CustomField::make('rating')
->component('Panels/Admin/Fields/StarRating')
->label('Editorial rating')
->helperText('One to five.')
->required()
->default(3)
->columnSpan(2)
->rules(['integer', 'between:1,5'])
->rulesUsing(static fn (?Model $record): array => $record === null ? ['nullable'] : [])
->visibleWhen('is_published', ConditionOperator::Truthy)
->hiddenOn(['create'])
->disabledOn(['view'])
->live(onBlur: false, debounce: 300)
->inlineLabel()
->dehydrateTo('editorial_rating')
->dehydrateStateUsing(static fn (mixed $state): ?int => $state === null ? null : (int) $state);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Custom field tidak memiliki validation rule bawaan. Semua batas nilai yang harus dipenuhi berasal dari rules() dan rulesUsing(). Component yang secara UI sulit menghasilkan value invalid hanyalah kenyamanan; validation PHP tetap menjadi kontrol yang authoritative.
Props yang diterima component
CustomFieldRenderer.vue membungkus component Anda dengan FieldWrapper standar — label, required marker, helper text, dan error message — lalu mengirim lima prop:
defineProps<{
field: CustomFieldDefinition;
modelValue: unknown;
config: Record<string, unknown>;
disabled: boolean;
error?: string;
}>();
defineEmits<{ 'update:modelValue': [value: FormValue] }>();2
3
4
5
6
7
8
9
| Prop | Type | Catatan |
|---|---|---|
field | CustomFieldDefinition | seluruh field yang sudah diserialisasi: name, label, placeholder, helperText, required, disabled, columnSpan, conditions, live, validation, componentName, config |
modelValue | unknown | working value saat ini |
config | Record<string, unknown> | object yang sama dengan field.config |
disabled | boolean | harus dihormati; server juga menolak value field yang disabled |
error | string | undefined | sudah dirender wrapper |
export interface CustomFieldDefinition extends BaseFieldDefinition {
type: 'custom';
componentName: string;
config: Record<string, unknown>;
}2
3
4
5
Jangan merender label, required marker, atau error message sendiri karena wrapper sudah melakukannya. Emit update:modelValue untuk mengubah value. Nilai yang di-emit itulah yang disimpan form, dikirim saat submit, dan divalidasi server.
Membuat custom field menjadi live
Custom field yang memakai live() mengirim perubahan melalui mekanisme yang sama seperti built-in field, sehingga afterStateUpdated() dan schema rebuild tetap bekerja:
CustomField::make('rating')
->component('Panels/Admin/Fields/StarRating')
->config(['max' => 5])
->live()
->afterStateUpdated(static function (mixed $state, mixed $previous, ?Model $record): void {
// Runs on the server, on the form-state endpoint.
});2
3
4
5
6
7
public function live(bool $onBlur = false, ?int $debounce = null): static
public function afterStateUpdated(Closure $callback): static2
Setiap live round-trip adalah request nyata. Control yang digeser terus-menerus dapat menghasilkan request per perubahan kecil jika onBlur atau debounce tidak digunakan.
Custom layout
PandaPanel\Forms\Layouts\CustomComponent adalah pasangan untuk kebutuhan layout, bukan input. Component ini tidak menyimpan value dan tidak submit data sendiri, tetapi dapat menampung field biasa yang tetap berperilaku normal.
use PandaPanel\Forms\Components\NumberInput;
use PandaPanel\Forms\Layouts\CustomComponent;
CustomComponent::make('Panels/Admin/Schemas/PricingCard')
->config(['currency' => 'USD'])
->schema([
NumberInput::make('price_cents')->label('Price (cents)')->integer()->min(0),
NumberInput::make('compare_at_cents')->label('Compare at (cents)')->integer()->min(0),
]);2
3
4
5
6
7
8
9
| Member | Signature | Default |
|---|---|---|
make() | static make(string $component): self | registry key berasal langsung dari argument constructor |
schema() | schema(array $components): self | [] |
config() | config(array $config): self | [] |
children() | children(): array | component anak yang ditampung |
fields() | fields(): array | seluruh field di bawahnya, dalam bentuk flattened |
Perhatikan perbedaannya dengan CustomField: pada CustomComponent, nama component adalah argument make(), bukan dipasang melalui component(). Keduanya terlihat mirip tetapi merepresentasikan hal yang berbeda.
resources/js/pages/Panels/Admin/Schemas/PricingCard.vue:
<script setup lang="ts">
defineProps<{ config: Record<string, unknown> }>();
</script>
<template>
<section class="rounded-lg border p-4">
<h3 class="mb-3 text-sm font-medium">Pricing ({{ config.currency }})</h3>
<div class="flex flex-col gap-4">
<slot />
</div>
</section>
</template>2
3
4
5
6
7
8
9
10
11
12
13
Field anak dirender oleh Panel lalu diberikan sebagai default slot. Dengan demikian custom layout hanya menentukan di mana child diletakkan dan tidak perlu mengetahui bagaimana masing-masing field dirender. Component yang tidak menggunakan slot juga valid; artinya layout tersebut memang tidak memiliki field di dalamnya.
Menampilkan value yang sama pada view page
Renderer form tidak digunakan oleh infolist. PandaPanel\Infolists\Components\CustomEntry adalah read-only counterpart untuk data yang sama:
use App\Models\Product;
use PandaPanel\Infolists\Components\CustomEntry;
CustomEntry::make('rating')
->label('Editorial rating')
->component('Panels/Admin/Entries/RatingStars')
->config(['max' => 5])
->placeholder('Not reviewed')
->state(static fn (Product $record): array => [
'value' => (int) ($record->rating ?? 0),
'reviewed' => $record->rating !== null,
]);2
3
4
5
6
7
8
9
10
11
12
| Member | Signature | Default |
|---|---|---|
make() | static make(string $name): static | diwarisi dari Entry |
type() | type(): EntryType | EntryType::Custom |
component() | component(string $component): self | '' |
config() | config(array $config): self | [] |
state() | state(Closure $callback): self | null — fallback ke attribute |
toValue() | toValue(Model $record): mixed | state closure, atau resolveValue() |
Component menerima tiga prop dan tidak meng-emit apa pun:
defineProps<{
entry: CustomEntryDefinition;
value: unknown;
config: Record<string, unknown>;
}>();2
3
4
5
resources/js/pages/Panels/Admin/Entries/RatingStars.vue:
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
value: unknown;
config: Record<string, unknown>;
}>();
const reading = computed(() => {
const value = props.value;
if (typeof value !== 'object' || value === null) {
return null;
}
const { value: score, reviewed } = value as {
value?: unknown;
reviewed?: unknown;
};
return typeof score === 'number' && typeof reviewed === 'boolean'
? { score, reviewed }
: null;
});
const max = computed(() =>
typeof props.config.max === 'number' ? props.config.max : 5,
);
</script>
<template>
<span
v-if="reading?.reviewed"
class="tracking-widest"
:aria-label="`${reading.score} of ${max}`"
>
{{ '★'.repeat(reading.score) }}{{ '☆'.repeat(max - reading.score) }}
</span>
<span v-else class="text-muted-foreground">—</span>
</template>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
Lokasi file yang harus digunakan
Registry adalah build-time allowlist. resources/js/panel/forms/registry.ts melakukan glob terhadap empat directory, satu untuk setiap extension seam:
resources/js/pages/Panels/**/Fields/*.vue ← CustomField
resources/js/pages/Panels/**/Schemas/*.vue ← CustomComponent
resources/js/pages/Panels/**/Entries/*.vue ← CustomEntry
resources/js/pages/Panels/**/Modals/*.vue ← Modal::content()2
3
4
Nama yang dikirim PHP adalah path di bawah resources/js/pages/, tanpa extension:
| File | Nama |
|---|---|
resources/js/pages/Panels/Admin/Fields/StarRating.vue | Panels/Admin/Fields/StarRating |
resources/js/pages/Panels/Admin/Schemas/PricingCard.vue | Panels/Admin/Schemas/PricingCard |
resources/js/pages/Panels/Admin/Entries/RatingStars.vue | Panels/Admin/Entries/RatingStars |
Dua helper membaca registry jika diperlukan:
import { hasFormComponent, resolveFormComponent } from '@/panel/forms/registry';
hasFormComponent('Panels/Admin/Fields/StarRating'); // boolean
resolveFormComponent('Panels/Admin/Fields/StarRating'); // loader, or null2
3
4
Segmen {Panel} adalah convention, bukan hard rule. Glob sebenarnya adalah pages/Panels/**/{Fields,…}/*.vue, sehingga kedalaman directory bebas selama kind directory tepat. Namun setiap pattern berakhir dengan *.vue, bukan **/*.vue: hanya direct child dari kind directory yang diregistrasikan. Fields/Inputs/StarRating.vue tidak termasuk.
Root dapat dikonfigurasi melalui panda-panel.frontend.pages_path, default-nya js/pages/Panels. Jika root dipindahkan, glob juga harus disesuaikan.
Ketika nama component tidak dapat di-resolve
| Kondisi | Yang dirender |
|---|---|
CustomField dengan nama tidak dikenal | Wrapper tetap muncul, dengan pesan "This field has no renderer." menggantikan control |
CustomComponent dengan nama tidak dikenal | Child tetap dirender tanpa wrapper custom |
CustomEntry dengan nama tidak dikenal | placeholder milik entry, atau em dash |
Tidak ada exception. Satu typo component tidak boleh menjatuhkan seluruh form, dan field lain di sekitarnya tetap dapat digunakan. Custom layout juga tetap merender child karena wrapper hanyalah dekorasi; field di dalamnya tetap merupakan form.
Pada development, registry memberi warning satu kali per nama:
[panel] The form component [Panels/Admin/Fields/Typo] is not in the build-time
registry, so a fallback is drawn instead. It has to live under
resources/js/pages/Panels/{Panel}/ — check the path and the spelling, then rebuild.2
3
Pada production warning tidak dicetak karena ini adalah masalah build dan console warning pada live Panel tidak membantu end-user. Tiga penyebab utamanya adalah typo, file di luar directory yang di-glob, atau build belum dijalankan ulang.
Test
Sisi PHP dapat diuji tanpa browser, dan di situlah validation/security rule berada:
<?php
declare(strict_types=1);
use App\Models\Product;
use App\Models\User;
use App\Panels\Admin\Resources\Products\ProductResource;
use PandaPanel\Core\PanelManager;
use PandaPanel\Forms\Components\CustomField;
beforeEach(function (): void {
app(PanelManager::class)->setCurrentPanel(panel('admin'));
$this->actingAs(User::factory()->admin()->create());
});
it('serializes the field with its component name and config', function (): void {
$definition = CustomField::make('rating')
->component('Panels/Admin/Fields/StarRating')
->config(['max' => 5])
->toArray(null, 'create');
expect($definition['type'])->toBe('custom')
->and($definition['componentName'])->toBe('Panels/Admin/Fields/StarRating')
->and($definition['config'])->toBe(['max' => 5]);
});
it('is an ordinary field as far as the schema is concerned', function (): void {
panelForm(ProductResource::class)->assertHasField('rating');
});
it('validates on the server, whatever the control emitted', function (): void {
$this->post('/admin/products/create', [
'name' => 'Keyboard',
'sku' => 'KB-001',
'price_cents' => 12900,
'stock' => 1,
'rating' => 9,
])->assertInvalid(['rating']);
expect(Product::query()->count())->toBe(0);
});
it('saves a value the rules accept', function (): void {
$this->post('/admin/products/create', [
'name' => 'Keyboard',
'sku' => 'KB-001',
'price_cents' => 12900,
'stock' => 1,
'rating' => 4,
])->assertRedirect();
expect(Product::query()->firstWhere('sku', 'KB-001')?->rating)->toBe(4);
});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
Vue component adalah component Vue biasa dan dapat diuji menggunakan tool yang sudah dipakai aplikasi. Contract frontend milik framework sendiri diuji oleh tests/Feature/Panel/FrontendContractTest.php, yang memastikan setiap PHP field type memiliki renderer di sisi frontend.
Menambahkan field type framework yang benar-benar baru
CustomField adalah extension point yang didukung untuk aplikasi. Menambahkan FieldType baru berarti mengubah framework dan membutuhkan tiga perubahan yang harus masuk bersama:
- Class PHP yang meng-extend
Fielddan mengembalikan caseFieldTypebaru. - TypeScript definition yang ditambahkan ke union di
resources/js/panel/types/form.ts. - Branch baru pada
resources/js/panel/forms/FormField.vue.
Switch renderer bersifat exhaustive terhadap union tersebut, sehingga definition tanpa renderer menghasilkan compile error. FormFieldTypeTest juga membaca TypeScript file dan gagal jika case PHP FieldType tidak pernah ditambahkan ke union — sesuatu yang tidak dapat dilihat TypeScript compiler dari sisi PHP. Seluruh kebutuhan aplikasi normal dapat dicapai melalui CustomField tanpa menyentuh bagian internal tersebut.
Hal yang perlu diperhatikan
- File baru membutuhkan rebuild.
import.meta.globdievaluasi saat build. Ini penyebab paling umum component yang jelas-jelas ada tetapi fallback tetap muncul. - Nama component bukan path filesystem. Jangan memakai prefix
@/, jangan akhiri dengan.vue, dan jangan membangunnya dari request value. PHP hanya mengirim registry name dan serializable config; tidak ada renderable object yang melewati wire. Itulah sebabnya allowlist dapat dipercaya. - Case-sensitive.
Panels/Admin/Fields/starRatingberbeda dari.../StarRating. Pada filesystem case-insensitive, bug ini sering baru terlihat di CI. CustomComponent::make()menerima nama component;CustomField::make()menerima nama field. Signature tampak mirip tetapi artinya berbeda.config()melakukan replace. Dua pemanggilan tidak di-merge; konfigurasi terakhir menang.- Vue source Panel menjadi bagian aplikasi. File dipublish ke
resources/js/melaluiphp artisan panel:install, sehingga registry glob dapat melihat custom component.php artisan panel:assetsmelaporkan published file framework yang tertinggal setelah package update. - Visibility sudah diterapkan sebelum component dirender. Field yang disembunyikan
visibleWhen()tidak pernah mencapai custom component, sehingga component tidak perlu mengecek visibility sendiri.
Lihat juga
- Product Resource — form tempat field ini ditambahkan
- User Resource —
CustomColumn, extension seam serupa pada table - Custom Fields (forms guide)
- Custom Fields (frontend)
- Custom Entries
- Custom Columns
- Custom Widgets
- Component Registries
- Form Layouts, Prime Components
- Validation, Live Fields
- Action Modals
- Frontend Assets