Widgets
A widget is a self-contained block of read-only summary on a panel page: a row of figures, a chart, a short table, or a Vue component of your own. You reach for one when a page should say something about records rather than list them — how many users signed up, what the last five orders were, whether the queue is backing up. Everything a widget knows it computes on the server; what crosses to the browser is a serialized description with no closures and no class names in it.
A minimal working example
Generate one:
php artisan make:panel-widget UserStats --panel=Admin --type=statsThen fill it in:
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Widgets;
use App\Models\User;
use PandaPanel\Widgets\Enums\StatColor;
use PandaPanel\Widgets\StatsWidget;
use PandaPanel\Widgets\Support\Stat;
final class UserStats extends StatsWidget
{
protected static int $sort = 10;
protected static ?string $heading = 'Accounts';
/**
* @return list<Stat>
*/
public function stats(): array
{
return [
Stat::make('Total users', User::query()->count())->icon('users'),
Stat::make('Verified', User::query()->whereNotNull('email_verified_at')->count())
->icon('shield')
->color(StatColor::Success),
];
}
}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
Point the panel at the directory it lives in:
use PandaPanel\Core\Panel;
public function panel(Panel $panel): Panel
{
return $panel
->id('admin')
->path('admin')
->discoverWidgets(app_path('Panels/Admin/Widgets'));
}2
3
4
5
6
7
8
9
GET /admin now renders the dashboard with the widget on it.
The four types
Each type is a base class with one thing to implement. The type is what the frontend switches on to pick a renderer.
| Type | Base class | You implement | type() returns |
|---|---|---|---|
| stats | PandaPanel\Widgets\StatsWidget | stats(): list<Stat> | WidgetType::Stats ('stats') |
| table | PandaPanel\Widgets\TableWidget | table(TableSchema): TableSchema, query(): Builder | WidgetType::Table ('table') |
| chart | PandaPanel\Widgets\ChartWidget | labels(): list<string>, series(): list<ChartSeries> | WidgetType::Chart ('chart') |
| custom | PandaPanel\Widgets\CustomWidget | $component, data(): array | WidgetType::Custom ('custom') |
PandaPanel\Widgets\Enums\WidgetType is a closed enum. Adding a case without a Vue renderer is a compile error on the frontend rather than an empty card, because WidgetRenderer.vue switches over the union exhaustively.
Each type has its own page: Stats, Tables, Charts, Custom Vue widgets.
Where widgets appear
Three places, and the difference between them is what the widget is handed.
use PandaPanel\Pages\Dashboard;
use PandaPanel\Pages\Page;
use PandaPanel\Resources\Pages\ListRecords;
use PandaPanel\Resources\Resource;
use PandaPanel\Widgets\Widget;
// 1. The panel dashboard: every widget in the panel's registry.
// PandaPanel\Pages\Dashboard::widgets() reads the registry.
// 2. Any standalone page, by naming classes.
final class Reports extends Page
{
/** @return list<class-string<Widget>> */
public function widgets(): array
{
return [RevenueChart::class];
}
}
// 3. A resource page, above or below its own content.
final class ListOrders extends ListRecords
{
/** @return list<class-string<Widget>> */
public function headerWidgets(): array
{
return [OrderStats::class];
}
/** @return list<class-string<Widget>> */
public function footerWidgets(): array
{
return [];
}
}
// Or declare resource-wide widgets once. The standard index page places
// getWidgets() in its header; individual pages can still override.
final class OrderResource extends Resource
{
/** @return list<class-string<Widget>> */
public static function getWidgets(): array
{
return [OrderStats::class];
}
}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
38
39
40
41
42
43
44
45
A dashboard or standalone page gives its widgets filters but no page context. A resource page gives its widgets page context and widget-level filters — see Filters and the notes below.
Registering widgets
/** @param list<class-string> $widgets */
public function widgets(array $widgets): self
public function discoverWidgets(string ...$paths): self2
3
4
use App\Panels\Admin\Widgets\UserStats;
$panel
->widgets([UserStats::class])
->discoverWidgets(app_path('Panels/Admin/Widgets'));2
3
4
5
Both lists are merged into one PandaPanel\Core\WidgetRegistry per panel, keyed by widget id, so a class that is both named and discovered is registered once. Discovery finds any class implementing PandaPanel\Contracts\WidgetContract under the given paths. See Discovery.
A widget's id is derived from its class name and nothing else:
public static function id(): string // Str::kebab(class_basename(static::class))App\Panels\Admin\Widgets\RecentUsers is recent-users. Two widgets in one panel producing the same id throw PanelRegistrationException::duplicateWidgetId() at registration — the id is what keys the deferred payload, the filter group, and a table widget's query-string namespace, so it has to be unique.
Read the registry directly when you need to:
use PandaPanel\Core\PanelManager;
app(PanelManager::class)->widgets('admin')->all(); // list<class-string>, sorted
app(PanelManager::class)->widgets('admin')->byId('recent-users');
app(PanelManager::class)->widgets('admin')->has('recent-users');
app(PanelManager::class)->widgets('admin')->count();2
3
4
5
6
The generator
php artisan make:panel-widget {name} --panel=Admin [--type=stats] [--force]| Option | Values | Default |
|---|---|---|
--panel | a panel name, studly-cased for you | required |
--type | stats, table, chart, custom | stats |
--force | overwrite an existing file | off |
The class is written to app/Panels/{Panel}/Widgets/{Name}.php. For --type=custom a Vue component is written as well, to resources/js/pages/Panels/{Panel}/Widgets/{Name}.vue, because a custom widget without its component renders only the fallback. An unknown --type fails without writing anything. See make:panel-widget.
The shared API
Everything below lives on PandaPanel\Widgets\Widget and works for all four types.
| Member | Signature | Default | Purpose |
|---|---|---|---|
$sort | protected static int | 0 | Order on the page, ascending. |
$columnSpan | protected static int|string|array | 1 | Grid width. See Layout. |
$lazy | protected static bool | false | Defer data() off the first response. See Lazy loading. |
$heading | protected static ?string | null | Title above the widget. |
$description | protected static ?string | null | Sub-line under the heading. |
$pollingInterval | protected static ?int | null | Seconds between refreshes. See Polling. |
id() | public static function id(): string | kebab class basename | Stable identity. |
sort() | public static function sort(): int | $sort | |
isLazy() | public static function isLazy(): bool | $lazy | |
heading() | public static function heading(): ?string | $heading | |
description() | public static function description(): ?string | $description | |
pollingInterval() | public static function pollingInterval(): ?int | $pollingInterval | |
columnSpan() | public static function columnSpan(): array | normalized $columnSpan | One value per breakpoint. |
canView() | public static function canView(): bool | true | Checked before data(). See Authorization. |
type() | abstract public static function type(): WidgetType | — | Supplied by the base class you extend. |
data() | abstract public function data(): array | — | The payload. Scalars, arrays and nulls only. |
filterSchema() | public function filterSchema(): ?FormSchema | null | See Filters. |
filtersInModal() | public static function filtersInModal(): bool | false | Filter form in a dialog. |
withFilters() | public function withFilters(array $filters): static | — | Called by the page. |
filter() | protected function filter(string $name, mixed $default = null): mixed | — | One filter value. |
filters() | protected function filters(): array | — | All of them. |
withPageContext() | public function withPageContext(PageContext $context): static | — | Called by the page. |
context() | protected function context(): PageContext | — | Throws when there is none. |
toDefinition() | public function toDefinition(): array | — | The serialized widget. |
toArray() | public function toArray(): array | — | Alias of toDefinition(). |
use PandaPanel\Widgets\StatsWidget;
final class QueueDepth extends StatsWidget
{
protected static int $sort = 5;
protected static int|string|array $columnSpan = ['default' => 1, 'md' => 2];
protected static bool $lazy = true;
protected static ?string $heading = 'Queue';
protected static ?string $description = 'Jobs waiting to run.';
protected static ?int $pollingInterval = 15;
public static function canView(): bool
{
return auth()->user()?->can('view-operations') === true;
}
public function stats(): array { /* ... */ }
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$heading, $description, $sort, $columnSpan, $lazy and $pollingInterval are static, so they are the same for every request. Anything that has to vary per user belongs in data(), or in canView().
What crosses to the browser
toDefinition() is the whole contract:
[
'id' => 'user-stats',
'type' => 'stats',
'sort' => 10,
'columnSpan' => ['default' => 1, 'md' => 2, 'lg' => 3, 'xl' => 4],
'lazy' => false,
'heading' => 'Accounts',
'description' => null,
'polling' => 60, // seconds, or null
'filters' => null, // or ['inModal' => bool, 'form' => FormDefinition]
'data' => ['stats' => [/* ... */]], // null when lazy
]2
3
4
5
6
7
8
9
10
11
12
CustomWidget adds one key, component. Nothing else is ever added, and no PHP class name appears anywhere in it — the package has a test asserting exactly that.
How a page resolves its widgets
PandaPanel\Pages\WidgetCollection does the work, in this order:
canView()is called on the class. A widget that refuses is dropped before it is constructed, so it never runs a query.- The survivors are instantiated, given page context if the page has one, and given their filter values if the page resolved any.
- They are sorted by
[sort(), id()]— the id is the tiebreaker, so two widgets with the same$sortstill have a stable order. definitions()serializes each one, callingdata()inline for eager widgets and leavingnullfor lazy ones.deferred()returns a singleInertia::defer()prop holding{widgetId: data}for the lazy widgets, ornullwhen none are lazy.
use PandaPanel\Pages\WidgetCollection;
use PandaPanel\Widgets\PageContext;
use PandaPanel\Widgets\Support\WidgetFilters;
$collection = WidgetCollection::for(
[UserStats::class, RecentUsers::class],
PageContext::forRecord($order), // optional
WidgetFilters::none(), // optional
);
$collection->definitions(); // list<array<string, mixed>>
$collection->deferred(); // Inertia deferred prop, or null
$collection->merge($other); // one collection for a single deferred prop2
3
4
5
6
7
8
9
10
11
12
13
The props a page ships:
| Page | Definition props | Deferred prop |
|---|---|---|
Dashboard / Page | widgets | widgetData |
| resource pages | headerWidgets, footerWidgets | widgetData |
Page context
A widget on a resource page is handed a PandaPanel\Widgets\PageContext describing what the page is showing.
public static function forRecord(Model $record): self
public static function forQuery(Closure $query): self
public function record(): ?Model
public function query(): ?Builder
public function count(): int2
3
4
5
6
ListRecords builds it with PageContext::forQuery() from the query the table actually ran, tab scoping included, so a widget counts what the user is looking at rather than the whole table. ViewRecord, EditRecord and ManageRelatedRecords build it with PageContext::forRecord().
use PandaPanel\Widgets\StatsWidget;
use PandaPanel\Widgets\Support\Stat;
final class SelectionSummary extends StatsWidget
{
public function stats(): array
{
return [
Stat::make('Matching orders', $this->context()->count()),
];
}
}2
3
4
5
6
7
8
9
10
11
12
count() is memoized on the context object, and every widget on the page shares one instance — three widgets asking for the count run one query, and a page whose widgets never ask runs none.
context() throws LogicException when the widget was rendered without one. That is deliberate: a widget reading a record it was never given is on the wrong page, and a zero would hide it. Dashboards and standalone pages pass no context.
Gotchas
canView()is static and takes no arguments. It runs before the widget exists, so it cannot see the page's record. Per-record hiding has to happen insidedata(), or by not naming the widget inheaderWidgets().Resource::getWidgets()is an index-page convenience. UsegetHeaderWidgets(string $page)orgetFooterWidgets(string $page)on the resource when view, edit or relation pages need their own widgets.- Resource widget filters are remembered per panel, resource, page and record. A filter chosen while viewing one record is not restored over another.
- Widget data is never cached by
panel:cache. The manifest caches class names; counts, rows and series are computed per request. widgetDatais absent from the first response rather than null, because it is a deferred prop. A Vue component reading it must declare it optional.- Two widgets whose class basenames kebab-case to the same string cannot live in one panel.
App\Panels\Admin\Widgets\UserStatsandApp\Panels\Admin\Reports\UserStatsare bothuser-stats, and registering the second throws. - There are no widget-specific testing helpers. Widgets are tested through the page's Inertia props, the way the package's own
WidgetRenderingTestdoes.