An add-on for the panel is a normal Composer package with a Laravel service provider, plus a small npm package that compiles to a single JavaScript bundle. Nothing about it requires recompiling the panel itself — the panel loads the add-on’s bundle at runtime and calls back into it.
lunarphp/panel-addon-example is the reference implementation everything on this page is drawn from: fork it with composer create-project lunarphp/panel-addon-example my-addon and replace the example page, slot, and table column with the add-on’s own. tests/panel/Feature/ExampleAddonTest.php in the monorepo exercises this package’s real, unmodified source against the panel’s real Customers routes, so every snippet below is proven to integrate, not just to work in isolation.
Scaffolding the package
This package’s own composer.json looks like this:
The extra.laravel.providers entry lets Laravel’s package auto-discovery register the provider without the host app touching config/app.php. The provider itself (src/ExampleAddonServiceProvider.php) registers a Section with the panel, and a Vite module for the compiled bundle:
PanelManager::vite() is what makes the panel’s Blade entry emit a <script> tag for the add-on’s compiled bundle automatically — no panel changes required for a new add-on to ship its own JS. See Extending the Admin Panel for what a Section can register beyond the page and Vite module shown here.
The npm setup
Two packages are published to npm with each tagged Lunar release, and an add-on depends on them by version, never a file: path:
@lunarphp/panel — the panel’s layout and page components (PageHeader, DataTable, form inputs, overlays, and so on), plus their types.
@lunarphp/panel-vite-plugin — the build preset that compiles an add-on to a single IIFE bundle sharing the panel’s own Vue instance instead of bundling a second copy.
vite.config.js
@lunarphp/panel-vite-plugin forces output.format: 'iife' and externalizes vue (to the window.Vue global), @inertiajs/vue3 (to window.InertiaVue3), and @lunarphp/panel (to window.LunarPanelUI) — each published by the panel’s own frontend entry at startup. That is what lets the add-on’s bundle call into the panel’s Vue and Inertia runtimes and reuse its layout and page components instead of shipping second copies.
The add-on entry point
resources/js/addon.ts registers the add-on’s pages and slot components:
Pages are named {key}::Path/Name, matching the folder structure under resources/js/pages/ and the Inertia component name the add-on’s routes render — see Pages and navigation for how the two sides line up.
Register pages, components, layouts, and translations at the top level of the bundle — never inside window.LunarPanel.booting(). booting() callbacks run after the panel has mounted, which is too late: a page or slot component registered there is missing when Inertia resolves and first renders it on a hard load, and because registration is not reactive the slot never recovers. The panel holds its initial mount until DOMContentLoaded, by which point every add-on script has already run, so anything registered at the top level of a bundle is guaranteed to be in place in time. Reserve booting() for work that genuinely needs the mounted app.
Beyond registerPages(), registerComponents(), and booting(), the runtime exposes registerLayout(name, component) (a persistent layout an add-on page can opt into), registerTranslations(locale, namespace, messages) (push vue-i18n messages directly at runtime), and resolveExtensionComponent(name) (look up a namespaced component registered by any bundle). The typed contract for all of this is the LunarPanelRuntime interface, shipped as dist/runtime.d.ts in the @lunarphp/panel package.
Compiling the bundle
This produces a compiled IIFE plus a manifest in build/. Commit this directory — the host app’s public/ never runs a JS build step for the panel or its add-ons, so the compiled build/ is what gets published or symlinked into place.
Shipping the compiled assets
The __buildSourcePath passed to PanelManager::vite() in the service provider is what lets the panel manage the add-on’s assets without any panel-side configuration:
- Production:
php artisan vendor:publish --tag=example-addon-panel-assets --force copies the add-on’s build/ into public/vendor/lunar-panel/example-addon/. The panel registers a {key}-panel-assets publish tag for every module’s __buildSourcePath automatically; --tag=panel-all-assets publishes the panel’s own build plus every add-on in one pass.
- Local development:
php artisan lunar:panel:link symlinks the build/ directory instead, so a rebuild is picked up without re-publishing.
The panel’s Blade entry loops every registered Vite module and emits a <script>/<link> tag for each automatically — no panel changes are required for a new add-on to appear.
Installing the example add-on
composer require lunarphp/panel-addon-example to install it as-is, or composer create-project lunarphp/panel-addon-example my-addon to fork it as the starting point for a new add-on.
- Register the provider — auto-discovered via
composer.json’s extra.laravel.providers, or added manually.
npm install inside the package, then npm run build.
- Publish or symlink the compiled build as described above.
Troubleshooting
A registered page, slot, or column that doesn’t appear is almost always one of:
- A slot never renders because its zone prefix doesn’t match the target page’s actual route name (with the
panel. prefix stripped) — not a name guessed from what the page conceptually does.
- A page component is “not found” client-side because the
Inertia::render() name in the route doesn’t exactly match the key passed to window.LunarPanel.registerPages(), including the namespace:: prefix.
- Registration never runs because it happened inside
booting() instead of at the bundle’s top level.
See Troubleshooting for the full list, including the table-extension and settings-layout gotchas.