chocoalano/panel
| Install | |
|---|---|
composer require chocoalano/panel |
|
| Latest Version: | v0.1.8 |
| PHP: | ^8.2 |
| License: | MIT |
| Last Updated: | Aug 19, 2026 |
| Links: | GitHub · Packagist |
Panda Panel
An admin panel framework for Laravel, built on Inertia and Vue. Resources, tables, forms, infolists, widgets, actions, relation managers, global search, imports and exports, a notification centre, and as many panels as an application needs — each with its own path, navigation, middleware and access rule.
Every screen is a real Vue component in your application's resources/js, not a black box:
published on install, in your repository, in your build, and editable.
Requirements
- PHP 8.2+ (8.2 through Laravel 12, which is the newest Laravel that runs on it)
- Laravel 12 or 13
- Inertia 3 with Vue 3, and Tailwind 4
- Laravel Fortify 1.37.2+
- A Laravel Vue starter kit, or the nineteen frontend modules one provides
The full matrix — including what is deliberately not supported, and why — is in docs/getting-started/compatibility.md.
Documentation
docs/pages.md is the entry point: every page in learning order. docs/sidebar.md is the same tree collapsed to one entry per section. The fast paths from here:
| You want to | Start at |
|---|---|
| Install it and see a panel | Installation → Opening your first panel |
| Understand how it works | Architecture, Request lifecycle |
| Build a CRUD screen | Creating resources, Forms, Tables |
| Ship it | Production checklist |
| Fix something | Troubleshooting |
| Run it in another language | Translations |
Installation
composer require chocoalano/panel
php artisan panel:install
panel:install publishes the config and the frontend, scaffolds a first panel, registers it,
checks what the frontend still needs, and offers to create a user who can sign in. It finishes by
naming anything it could not do for you — and on a Laravel Vue starter kit application, that list
is usually empty.
Signing in afterwards lands in the panel rather than on the starter kit's placeholder dashboard:
/dashboard redirects to the first panel the user can enter. Your route, its name, and its page
component are all left where they are — see home_redirect in Configuration to
turn it off.
Each step is available on its own:
php artisan vendor:publish --tag=panda-panel-config
php artisan vendor:publish --tag=panda-panel-assets
php artisan vendor:publish --tag=panda-panel-migrations
php artisan vendor:publish --tag=panda-panel-stubs
php artisan make:panel Admin
php artisan panel:user
Panels are listed rather than discovered — registration order decides where a user lands when the request does not name a panel — so the installer writes the line into the file you can see:
// config/panda-panel.php
'panels' => [
App\Panels\Admin\AdminPanelProvider::class,
],
Frontend
The published components are Vue 3 with Tailwind 4. panel:install prints the exact npm install
line for your project, read from the package's own package.json so the two cannot disagree — and
lists only what you are actually missing.
Panel components resolve through import.meta.glob over resources/js/pages/Panels/** — a
build-time allowlist by design. A component the build never saw is a name that cannot resolve, so
custom columns, widgets and pages live in your own tree rather than in a package.
Nineteen modules the components import are yours, not the package's. @/routes/* and
@/actions/* are generated by Wayfinder from your own
routes; the rest — @/components/UserMenuContent.vue, @/composables/useTwoFactorAuth, eight more
— are where a project keeps its own account UI. A starter kit has all of them, and
docs/frontend/host-modules.md lists every one.
panel:install names the ones you are missing.
Defining a panel
namespace App\Panels\Admin;
use Illuminate\Contracts\Auth\Authenticatable;
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelProvider;
use PandaPanel\Pages\Dashboard;
final class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->path('admin')
->name('Administrator')
->icon('shield')
->auth()
->navigationGroups(['User Management', 'System'])
->dashboards([Dashboard::class])
->discoverResources(app_path('Panels/Admin/Resources'))
->discoverPages(app_path('Panels/Admin/Pages'))
->discoverWidgets(app_path('Panels/Admin/Widgets'))
->canAccess(static fn (?Authenticatable $user): bool => $user?->is_admin === true);
}
}
The panel itself is registered explicitly; the classes inside it are discovered.
A working two-panel application — resources, forms, tables, infolists, imports, exports, widgets, custom pages, and the policies behind them — is in examples/. It is also what the test suite runs against, so it cannot drift out of date.
Generators
php artisan make:panel Admin
php artisan make:panel-resource Product --panel=Admin
php artisan make:panel-page Reports --panel=Admin
php artisan make:panel-widget Revenue --panel=Admin --type=stats
php artisan make:panel-relation-manager variants --panel=Admin --resource=Product
php artisan panel:user --name=Ada --email=ada@example.com
Every generator reads from stubs/panel/. Publish them with
vendor:publish --tag=panda-panel-stubs to change what your project scaffolds; the package's own
are used until you do.
Production
php artisan panel:cache # discover once, at deploy time, instead of per request
php artisan panel:clear
php artisan panel:icons # rewrite the icon registry from the icons your panels declare
php artisan panel:plugins # what is installed, on which panel, at which version
Upgrading the frontend
The panel's Vue components live in your resources/js — which is what makes them
debuggable and what the build-time component registries require. The cost is that
composer update cannot improve a file you now own, and vendor:publish cannot help:
without --force it updates nothing, with --force it overwrites your edits, and it
has no way to tell the two apart.
panel:assets does, because .panel-assets.json records what each file looked like
when you published it:
php artisan panel:assets # what is behind, what you changed, what conflicts
php artisan panel:assets --update # write only the files you have never touched
npm run build
| On disk | In package | Reported as | --update |
|---|---|---|---|
| unchanged | unchanged | current | — |
| unchanged | changed | out of date | written |
| changed | unchanged | yours | left alone |
| changed | changed | conflict | never written |
A conflict is named by path and left exactly as it is. Diff it against
vendor/chocoalano/panel, merge by hand, then --force. Commit
.panel-assets.json: it is the record of what your application published, the same way
composer.lock records what it installed.
panel:cache is registered as an optimize hook, so php artisan optimize includes it beside the
config and route caches. Panel routes point at controllers rather than closures, so route:cache
keeps working.
Configuration
config/panda-panel.php:
| Key | Default | Description |
|---|---|---|
panels |
[] |
Panel providers to register, in order. |
register_routes |
true |
Register one route group per panel during boot. |
register_web_middleware |
true |
Add the panel's four web middleware to the group. |
register_guest_redirect |
true |
Send guests who open a panel URL to that panel's own login. Turn off if you set your own redirectGuestsTo. |
home_redirect.enabled |
true |
Send a signed-in user who lands on the starter kit's dashboard into the first panel they can enter. Turn off to keep your own screen. |
home_redirect.paths |
['dashboard'] |
The Request::is() patterns that redirect. A path a panel is mounted on is ignored. |
load_migrations |
true |
Run the package migrations from the package. Turn off if you publish them. |
frontend.panel_path |
js/panel |
Where vendor:publish puts the panel's components. |
frontend.pages_path |
js/pages/Panels |
Where the generators scaffold components. |
Panels themselves are configured in code — path, domain, middleware, navigation, branding, access — because those are decisions with logic in them.
Languages
The package ships its own strings in English and Indonesian, and follows whatever locale the application sets:
app()->setLocale('id');
To let each reader choose instead, name the languages and a switcher appears in the panel header and on the login screen:
// config/panda-panel.php
'locales' => [
'en' => 'English',
'id' => 'Bahasa Indonesia',
],
Numbers and dates follow too — 1.234,56 and 5 Jan 2026 in Indonesian, from
lang/{locale}/formats.php. Not through ext-intl, which this package does not require.
Nothing has to be published or configured. Buttons, confirmations, empty states, error toasts and
the two-factor email all follow, and a locale the package does not ship falls back to English
rather than rendering raw keys. Reword a sentence with
php artisan vendor:publish --tag=panda-panel-translations, or add a third locale under
lang/vendor/panda-panel/.
Your own names follow too. A column named created_at renders as "Created At" through
Str::headline() — English, in every locale. Name it once and every table, form, infolist,
filter and export column in every panel follows:
// lang/id/panel.php
return [
'fields' => ['created_at' => 'Dibuat pada'],
'resources' => ['User' => 'Pengguna'],
];
->label() still wins where it is set, and an application with no such file behaves exactly as it
did.
The Vue components follow the same locale. SharePanelData puts one dictionary on the page and
useTranslator() reads it — no vue-i18n, because these components are published into your
application and a runtime dependency would be a line every application has to keep in step:
<script setup lang="ts">
import { useTranslator } from '@/composables/useTranslator';
const { t, locale } = useTranslator();
</script>
<template>
<span>{{ t('tables.rows_per_page') }}</span>
</template>
See Translations.
What the panel asks of your user model
Nothing that a Laravel starter kit does not already provide:
Illuminate\Notifications\Notifiable— for the notification centre.Laravel\Fortify\TwoFactorAuthenticatable— for the security settings page.- Optionally
PandaPanel\Contracts\PanelUser— a rule about the account ("suspended", "no tenant") that applies to every panel at once, asked alongside each panel's owncanAccess. Both must agree. - For a tenant-scoped panel,
PandaPanel\Contracts\HasPanelTenants— which tenants this account may enter, and whether it may enter a given one. See Tenancy.
Authorization
Every resource ability resolves to an ordinary Laravel policy: canViewAny() asks viewAny,
canEdit() asks update, and so on. Nothing in a policy needs to know a panel exists.
A freshly generated resource therefore 403s until its model has a policy — the gate is asked and answers no. That is the intended default: a panel that showed every record because nobody had written a rule yet would be worse.
A panel may demand that they be answerable:
$panel->strictAuthorization();
Under that, a model with no policy — or a policy with no method for the ability — raises rather than reading as a working deny. A missing policy that silently refuses everything and a missing policy that silently allows everything are both bugs; this makes them loud.
Tenancy
A panel can be scoped to a tenant. What the framework owns is the part that is the same in every project — identify, authorize, bind, scope — and nothing else: it does not create databases, switch connections, or decide what a subdomain means.
use Illuminate\Http\Request;
$panel->tenant(Team::class, fn (Request $request) => Team::query()
->where('slug', $request->route('team'))
->first());
final class InvoiceResource extends Resource
{
// The relationship leading to the tenant. Naming one is the whole opt-in;
// a resource that names none is not scoped, which is right for a global
// table and for a database-per-tenant arrangement.
protected static ?string $tenantRelationship = 'team';
}
Your user model implements HasPanelTenants — one method for the switcher's list, one for the
per-request check, and deliberately not one derived from the other. A scoped resource asked
outside a tenant raises rather than running unscoped, so console and queued work enters one
explicitly:
Tenancy::for($tenant, fn () => InvoiceResource::query()->count());
Tell the panel how a tenant is addressed and the header grows a switcher, filtered to the tenants this user may actually enter:
$panel->tenantUrlUsing(fn (Team $team) => "https://{$team->slug}.example.com/app");
Without that the switcher does not render — identification is your application's, so reversing it into a URL is too, and a switcher whose entries went nowhere would be worse than none.
docs/tenancy/stancl-tenancy.md is the guide for putting this
together with stancl/tenancy; docs/tenancy/
is the rest of it.
Plugins
A plugin is a reusable bundle of panel configuration, applied through the panel's own public API and nothing else. Three phases, and which one a piece of work belongs in is the thing to get right:
| Phase | When | What belongs there |
|---|---|---|
register() |
while the panel is being configured | resources, pages, widgets, navigation groups |
boot() |
after the panel is resolved, per request | anything needing the container, the user, or a URL |
publishes() |
never automatically — only panel:publish |
files the plugin copies into the application |
register() runs for every request, including the ones that never touch a panel, so work
there that queries is work every request pays for. boot() runs before the panel's own
bootUsing() callbacks, so an application always gets the last word over a plugin it installed.
A plugin shipped as a package says what it is and what it needs, and gets its version read from composer rather than restating it:
public function metadata(): PluginMetadata
{
return new PluginMetadata(
name: 'Billing',
package: 'acme/panda-billing',
requiresPanel: '^1.2',
);
}
requiresPanel is checked when the plugin registers, so a plugin built against an older framework
says so by name — instead of failing later with Call to undefined method Panel::whatever(), which
names this framework rather than the plugin that asked for it. panel:plugins lists the lot.
Testing
The package ships helpers that go through the real schemas, queries and actions — the same ones its own 1,200-test suite uses. They are autoloaded, so a test needs no import and no base class:
panelTable(UserResource::class)->assertCanSeeRecord($user)->assertCount(2);
panelForm(UserResource::class)->assertFieldIsRequired('name');
panelTableActions(UserResource::class)->assertCanNotRun('purgeUnverified');
Every one goes through the real machinery. They are a nicer way to ask, never a second
implementation of the answer: a helper that computed its own idea of what a table shows would pass
while the table was broken. The classes behind them are PandaPanel\Testing\*, public for a test
that would rather hold one than chain from a free function.
docs/testing/helpers.md is the full reference — every helper, and docs/testing/setup.md is what a panel test needs before the first assertion.
Local development
composer install
composer test # pest
composer analyse # phpstan / larastan
composer format # pint
composer ci # all three, as CI runs them
The suite runs against Testbench with examples/ as the application: its user model, its panels, its policies, its routes.
The other half of this package is over 350 Vue and TypeScript files, which no PHP job can say anything about:
npm ci
npm run format:check
npm run lint
npm run typecheck # vue-tsc over every component
npm run test # vitest over the frontend's pure modules
npm run build # the real thing: does all of it compile together
npm run ci # all five, as CI runs them
Almost none of it ships — the Vite config, the tsconfig, the lint configs and
package-lock.json are all export-ignored, so composer require pulls none of them.
package.json is the deliberate exception: FrontendRequirements::npmPackages() reads it at
runtime from inside vendor/ to tell an application which npm packages the published components
import, so export-ignoring it made panel:install report nothing missing because it could not
look. package-lock.json stays out and CI runs npm ci against it here, because this
repository's toolchain has to be reproducible; an application never sees that lockfile and
installs from the version ranges instead.
The build needs nineteen modules the package does not ship — listed one by one in docs/frontend/host-modules.md. Minimal stand-ins live in frontend/host/, used only here — see that README for why each one is the application's rather than ours.
License
MIT. See LICENSE.md.
Related Packages
The open-source admin panel framework for Laravel, built for Inertia and Vue. De...
Server-driven admin panels for Laravel + Inertia.js. Define forms, tables, and p...
A powerful admin panel builder for Laravel using the VILT stack (Vue, Inertia, L...