chocoalano/panel

An Inertia + Vue admin panel framework for Laravel: resources, tables, forms, infolists, widgets, actions and multi-panel routing.

Maintainers

Package info

github.com/chocoalano/panda-panel

pkg:composer/chocoalano/panel

Transparency log

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.4 2026-08-16 02:33 UTC

This package is auto-updated.

Last update: 2026-08-16 02:37:30 UTC


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 own canAccess. 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.