chocoalano / panel
An Inertia + Vue admin panel framework for Laravel: resources, tables, forms, infolists, widgets, actions and multi-panel routing.
Requires
- php: ^8.2
- composer-runtime-api: ^2.2
- ext-json: *
- ext-zip: *
- composer/semver: ^3.0
- inertiajs/inertia-laravel: ^3.0
- laravel/fortify: ^1.37.2
- laravel/framework: ^12.0|^13.0
- symfony/finder: ^7.0|^8.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
README
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+
- A Laravel Vue starter kit, or the eighteen frontend modules one provides
The full matrix — including what is deliberately not supported, and why — is in docs/compatibility.md.
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.
Eighteen 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, six more
— are where a project keeps its own account UI. A starter kit has all of them.
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.
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/panel-tenancy.md is the guide for putting this together with
stancl/tenancy.
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,000-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.md is the full reference — every helper, and what is actually worth asserting about a panel.
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 337 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 build # the real thing: does all of it compile together npm run ci # all four, as CI runs them
None of it ships — package.json, the Vite config, the tsconfig and the lint configs are all
export-ignored, so composer require pulls none of them. package-lock.json is committed and
CI runs npm ci against it, 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 eighteen modules the package does not ship. 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.