make:panel-widget
Generates a dashboard widget of one of four kinds: stats, table, chart, or custom. Reach for it when a panel needs a figure, a short table, a graph, or a bespoke Vue component on a dashboard or on a page.
php artisan make:panel-widget UserStats --panel=Admin --type=statsINFO Created [app/Panels/Admin/Widgets/UserStats.php]The panel's discoverWidgets() path already covers app/Panels/Admin/Widgets, so the widget appears on the panel dashboard on the next request.
Signature
make:panel-widget
{name : The widget class name}
{--panel= : The panel it belongs to}
{--type=stats : stats, table, chart, or custom}
{--force}2
3
4
5
| Argument / option | Default | Effect |
|---|---|---|
name | required | Studly-cased. |
--panel= | required | The panel to generate into, studly-cased. Omitting it fails the command. |
--type= | stats | One of stats, table, chart, custom. Anything else fails and writes nothing. |
--force | off | Overwrite files that already exist. |
php artisan make:panel-widget UserStats --panel=Admin --type=stats
php artisan make:panel-widget RecentUsers --panel=Admin --type=table
php artisan make:panel-widget UserGrowth --panel=Admin --type=chart
php artisan make:panel-widget ServerHealth --panel=Admin --type=custom2
3
4
An unknown type is refused rather than guessed at:
ERROR Unknown widget type [hologram]. Valid types are: stats, table, chart, custom.What each type writes
--type | Base class | PHP file | Vue file |
|---|---|---|---|
stats | PandaPanel\Widgets\StatsWidget | app/Panels/{Panel}/Widgets/{Class}.php | — |
table | PandaPanel\Widgets\TableWidget | same | — |
chart | PandaPanel\Widgets\ChartWidget | same | — |
custom | PandaPanel\Widgets\CustomWidget | same | resources/js/pages/Panels/{Panel}/Widgets/{Class}.vue |
Only custom gets a Vue file, and it is not optional for that type: a custom widget without its component renders the fallback rather than anything you wrote. The other three are drawn by components the package publishes.
What every widget inherits
PandaPanel\Widgets\Widget is the base of all four:
| Property | Type | Default | Meaning |
|---|---|---|---|
$sort | int | 0 | Order on the dashboard, ascending. |
$columnSpan | int|string|array<string, int|string> | 1 (['default' => 1, 'md' => 2, 'lg' => 2, 'xl' => 2] on table and chart) | Grid width, per breakpoint. |
$lazy | bool | false | Deliver as a deferred Inertia prop so the dashboard paints first. |
$heading | ?string | null | Heading above the widget. |
$description | ?string | null | Line under the heading. |
$pollingInterval | ?int | null | Seconds between self-refreshes. Null means never. |
use PandaPanel\Widgets\StatsWidget;
final class UserStats extends StatsWidget
{
protected static int $sort = 10;
protected static bool $lazy = true;
protected static ?string $heading = 'Accounts';
protected static ?string $description = 'Everyone who has ever signed up.';
protected static ?int $pollingInterval = 60;
// ...
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
--type=stats
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Widgets;
use PandaPanel\Widgets\StatsWidget;
use PandaPanel\Widgets\Support\Stat;
final class UserStats extends StatsWidget
{
protected static int $sort = 0;
/**
* Use aggregates. Hydrating a collection to count it is how a dashboard
* becomes the slowest page in the application.
*
* @return list<Stat>
*/
public function stats(): array
{
return [
Stat::make('Example', 0),
];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
stats() is the one abstract method. A Stat is built fluently:
use App\Models\User;
use PandaPanel\Widgets\Enums\StatColor;
use PandaPanel\Widgets\Support\Stat;
public function stats(): array
{
return [
Stat::make('Users', User::query()->count())
->description('All time')
->icon('users')
->color(StatColor::Info)
->trend('up', 12.5)
->chart([4, 9, 6, 11, 14])
->format(suffix: ' accounts')
->url('/admin/users'),
];
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| Method | Signature |
|---|---|
make | static make(string $label, string|int|float $value): self |
description | description(string $description): self |
icon | icon(string $icon): self |
color | color(StatColor $color): self |
trend | trend(string $direction, float $value): self |
chart | chart(array $values): self |
url | url(string $url): self |
format | format(?string $prefix = null, ?string $suffix = null, ?int $decimals = null): self |
--type=table
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Widgets;
use Illuminate\Support\Collection;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\TableSchema;
use PandaPanel\Widgets\TableWidget;
final class RecentUsers extends TableWidget
{
protected static int $sort = 0;
public function table(TableSchema $table): TableSchema
{
return $table->columns([
TextColumn::make('id')->label('ID'),
]);
}
/**
* @return Collection<int, covariant \Illuminate\Database\Eloquent\Model>
*/
public function rows(): Collection
{
return new Collection;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
This does not run as generated. TableWidget declares two abstract methods, table() and query(), and the stub implements table() and a rows() that overrides nothing:
PHP Fatal error: Class App\Panels\Admin\Widgets\RecentUsers contains 1 abstract
method and must therefore be declared abstract or implement the remaining
methods (PandaPanel\Widgets\TableWidget::query)2
3
Replace rows() with query():
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Tables\Columns\DateTimeColumn;
use PandaPanel\Tables\Columns\TextColumn;
use PandaPanel\Tables\Enums\SortDirection;
use PandaPanel\Tables\TableSchema;
use PandaPanel\Widgets\TableWidget;
final class RecentUsers extends TableWidget
{
protected static int $sort = 20;
protected static string $emptyMessage = 'No one has signed up yet.';
protected static int $perPage = 5;
public function table(TableSchema $table): TableSchema
{
return $table
->columns([
TextColumn::make('name')->searchable()->sortable(),
DateTimeColumn::make('created_at')->label('Joined')->relative()->sortable(),
])
->defaultSort('created_at', SortDirection::Descending);
}
/**
* @return Builder<User>
*/
public function query(): Builder
{
return User::query()->select(['id', 'name', 'created_at']);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
A query rather than a collection, because the table builder searches, sorts and pages it — the same TableSchema and TableQuery a resource index uses. $perPage defaults to 5, and $emptyMessage to 'Nothing to show yet.'.
--type=chart
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Widgets;
use PandaPanel\Widgets\ChartWidget;
use PandaPanel\Widgets\Support\ChartSeries;
final class UserGrowth extends ChartWidget
{
protected static int $sort = 0;
/**
* Set to true when the query is slow enough that the dashboard should
* paint before it finishes.
*/
protected static bool $lazy = false;
protected static string $variant = 'bar';
/**
* @return list<string>
*/
public function labels(): array
{
return [];
}
/**
* @return list<ChartSeries>
*/
public function series(): 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
25
26
27
28
29
30
31
32
33
34
35
36
37
This does not run as generated either. ChartWidget::$variant is typed PandaPanel\Widgets\Enums\ChartVariant, and a subclass may not redeclare a typed static property with a different type:
PHP Fatal error: Type of App\Panels\Admin\Widgets\UserGrowth::$variant must be
PandaPanel\Widgets\Enums\ChartVariant (as in class PandaPanel\Widgets\ChartWidget)2
Either delete the line — ChartVariant::Bar is already the default — or write the enum:
use PandaPanel\Widgets\Enums\ChartVariant;
protected static ChartVariant $variant = ChartVariant::Area;2
3
| Case | Value |
|---|---|
ChartVariant::Bar | bar |
ChartVariant::Line | line |
ChartVariant::Area | area |
ChartVariant::Doughnut | doughnut |
A working chart:
use App\Models\User;
use PandaPanel\Widgets\ChartWidget;
use PandaPanel\Widgets\Enums\ChartVariant;
use PandaPanel\Widgets\Enums\StatColor;
use PandaPanel\Widgets\Support\ChartOptions;
use PandaPanel\Widgets\Support\ChartSeries;
final class UserGrowth extends ChartWidget
{
protected static ChartVariant $variant = ChartVariant::Area;
protected static int $maxHeight = 200;
public function options(): ChartOptions
{
return ChartOptions::make()->legend(false)->curved()->filled();
}
/**
* @return list<string>
*/
public function labels(): array
{
return ['Jan', 'Feb', 'Mar'];
}
/**
* @return list<ChartSeries>
*/
public function series(): array
{
return [
ChartSeries::make('Sign-ups', [4, 9, 6])->color(StatColor::Info),
];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
ChartSeries::make(string $label, array $values): self and ChartSeries::color(StatColor $color): self are the whole series API. $maxHeight is 220 pixels by default: a chart with no height of its own grows with its container, and a dashboard of them is a page of charts nobody can compare.
--type=custom
php artisan make:panel-widget ServerHealth --panel=Admin --type=customINFO Created [app/Panels/Admin/Widgets/ServerHealth.php]
INFO Created [resources/js/pages/Panels/Admin/Widgets/ServerHealth.vue]2
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Widgets;
use PandaPanel\Widgets\CustomWidget;
final class ServerHealth extends CustomWidget
{
protected static int $sort = 0;
protected static string $component = 'Panels/Admin/Widgets/ServerHealth';
/**
* @return array<string, mixed>
*/
public function data(): array
{
return [];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script setup lang="ts">
defineProps<{
// Mirror whatever the PHP widget's data() returns.
}>();
</script>
<template>
<div class="flex h-full flex-col gap-3 rounded-lg border p-4">
<h3 class="text-sm font-medium">ServerHealth</h3>
</div>
</template>2
3
4
5
6
7
8
9
10
11
Whatever data() returns arrives as the component's props:
public function data(): array
{
return ['queue' => 12, 'failed' => 0];
}2
3
4
<script setup lang="ts">
defineProps<{
queue: number;
failed: number;
}>();
</script>2
3
4
5
6
The component name is the path below resources/js/pages/, and custom widget components resolve through a build-time import.meta.glob over resources/js/pages/Panels/**/Widgets/*.vue. A component outside that shape is not in the bundle and cannot be reached, however its name arrives.
Custom stubs
php artisan vendor:publish --tag=panda-panel-stubs| Stub | Used for | Placeholders |
|---|---|---|
stubs/panel/widget-stats.stub | --type=stats | panel, class, component |
stubs/panel/widget-table.stub | --type=table | panel, class, component |
stubs/panel/widget-chart.stub | --type=chart | panel, class, component |
stubs/panel/widget-custom.stub | --type=custom | panel, class, component |
stubs/panel/widget-component.stub | the Vue file for --type=custom | label |
component is only used by the custom stub; the other three ignore it. Publishing is also how to fix the chart and table stubs once for the whole project rather than per generated file.
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. |
--type unknown | 1, and nothing is written |
Gotchas
- The generated chart and table widgets do not compile. Both are described above with the one-line fix. The generator's test asserts the class extends the right base and that Pint passes; neither of those loads the class.
{{ label }}in the generated Vue file is a stub placeholder, not a binding. It is replaced with the class name at generation time, and there is nolabelprop behind it.- Widgets are discovered, not registered. They must live under a directory the panel's
discoverWidgets()names, and a cached manifest hides new ones entirely — runphp artisan panel:clear. - A discovered widget goes on the panel dashboard. To put one on a specific page instead, list it in that page's
widgets(). - A custom widget needs a frontend build. The
.vuefile is a new source file; untilnpm run devornpm run buildhas seen it, the widget renders the fallback. - Icons in stats need registering.
Stat::icon('users')draws nothing untilphp artisan panel:iconshas putusersin the registry. --lazyis not an option. Laziness is a property on the class; the chart stub is the only one that writes it out, and it writesfalse.