Plugin
Recipe ini membawa plugin dari awal sampai akhir: dimulai sebagai satu class di dalam aplikasi, kemudian dibuat configurable, lalu dikemas sebagai Composer package sendiri yang membawa Vue component untuk dipublish serta version constraint yang dapat menolak instalasi tidak kompatibel. Gunakan halaman ini ketika sekumpulan konfigurasi Panel — misalnya resource, widget, dan navigation group — perlu dipasang pada lebih dari satu Panel atau lebih dari satu aplikasi.
Semua yang dilakukan plugin tetap dilakukan melalui public API milik Panel. Tidak ada configuration surface kedua khusus plugin. Batas ini sengaja dibuat agar plugin tidak memiliki kemampuan yang bahkan tidak dimiliki Panel.
Tidak ada command make:panel-plugin. Sebuah plugin hanya membutuhkan satu class dan satu method wajib, sehingga stub generator akan lebih panjang daripada implementasi paling kecilnya.
Contoh minimal yang berfungsi
<?php
declare(strict_types=1);
namespace App\Panels\Plugins;
use App\Panels\Admin\Resources\Products\ProductResource;
use PandaPanel\Core\Panel;
use PandaPanel\Plugins\Plugin;
final class CatalogPlugin extends Plugin
{
public function register(Panel $panel): void
{
$panel->resources([ProductResource::class]);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// app/Panels/Admin/AdminPanelProvider.php
use App\Panels\Plugins\CatalogPlugin;
return $panel
->path('admin')
->auth()
->plugins([
new CatalogPlugin,
]);2
3
4
5
6
7
8
9
10
PandaPanel\Plugins\Plugin sudah menyediakan id(), boot(), metadata(), dan publishes(), sehingga Anda hanya perlu mengimplementasikan register(). ID diturunkan dari nama class: CatalogPlugin menjadi catalog.
Contract
PandaPanel\Contracts\PanelPlugin mendeklarasikan lima method:
interface PanelPlugin
{
public function id(): string;
public function register(Panel $panel): void;
public function boot(Panel $panel): void;
public function metadata(): PluginMetadata;
/** @return array<string, string> absolute source path => absolute destination path */
public function publishes(): array;
}2
3
4
5
6
7
8
9
10
PandaPanel\Plugins\Plugin hanyalah convenience base class yang mengimplementasikan empat method tersebut. Framework sendiri tidak pernah membutuhkan concrete base class itu — lookup plugin, hook, maupun panel:publish semuanya bekerja melalui contract — sehingga plugin yang dikirim sebagai package sendiri sebaiknya mengimplementasikan interface secara langsung.
| Default base class | Value |
|---|---|
id() | Str::kebab(Str::beforeLast(class_basename(static::class), 'Plugin')) |
boot() | tidak melakukan apa pun |
metadata() | new PluginMetadata(name: Str::headline($this->id())) |
publishes() | [] |
Membuat plugin configurable
Plugin tanpa konfigurasi sering kali hanya menjadi class yang sebenarnya dapat ditulis langsung oleh aplikasi. Nilai tambahnya muncul ketika fluent setter menyimpan state, mengembalikan $this, lalu state tersebut dibaca di register():
<?php
declare(strict_types=1);
namespace App\Panels\Plugins;
use App\Panels\Admin\Resources\Products\ProductResource;
use App\Panels\Admin\Widgets\LowStock;
use PandaPanel\Core\Panel;
use PandaPanel\Plugins\Plugin;
final class CatalogPlugin extends Plugin
{
private bool $widgets = true;
private ?string $group = 'Catalog';
private string $currency = 'usd';
public static function make(): self
{
return new self;
}
public function withWidgets(bool $widgets = true): self
{
$this->widgets = $widgets;
return $this;
}
public function group(?string $group): self
{
$this->group = $group;
return $this;
}
public function currency(string $currency): self
{
$this->currency = $currency;
return $this;
}
/** Read back by this plugin's own resources. */
public function getCurrency(): string
{
return $this->currency;
}
public function register(Panel $panel): void
{
if ($this->group !== null) {
$panel->navigationGroups([$this->group]);
}
$panel->resources([ProductResource::class]);
if ($this->widgets) {
$panel->widgets([LowStock::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
52
53
54
55
56
57
58
59
60
61
62
63
64
Satu plugin dapat memiliki dua bentuk berbeda pada dua Panel tanpa membuat class Panel-specific tambahan:
$admin->plugins([CatalogPlugin::make()->currency('eur')]);
$app->plugins([CatalogPlugin::make()->withWidgets(false)->group(null)]);2
make() hanyalah convention agar chaining lebih mudah dibaca, bukan framework hook. Konfigurasi yang wajib sebaiknya ditempatkan pada constructor sehingga tidak mungkin terlupa:
public function __construct(private readonly string $currency) {}
public static function make(string $currency): self
{
return new self($currency);
}2
3
4
5
6
Object plugin dibuat oleh aplikasi, bukan container. Tidak ada automatic constructor injection. Dependency request-scoped/container sebaiknya di-resolve pada boot() menggunakan app().
Membaca kembali konfigurasi
Resource milik plugin perlu membaca konfigurasi sebagaimana plugin dipasang. Ada dua cara, dan biasanya static lookup lebih nyaman:
use PandaPanel\Contracts\PanelPlugin;
$plugin = panel()?->plugin('catalog'); // ?PanelPlugin2
3
use App\Panels\Plugins\CatalogPlugin;
$currency = CatalogPlugin::in(panel())?->getCurrency() ?? 'usd';2
3
public static function in(?Panel $panel): ?staticin() mencocokkan berdasarkan class, sehingga return value sudah bertipe plugin Anda sendiri tanpa instance check. Lookup berdasarkan class dipilih karena membaca id() dengan membuat instance plugin baru tidak selalu mungkin; constructor plugin dapat membutuhkan konfigurasi.
Method mengembalikan null ketika Panel tidak memasang plugin, termasuk ketika panel() sendiri null di luar request Panel. Resource yang digunakan bersama oleh dua Panel tetapi plugin hanya terpasang pada salah satunya adalah konfigurasi normal, bukan error.
Sisi Panel menyediakan:
public function plugins(array $plugins): self // list<PanelPlugin>; calls register() in array order
public function getPlugins(): array // array<string, PanelPlugin>, keyed by id
public function hasPlugin(string $id): bool
public function plugin(string $id): ?PanelPlugin2
3
4
Dua plugin yang mengklaim ID yang sama ditolak saat registration:
Panel::make('admin')->plugins([CatalogPlugin::make(), CatalogPlugin::make()]);
// PandaPanel\Exceptions\PanelRegistrationException: … claim the id …2
Panel harus dapat menjawab hasPlugin('catalog') dengan satu jawaban. Duplicate ID lebih baik gagal saat boot daripada baru terlihat kemudian sebagai resource yang muncul dua kali.
register() dan boot()
Ada tiga fase dan menentukan pekerjaan harus berada di fase mana adalah bagian paling penting saat menulis plugin.
| Fase | Kapan | Yang diletakkan di sana |
|---|---|---|
register() | ketika Panel sedang dikonfigurasi | resource, page, widget, navigation group, settings, discovery path |
boot() | setelah Panel di-resolve, per request | pekerjaan yang membutuhkan container, user, atau URL |
publishes() | tidak pernah otomatis — hanya lewat panel:publish | file yang disalin plugin ke aplikasi |
register() berjalan selama application boot untuk setiap request, termasuk request yang tidak pernah masuk Panel. Query, pembacaan authenticated user, atau route resolution di sini akan dibayar setiap request dan banyak request membayarnya tanpa manfaat. Selain itu user belum tersedia pada fase tersebut.
use PandaPanel\Core\Panel;
use PandaPanel\Enums\RenderHook;
public function boot(Panel $panel): void
{
// A route name only exists once routes are registered, and the user only
// once the request has been authenticated. Both are true here and
// neither is true in register().
$panel->renderHook(
RenderHook::SidebarEnd,
'Panels/Catalog/Hooks/StockShortcut',
[
'url' => route($panel->routeName('resources.products.index')),
'name' => auth()->user()?->name,
],
);
$panel->cssHooks(['page' => 'catalog-page']);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function renderHook(RenderHook $hook, string $component, array $data = [], array $scopes = []): self
public function cssHooks(array $classes): self
public function getRenderHooks(): array
public function getCssHooks(): array2
3
4
RenderHook merupakan closed set — BodyStart, BodyEnd, SidebarStart, SidebarEnd, HeaderStart, HeaderEnd, PageStart, PageEnd — karena nama hook yang tidak pernah dirender shell akan menjadi konfigurasi yang diam-diam tidak melakukan apa pun. Component yang dikirim adalah registry key di bawah resources/js/pages/Panels/{Panel}/Hooks/, bukan markup. Tidak ada renderable object yang menyeberang wire.
boot() berjalan sekali per request yang berhasil mencapai Panel, setelah access check dan sebelum callback bootUsing() milik Panel. Dengan demikian aplikasi selalu memiliki keputusan terakhir atas plugin yang dipasang.
Bug lifecycle paling umum adalah menukar tanggung jawab keduanya. Query pada register() mungkin tidak terlihat di development tetapi menjadi database hit pada setiap asset request production.
Karena boot() berjalan per request, implementasinya harus idempotent. Operasi append seperti render hook atau navigation group dapat menambahkan entry yang sama lagi pada process panjang seperti Octane.
Apa yang dapat diregistrasikan plugin
Semua yang dapat dilakukan panel provider, melalui method Panel yang sama:
public function register(Panel $panel): void
{
$panel
->resources([ProductResource::class])
->pages([CatalogSettings::class])
->widgets([LowStock::class])
->navigationGroups(['Catalog'])
// Discovery works from a plugin too, which is how a package
// registers a whole directory. These paths are cached by
// `panel:cache` like any other.
->discoverResources(__DIR__.'/Resources')
->assets('resources/css/catalog.css')
->cssHooks(['page' => 'catalog-page']);
}2
3
4
5
6
7
8
9
10
11
12
13
14
PanelPlugin bukan service provider dan tidak di-resolve container. Migration, config file, translation, event listener, dan non-panel route tetap menjadi tanggung jawab Laravel service provider biasa yang dikirim bersama package.
Mengirim Vue component
Component yang masih berada di package plugin tidak dapat langsung di-resolve. Semua component registry framework memakai import.meta.glob terhadap resources/js/pages/Panels/** milik aplikasi. Ini sengaja menjadi build-time allowlist: component yang berada di luar tree adalah component yang tidak pernah dilihat build system.
Karena itu plugin mem-publish component ke tree aplikasi. Setelah dipublish, file menjadi bagian aplikasi: masuk repository, ikut build, dan dapat diedit. Ini feature, bukan workaround; component yang source-nya tidak terlihat developer sulit di-debug.
/**
* Absolute source path => absolute destination path. A directory copies
* recursively; a file copies as one file.
*
* @return array<string, string>
*/
public function publishes(): array
{
return [
// For a plugin living in the application, keep the sources beside
// the class: app/Panels/Plugins/stubs/StockGauge.vue.
__DIR__.'/stubs' => resource_path('js/pages/Panels/Catalog'),
];
}2
3
4
5
6
7
8
9
10
11
12
13
14
Plugin package menunjuk ke resources/js milik package pada sisi source:
__DIR__.'/../resources/js' => resource_path('js/pages/Panels/AcmeCatalog'),php artisan panel:publish catalog
npm run build2
php artisan panel:publish
{plugin?} # only this plugin, by id; omit for every plugin on every panel
--force # overwrite files that already exist2
3
Tanpa --force, destination yang sudah ada dilewati dan dilaporkan, tidak pernah ditimpa:
[catalog] .../resources/js/pages/Panels/Catalog/Gauge.vue ... exists, skippedPublished file mungkin sudah diedit aplikasi. Menimpanya diam-diam berarti menghapus pekerjaan developer. Source path yang tidak ada hanya menghasilkan warning dan proses publish plugin lain tetap dilanjutkan.
Nama dan versioning plugin
use PandaPanel\Plugins\PluginMetadata;
public function id(): string
{
return 'acme-catalog';
}
public function metadata(): PluginMetadata
{
return new PluginMetadata(
name: 'Acme Catalog',
package: 'acme/panda-catalog',
requiresPanel: '^1.2',
url: 'https://github.com/acme/panda-catalog',
);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
final readonly class PluginMetadata
{
public function __construct(
public string $name,
public ?string $package = null,
public ?string $requiresPanel = null,
public ?string $url = null,
) {}
public function version(): ?string; // read from composer, null for a package it has never heard of
public function toArray(): array; // name, package, version, requiresPanel, url
}2
3
4
5
6
7
8
9
10
11
12
Versi dibaca dari installed-package data milik Composer, bukan dideklarasikan manual. String versi manual mudah lupa diubah; plugin yang melaporkan 1.2.0 saat 1.4.1 sebenarnya terpasang lebih buruk daripada plugin yang tidak melaporkan versi.
Plugin tanpa package tidak memiliki version report, dan ini benar untuk plugin yang tinggal langsung di aplikasi.
id() harus stabil lintas versi. Aplikasi yang memanggil hasPlugin('catalog') sedang menanyakan identitas plugin, bukan release tertentu.
php artisan panel:plugins
php artisan panel:plugins --panel=admin2
Panel ID Name Package Version Requires
admin acme-catalog Acme Catalog acme/panda-catalog 1.4.1 ^1.22
Tabel ini adalah informasi yang seharusnya ikut dalam bug report. Panel dengan empat plugin memiliki empat sumber tambahan untuk resource, page, widget, dan route. Dua pertanyaan pertama ketika salah satunya bermasalah selalu "plugin mana?" dan "versi berapa?".
Compatibility check
requiresPanel adalah Composer-style constraint terhadap framework ini, diperiksa oleh PandaPanel\Plugins\PluginCompatibility saat registration — momen paling awal ketika jawabannya diketahui dan momen terakhir sebelum plugin mengubah Panel.
PluginCompatibility::assert(PanelPlugin $plugin, string $panelId, ?string $installed = null): voidJika tidak kompatibel, method melempar PandaPanel\Exceptions\PanelRegistrationException yang menyebut plugin, Panel, constraint, dan versi terpasang. Ini menggantikan failure yang jauh lebih membingungkan seperti Call to undefined method Panel::whatever() di tengah request yang hanya menyebut framework.
Check dilewati dalam tiga kondisi karena tidak ada perbandingan valid yang dapat dilakukan:
- Plugin tidak mendeklarasikan constraint.
- Framework tidak terpasang sebagai Composer package, misalnya path repository, git checkout, atau test suite repository framework.
- Installed version berupa branch alias seperti
dev-mainatau placeholder Composer1.0.0+no-version-set.
Mengirim plugin sebagai package
Implementasikan contract secara langsung. Package yang extend convenience base class aplikasi menjadi lebih coupled daripada yang dibutuhkan.
<?php
declare(strict_types=1);
namespace Acme\Catalog;
use PandaPanel\Contracts\PanelPlugin;
use PandaPanel\Core\Panel;
use PandaPanel\Plugins\PluginMetadata;
final class CatalogPlugin implements PanelPlugin
{
public static function make(): self
{
return new self;
}
public function id(): string
{
return 'acme-catalog';
}
public function register(Panel $panel): void
{
$panel
->navigationGroups(['Catalog'])
->discoverResources(__DIR__.'/Resources')
->discoverWidgets(__DIR__.'/Widgets');
}
public function boot(Panel $panel): void
{
//
}
public function metadata(): PluginMetadata
{
return new PluginMetadata(
name: 'Acme Catalog',
package: 'acme/panda-catalog',
requiresPanel: '^0.1',
);
}
/** @return array<string, string> */
public function publishes(): array
{
return [
__DIR__.'/../resources/js' => resource_path('js/pages/Panels/AcmeCatalog'),
];
}
}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
Mengimplementasikan contract berarti menulis seluruh lima method, termasuk method yang sebelumnya memiliki default pada base class. Trade-off-nya sederhana: coupling lebih rendah dengan sedikit boilerplate tambahan.
Service provider milik package
<?php
declare(strict_types=1);
namespace Acme\Catalog;
use Illuminate\Support\ServiceProvider;
final class CatalogServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->mergeConfigFrom(__DIR__.'/../config/catalog.php', 'catalog');
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"name": "acme/panda-catalog",
"require": {
"php": "^8.2",
"chocoalano/panel": "^0.1"
},
"autoload": {
"psr-4": {
"Acme\\Catalog\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Acme\\Catalog\\CatalogServiceProvider"
]
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Composer package discovery mendaftarkan service provider otomatis. Object plugin tetap dipasang manual pada panel provider. Package yang hanya ter-install tidak seharusnya diam-diam menambahkan resource dan navigation ke Admin Panel tanpa satu baris application code yang menyatakan hal tersebut.
Struktur directory yang disarankan
packages/catalog/
├── composer.json
├── resources/
│ └── js/
│ └── Widgets/
│ └── StockGauge.vue
└── src/
├── CatalogPlugin.php
├── CatalogServiceProvider.php
├── Resources/
│ └── Products/
│ ├── ProductResource.php
│ ├── Forms/ProductForm.php
│ └── Tables/ProductsTable.php
└── Widgets/
└── StockGauge.php2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Untuk plugin yang hanya hidup di dalam aplikasi dan tidak akan menjadi package, app/Panels/Plugins/ adalah lokasi yang wajar.
Test
<?php
declare(strict_types=1);
use App\Panels\Admin\Resources\Products\ProductResource;
use App\Panels\Plugins\CatalogPlugin;
use Illuminate\Support\Facades\File;
use PandaPanel\Core\Panel;
use PandaPanel\Exceptions\PanelRegistrationException;
use PandaPanel\Plugins\PluginCompatibility;
use PandaPanel\Plugins\PluginMetadata;
it('configures the panel through the panel\'s own API', function (): void {
$panel = Panel::make('plug')->path('plug')->plugins([CatalogPlugin::make()]);
expect($panel->getResources())->toContain(ProductResource::class)
->and($panel->getNavigationGroups())->toContain('Catalog');
});
it('is configurable, so one plugin can be two shapes', function (): void {
$bare = Panel::make('plug-bare')->path('plug-bare')->plugins([
CatalogPlugin::make()->withWidgets(false)->group(null),
]);
expect($bare->getNavigationGroups())->not->toContain('Catalog');
});
it('takes its id from its class name and can be looked up by it', function (): void {
$panel = Panel::make('plug-ask')->path('plug-ask')->plugins([CatalogPlugin::make()]);
expect(CatalogPlugin::make()->id())->toBe('catalog')
->and($panel->hasPlugin('catalog'))->toBeTrue()
->and($panel->plugin('catalog'))->toBeInstanceOf(CatalogPlugin::class);
});
it('refuses two plugins claiming one id', function (): void {
expect(fn () => Panel::make('plug-dupe')->path('plug-dupe')->plugins([
CatalogPlugin::make(),
CatalogPlugin::make(),
]))->toThrow(PanelRegistrationException::class, 'claim the id');
});
it('runs register while the panel is being built, and boot later', function (): void {
$panel = Panel::make('plug-phase')->path('plug-phase')->plugins([CatalogPlugin::make()]);
// register() has run: the resource is there.
expect($panel->getResources())->toContain(ProductResource::class)
// boot() has not: work needing the container, the user, or a URL must
// not happen while the panel is still being configured.
->and($panel->getCssHooks())->not->toHaveKey('page');
$panel->boot();
expect($panel->getCssHooks()['page'] ?? '')->toContain('catalog-page');
});
it('publishes a plugin\'s components into the application tree', function (): void {
Panel::make('plug-publish')->path('plug-publish')->plugins([CatalogPlugin::make()]);
$destination = resource_path('js/pages/Panels/Catalog/StockGauge.vue');
File::delete($destination);
$this->artisan('panel:publish', ['plugin' => 'catalog'])->assertSuccessful();
expect(File::exists($destination))->toBeTrue();
File::deleteDirectory(resource_path('js/pages/Panels/Catalog'));
});
it('never overwrites a published file without being told to', function (): void {
$destination = resource_path('js/pages/Panels/Catalog/StockGauge.vue');
File::ensureDirectoryExists(dirname($destination));
File::put($destination, '<!-- edited by the application -->');
$this->artisan('panel:publish', ['plugin' => 'catalog'])->assertSuccessful();
expect(File::get($destination))->toBe('<!-- edited by the application -->');
File::deleteDirectory(resource_path('js/pages/Panels/Catalog'));
});
it('refuses a plugin built against a version that is no longer installed', function (): void {
$plugin = new class extends PandaPanel\Plugins\Plugin
{
public function register(Panel $panel): void {}
public function metadata(): PluginMetadata
{
return new PluginMetadata(name: 'Demanding', requiresPanel: '^2.0');
}
};
// The version is passed in: the check is skipped when this framework has
// no real version to compare against, which is the case in a checkout.
expect(fn () => PluginCompatibility::assert($plugin, 'admin', '1.4.1'))
->toThrow(PanelRegistrationException::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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
tests/Feature/Panel/PluginTest.php adalah test framework terhadap fixture ReportingPlugin dan RecordingPlugin.
php artisan test --compact --filter=Plugin
php artisan panel:plugins2
Hal yang perlu diperhatikan
register()berjalan pada setiap request. Bukan hanya request Panel. Jangan melakukan query, resolve route, atau membaca current user di dalamnya.boot()berjalan per request dan harus idempotent. Operasi append akan terus menambahkan state pada long-lived process seperti Octane.- Plugin diregistrasikan manual. Tidak ada plugin discovery secara sengaja. Installed package tidak boleh menambah resource ke Admin Panel tanpa explicit application code.
- Plugin diproses mengikuti urutan array. Plugin dapat melihat apa yang diregistrasikan plugin sebelumnya, tetapi bergantung pada hal ini membuat install order menjadi penting.
in()menghasilkannulldi luar request Panel.panel()juganullpada console command atau queued job kecuali current Panel diset manual.publishes()mengembalikan absolute path. Gunakan__DIR__pada source danresource_path()pada destination. Relative source akan dilaporkan tidak ada.- Published component tetap membutuhkan build.
panel:publishhanya menyalin file;import.meta.globbaru melihatnya setelah Vite dijalankan kembali. requiresPanelsengaja dilewati pada checkout. Constraint baru benar-benar ditegakkan terhadap installed release. Test lokal yang ingin menguji constraint harus memberikan installed version secara eksplisit.