make:panel-page
Generates a standalone panel page — a screen that belongs to a panel but is not one of a resource's CRUD pages. Reach for it for a settings screen, a second dashboard, a report, or anything else that needs a URL, a navigation entry, and its own props.
php artisan make:panel-page Reports --panel=AdminINFO Created [app/Panels/Admin/Pages/Reports.php]The panel's discoverPages() path already covers app/Panels/Admin/Pages, so /admin/reports answers on the next request and a "Reports" entry appears in the sidebar.
Signature
make:panel-page
{name : The page class name}
{--panel= : The panel it belongs to}
{--component : Also generate a Vue component for the page}
{--force}2
3
4
5
| Argument / option | Default | Effect |
|---|---|---|
name | required | Studly-cased. reports, Reports and report-summary all become valid class names. |
--panel= | required | The panel to generate into, studly-cased. Omitting it fails the command. |
--component | off | Sets $component to this page's own Vue file and generates that file. |
--force | off | Overwrite files that already exist. |
php artisan make:panel-page Reports --panel=Admin
php artisan make:panel-page Settings --panel=Admin --component
php artisan make:panel-page Settings --panel=Admin --component --force2
3
The generated page
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Pages;
use PandaPanel\Pages\Page;
final class Reports extends Page
{
protected static ?string $navigationIcon = 'file-text';
protected static int $navigationSort = 0;
protected static string $component = 'panel/Page';
/**
* @return array<string, mixed>
*/
public function props(): array
{
return [];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Everything else is inherited and derived:
| Behaviour | Where it comes from | Result for Reports |
|---|---|---|
| URL slug | Page::slug(), kebab of the class basename | reports |
| Route path | Page::routePath(), the slug under the cluster prefix when there is one | reports |
| Route name | Page::routeName() | panel.admin.pages.reports — read it with Reports::routeName() |
| Title | Page::title(), Str::headline() of the class basename | Reports |
| Heading | Page::heading(), the title unless overridden | Reports |
| Access | Page::canAccess() | true — override to restrict |
use App\Panels\Admin\Pages\Reports;
Reports::slug(); // 'reports'
Reports::url(); // '/admin/reports'
Reports::routeName(); // 'panel.admin.pages.reports'2
3
4
5
Without --component: the generic renderer
$component = 'panel/Page' points at the component the package publishes at resources/js/pages/panel/Page.vue. It renders the page heading and subheading, the header actions and the widgets, inside PanelLayout — which draws the breadcrumbs from the same page metadata. A page filter bar is the one thing it does not draw: filterSchema() controls are rendered by the dashboard component, so a standalone page that wants them on screen needs its own --component.
A page with nothing bespoke to draw therefore needs no Vue file at all. That is the default because most standalone pages are a heading and some widgets:
use App\Panels\Admin\Widgets\UserStats;
use PandaPanel\Pages\Page;
final class Reports extends Page
{
protected static ?string $navigationIcon = 'chart-line';
protected static string $component = 'panel/Page';
/**
* @return list<class-string<\PandaPanel\Widgets\Widget>>
*/
public function widgets(): array
{
return [UserStats::class];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
With --component: your own Vue file
php artisan make:panel-page Settings --panel=Admin --componentINFO Created [app/Panels/Admin/Pages/Settings.php]
INFO Created [resources/js/pages/Panels/Admin/Pages/Settings.vue]2
The PHP side changes in exactly one place:
protected static string $component = 'Panels/Admin/Pages/Settings';and the Vue file is a working starting point:
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import PageHeader from '@/panel/components/PageHeader.vue';
import type { PageMetadata } from '@/panel/types/page';
defineProps<{
page: PageMetadata;
}>();
</script>
<template>
<Head :title="page.title" />
<div class="flex flex-col gap-6">
<PageHeader :heading="page.heading" :subheading="page.subheading" />
</div>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
The component name is the path below resources/js/pages/, without the extension, which is how Inertia resolves a page component. Panels/Admin/Pages/Settings therefore means resources/js/pages/Panels/Admin/Pages/Settings.vue.
Passing your own props
props() is merged into the Inertia response alongside the framework's own page, widgets, widgetData and filters keys:
use App\Models\User;
use PandaPanel\Pages\Page;
final class Settings extends Page
{
protected static string $component = 'Panels/Admin/Pages/Settings';
/**
* @return array<string, mixed>
*/
public function props(): array
{
return [
'userCount' => User::query()->count(),
];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts">
import type { PageMetadata } from '@/panel/types/page';
defineProps<{
page: PageMetadata;
userCount: number;
}>();
</script>2
3
4
5
6
7
8
Serializable values only — the props cross to the browser as JSON.
Where the Vue file goes
resources/js/pages/Panels/{Panel}/Pages/{Class}.vue, and the Panels segment is configurable:
// config/panda-panel.php
'frontend' => [
'panel_path' => 'js/panel',
'pages_path' => 'js/pages/Panels',
],2
3
4
5
6
pages_path is read through PandaPanel\Support\FrontendPaths::pages(), which both this generator and make:panel-widget use. It is also the root of the import.meta.glob allowlists the frontend resolves component names through, so moving it means changing those globs too.
Custom stubs
php artisan vendor:publish --tag=panda-panel-stubs| Stub | Written to | Placeholders |
|---|---|---|
stubs/panel/page.stub | app/Panels/{Panel}/Pages/{Class}.php | panel, class, component |
stubs/panel/page-component.stub | resources/js/pages/Panels/{Panel}/Pages/{Class}.vue | none |
The page-component stub takes no placeholders — it is copied verbatim.
Exit codes
| Outcome | Code |
|---|---|
| At least one file created | 0 |
| Every file already existed and was skipped | 1 |
--panel missing | 1, with The --panel option is required. |
Gotchas
- The page is discovered, not registered. It must be under a directory the panel's
discoverPages()names. A page moved elsewhere vanishes silently. - A cached manifest hides a new page. No route, no navigation entry, no error. Run
php artisan panel:clear. --componentalone does not rebuild the frontend. The generated.vuefile is a new source file; runnpm run devornpm run buildbefore the page will render it.- Removing
--componentlater means editing$componentby hand. Deleting the Vue file is not enough — the PHP class still names it, and an unresolvable component name renders nothing. - The navigation icon must be in the icon registry. The stub uses
file-text; any name you write instead needsphp artisan panel:iconsbefore it will draw. An unregistered name renders nothing at all, with no error. - Two pages in one panel may not share a slug. The slug comes from the class basename, so
Admin\Pages\Reportsand a cluster page also calledReportscollide unless one sets$slug. A$clusterdoes not help: it prefixes the route path and leavesslug()alone, soPageRegistrystill throwsduplicatePageSlug()at boot.