Core
Widgets
What every widget shares — the base class, polling, authorization and the complete API — and which type to reach for.
On this page
- Widget Types At A Glance
- Widget Base
- Polling
- Usage
- Polling API
- A tick refreshes the widget, not the dashboard
- Filters
- How the round trip works
- Deferred Rendering
- Writing Your Own Widget
- Header Actions
- What a widget action is, and what it is not
- How it reaches an Action at all
- Customisable Dashboards
- What is stored
- The rules worth knowing
- Height needs a row to mean something
- Where it starts: a default layout
- Rearranging it
- Saving as you go
- Edit-mode API
- The tray
- Tray API
- What the browser may change
- More than one arrangement
- Ready-made controls
- What the whole thing needs to work
- Authorization
- Widget API Reference
- Widget (base class)
- Blade Components
- In This Section
- Related
A widget is one panel of a dashboard: a figure, a chart, a small table, or a view of your own. They are ordinary PHP objects that render to HTML, composed into a responsive grid by a host component — so a widget knows nothing about the page it is on, and a page knows nothing about what a widget draws.
Every widget shares the same fluent builder, so heading, visibility, authorization, column span, polling, filters and deferral work identically across every type.
Widget Types At A Glance
| Widget | Class | Best for |
|---|---|---|
| Stats overview | StatsOverviewWidget |
KPIs, counters, and summary metrics with optional sparklines |
| Chart | ChartWidget |
Line, bar, pie, and doughnut charts powered by Chart.js |
| Chart presets | LineChartWidget / PieChartWidget / DoughnutChartWidget |
Declarative ChartWidget presets (pie/doughnut show the legend by default) |
| Bar chart | BarChartWidget |
Pure-CSS vertical/horizontal bars (finance, system) — no JavaScript |
| Progress | ProgressWidget |
Pure-CSS rows of a fill against a track — how far a figure is toward its target |
| List | ListWidget |
Pure-CSS feed of recent records or events, each optionally a link |
| Table | TableWidget (in wire-table) |
A few rows of a table inside a dashboard card — no toolbar, no pagination |
| Custom | CustomWidget |
Any Blade view rendered as a widget |
Mix widget types freely inside a single
WithWidgetsdashboard — each widget controls its own column span, visibility, and refresh interval. See Dashboard Layout.
Widget Base
All widgets extend NyonCode\WireCore\Widgets\Widget — an abstract class implementing Htmlable.
use NyonCode\WireCore\Widgets\Widget;
Every widget supports:
->heading(?string $heading) // widget title->description(?string $description) // subtitle text->columnSpan(int|string $span) // grid column span (1-12, 'full')->extraAttributes(array $attrs) // custom HTML attributes->hidden(bool|Closure $hidden) // visibility control->visible(bool|Closure $visible) // visibility control->permission(string $permission) // authorization via Gate->authorize(string $ability) // authorization via Gate ability->authorizeUsing(Closure $callback) // custom authorization callback->filter(array $options, ?string $default = null) // a select the server resolves->lazy(bool $condition = true) // defer the first render->headerActions(array $actions) // buttons in the widget header
Widgets render via Blade views and support toHtml() / __toString() for direct output.
Polling
All widgets support auto-refresh via Livewire polling.
use NyonCode\WireCore\Widgets\Concerns\HasPolling;
Usage
StatsOverviewWidget::make() ->pollingInterval('30s') ->stats([...]) ChartWidget::make() ->pollingInterval('60s') ->pollingOnlyVisible() // pause polling when widget is off-screen
Polling API
->pollingInterval(?string $interval) // '5s', '10s', '30s', '60s', etc.->getPollingInterval(): ?string->isPolling(): bool->pollingOnlyVisible(bool $only = true) // only poll when visible in viewport->isPollingOnlyVisible(): bool->getPollingDirective(): ?string // returns wire:poll directive string
Polling is visibility-aware by default.
pollingOnlyVisibledefaults totrue, so widgets usewire:poll.visibleand pause requests while scrolled out of view. Call->pollingOnlyVisible(false)to keep refreshing off-screen.
A tick refreshes the widget, not the dashboard
A plain wire:poll is $refresh — Livewire renders the whole component. On a
dashboard that means one polling widget re-renders every other widget beside it,
and any table sharing the page: measured on a 12-widget grid, 6.5 ms and
57 219 B to deliver one widget's 3 940 B.
So the tick names the widget instead. Each widget carries a key, and the grid anchors the polling one:
<div wire:poll.30s.visible="refreshWidget('w1')"> <div wire:partial="widget-w1"> … </div> </div>
WithWidgets::refreshWidget() renders that widget alone and the response carries
only its markup. Nothing to configure — it is how a polling widget behaves.
Keys are derived from the widget's position in getWidgets(), so hiding a widget
does not renumber the rest. Where the declaration's order is likely to change,
name the key yourself:
StatsOverviewWidget::make() ->key('revenue') ->pollingInterval('30s') ->stats([...])
If the key names nothing on the next request — the widget was hidden, stopped polling, or was removed — the tick falls back to a full render rather than answering with half a page.
The grid ships wire-core's bundle when a widget polls. The anchor is only an attribute; the code that applies the region lives in
wire-core-dropdown.js. A dashboard with no dropdown, modal or table has no other reason to load it, so the grid asks for it — otherwise the response would arrive and nothing on the page would change.
Filters
A widget can carry a select that narrows what it shows, and the selection is resolved on the server — which is the only place it can be, because the closure that turns a filter key into data is PHP.
ListWidget::make() ->heading('Recent orders') ->filter(['week' => 'This week', 'month' => 'This month'], 'week') ->items(fn (?string $filter) => $this->orders($filter))
filter() is on the widget base, so every type has it: a chart's datasets, a
list's entries and a progress board's rows all take the same closure shape.
How the round trip works
The select calls filterWidget() on the host component. The host records the
choice against the widget's key, pushes it back into the widget before anything
renders, and answers with that widget's markup alone — the same wire:partial
region a poll tick uses:
<select wire:change="filterWidget('w0', $event.target.value)"> <option value="week" selected>This week</option> <option value="month">This month</option></select>
Three consequences worth knowing:
- The selection survives. It lives on the host (
$widgetFilters), not on the widget — a widget is rebuilt from the declaration on every request and cannot remember anything. - A widget answers only for the options it enumerated. The key and the value
both travel in the request, so a value the widget never offered is ignored
rather than handed to your
match. - A widget with no filter falls back to a full render, like any call that queues no region.
This used to do nothing. Before 2.0 the filter lived on
ChartWidgetalone and was resolved in the browser: an AlpineupdateChart()assignedthis.labelsandthis.datasetsback onto the chart it was constructed with, so changing the selection redrew the identical chart and the dataset closure never ran with anything but its default.
Deferred Rendering
A widget whose figure costs a query the rest of the page should not wait for can draw a placeholder first and fetch itself afterwards:
StatsOverviewWidget::make() ->heading('Lifetime revenue') ->lazy() ->stats([Stat::make('Revenue', $this->lifetimeRevenue())])
The grid renders a skeleton card the size of the widget, wire:init calls
loadWidget() on the host, and the response carries that one widget's markup as
a wire:partial region. The page is interactive before the count comes back.
Once loaded, loaded. The host records which widgets have been fetched, so a later poll tick, filter change or full render draws the real widget rather than dropping back to the skeleton.
The placeholder is a card, not a spinner — the grid has already committed to
a layout, and a placeholder shorter than its widget makes the page jump when the
real one lands. The heading is drawn for real, because the server already knows
it, and the skeleton bars carry aria-busy with a live region announcing the
wait.
Reach for it last. The framework's rule is to make a render cheap rather than to defer it: eager HTML keeps the full DOM, adds no open latency and needs no client-side re-render.
lazy()is for a widget that is slow, not one that is merely large.
Writing Your Own Widget
Two files — a class that resolves state and a Blade view that draws it — and the generator writes both:
php artisan make:wire-widget Revenue
app/Widgets/RevenueWidget.phpresources/views/widgets/revenue.blade.php
The class extends Widget, names the view in viewName() and hands it data from
getViewData(); $widget is always in scope inside the view. Everything on this
page — polling, filters, deferral, column span, authorization — works on it
without another line.
namespace App\Widgets; use App\Models\Invoice;use NyonCode\WireCore\Widgets\Widget; class RevenueWidget extends Widget{ protected function viewName(): string { return 'widgets.revenue'; } protected function getViewData(): array { return [ 'value' => Invoice::whereMonth('paid_at', now()->month)->sum('total'), ]; } }
An existing view is never overwritten — re-running the generator is usually about
getting the class back, and replacing markup somebody wrote is not a trade a
generator gets to make. Pass --force to overwrite the class.
Both templates are publishable, so an application can change what the generator produces:
php artisan vendor:publish --tag=wire-core::stubs
They land in stubs/wire-core/, namespaced by package so two packages shipping a
dashboard.stub cannot overwrite each other, and the generator reads them from
there. (stubs/ is still consulted second, for a file put there by hand.)
For a widget that is only a Blade view with no state of its own, reach for
CustomWidget instead — no class to write at all.
Header Actions
A widget can carry buttons in its header — refresh a figure, export the rows, mark a queue as read:
use NyonCode\WireCore\Actions\Action; StatsOverviewWidget::make() ->heading('Open tickets') ->headerActions([ Action::make('refresh') ->label('Refresh') ->icon('outline:arrow-path') ->action(fn () => $this->recount()), Action::make('report')->label('Full report')->url(route('tickets.report')), ]) ->stats([Stat::make('Waiting', $this->waiting())])
headerActions() is the same vocabulary a schema section header and an infolist
entry use — one owner, Foundation\Concerns\HasActions — so an action you wrote
for one of those surfaces works here unchanged. Every widget type draws them, in
its own header.
What a widget action is, and what it is not
It runs its callback, on the server, and the widget is re-rendered afterwards — the one region, like a poll tick. It is not the action lifecycle:
| Works | Does not |
|---|---|
->action(fn () => …) |
->requiresConfirmation() |
->url(…), ->openUrlInNewTab() |
->form([...]), ->slideOver(), ->modal() |
->label(), ->icon(), ->color(), ->tooltip() |
->wizard() |
->hidden() / ->visible() |
mounting, and anything that resumes into a mounted action |
An action that removes its own widget — a "Dismiss" button whose callback
flips what the widget's visible() reads — is answered with a full render
instead. A partial can replace an element but not delete one.
That is the same bargain an infolist entry's actions strike, and for the same
reason: a widget is rebuilt from its declaration on every request, so the button
carries a name rather than a closure and there is nothing mounted to resume
into. An action that has to ask before it acts belongs on the page — a host
composing WithActions owns a modal host — or inside a
CustomWidget whose view renders <x-wire-actions::button>.
How it reaches an Action at all
Worth one paragraph, because it is the framework's own layering rule in
miniature. Widgets and Actions are sibling modules and neither may import
the other, so nothing in the widget host knows what an Action is: the widget
resolves the action by name and answers with Foundation\Contracts\ActionContract,
and Foundation\Contracts\RunsComponentActions — bound in the container,
implemented on the Actions side — is what runs the callback. A widget in a
package that does not require the Actions module still compiles; its buttons
simply have nothing to run.
Customisable Dashboards
A dashboard can let each user decide which widgets are on it, in what order, and how big each one is. It is off by default, and a dashboard that says nothing behaves exactly as it does today — no store is consulted and the declaration is the layout.
class SalesDashboard extends Dashboard{ public function customisable(): bool { return true; } public function widgets(): array { return [/* … */]; }}
The layout is stored per (dashboard key, user), through the same
preference store a table's column
layout uses — configured separately, under wire-core.preferences, because
whether a dashboard layout is worth a database row is its own question. With no
driver configured a dashboard still works and simply forgets.
What is stored
An ordered list of what is placed, each entry with a width and a height:
['widgets' => [ ['key' => 'revenue', 'w' => 2, 'h' => 1], ['key' => 'orders', 'w' => 1, 'h' => 2],]]
Position comes from the order and size from two integers; CSS Grid packs the
result. There are no coordinates, so there is nothing to collide and no hole to
re-pack — and no way to pin a widget to an absolute cell. w is the column span
(1–4) and h the row span (1–6).
The rules worth knowing
- The list is what is placed. A declared widget whose key is not in it is
not on the dashboard — it is available to put back. One rule doing two jobs,
rather than a
hiddenflag beside the order that could disagree with it. - An empty list is not the same as no layout. Nothing stored means the dashboard's default layout decides — or, without one, the declaration — which is what a user who has never touched this dashboard must see. A stored empty list means somebody took everything off, and they are entitled to an empty dashboard.
- A layout is a preference, never a grant. A widget the layout places and
visible()orpermission()hides stays hidden. - A key the declaration no longer has is dropped. A layout cannot conjure back a widget that was renamed or removed by whoever owns the dashboard.
- The bag arrives from a request, so a size outside the grid is clamped and a key listed twice is placed once, at its first position.
Height needs a row to mean something
A row-span-2 tile needs rows of a known height, so a grid that has one gets a
row baseline and its tiles scroll inside themselves rather than stretching the
row. A grid where nothing spans rows never gets that baseline — a card there is
still as tall as its contents, exactly as before.
Where it starts: a default layout
A dashboard that offers a catalogue of widgets should not put the whole
catalogue in front of a newcomer. defaultLayout() says which widgets somebody
sees before arranging anything; every other declared widget starts in the tray.
use NyonCode\WireCore\Widgets\Dashboard; final class ProductionDashboard extends Dashboard{ public function customisable(): bool { return true; } public function defaultLayout(): ?array { return auth()->user()?->hasRole('technician') ? ['queue' => 'L', 'kpi'] // the bench works from its queue : ['kpi' => 'L', 'attention' => [2, 1], 'queue' => 'M']; } public function widgets(): array { return [/* kpi, attention, queue, money, … — each with its own key() */]; }}
The order of resolution is fixed: a layout the user stored wins; with none, the default layout is placed; with no default either, everything declared is. "Reset" forgets the stored layout, so it comes back to the default rather than to the whole declaration — and because the default is a real placement, the widgets it leaves out are in the tray from the first time the editor opens.
The spec is keys in order, each optionally with a size:
- a bare key arrives at its first offered size, as if added from the tray;
key => [width, height]is snapped to the nearest size the widget offers on this grid, so a default cannot give a widget a size its declaration refuses;key => 'L'names one of the widget's named sizes; a name it does not declare falls back to the arrival size.
A key the dashboard does not declare is dropped, the same rule a stored layout
follows. defaultLayout() is asked on every render, so it may depend on who is
looking — which is the reason it exists rather than a constant. A hand-written
host answers the same question with the protected defaultWidgetLayout(), and
hasStoredWidgetLayout() says which of the two the user is looking at: the
ready-made controls offer Reset only when there is a layout of their own to
forget.
Rearranging it
Editing is a mode, not a permanent set of handles. Customise reveals the
drag handles and the size steppers; Save keeps the arrangement, Cancel
throws it away, Reset forgets the layout entirely and goes back to what the
dashboard declares.
The host trait gives you the methods; the chrome around the grid is yours, because it belongs to whatever renders the dashboard rather than to the grid:
@if($editingWidgets) <button wire:click="saveWidgetLayout">Save layout</button> <button wire:click="cancelEditingWidgets">Cancel</button> <button wire:click="resetWidgetLayout">Reset to default</button> @else <button wire:click="startEditingWidgets">Customise</button> @endif @include('wire-core::widgets.widget-grid', [ 'widgets' => $this->getVisibleWidgets(), 'columns' => 2, 'editing' => $editingWidgets,])
Nothing is written until Save. The arrangement lives in a draft on the component while the mode is open, which is what gives Cancel something to throw away — a live drag would persist on every drop, so a stray grab would overwrite a layout somebody was happy with and nothing could undo it.
The drag costs no JavaScript. It is x-sort, Livewire's own Alpine plugin,
which carries SortableJS and a drag ghost with it. The server places the tile: a
drop reports "this key, this position", the draft is reordered and the grid
re-renders. Cells carry a wire:key, so the morph pairs them by key rather than
position and the dropped DOM cannot disagree with the answer.
Resizing is steppers, not a drag corner. A span is 1–4 columns and 1–6 rows,
so there are eleven reachable sizes and a button says which one you are getting.
A widget that names its sizes is offered those names instead —
S, M, L — because three sizes are three choices, and a stepper would walk
the user to them one column at a time without saying where the walk ends.
The drop lands where the outline is. While a tile is dragged, SortableJS
keeps a copy of it where it would land; the grid styles that copy as a drop
target — a dashed outline over a faded tile — so the reader sees where the
widget goes before letting go. It is a class on the cell (sortable-ghost), so
it costs no JavaScript either.
Saving as you go
Save and Cancel exist so a stray drag cannot overwrite a layout. A dashboard that
is a working tool — opened all morning, rearranged in passing — has the opposite
problem: a forgotten Save loses more than a stray drag does, and Reset is undo
enough. autosave() stores every change as it is made:
public function autosave(): bool{ return true; // every move, resize, add and remove is stored at once}
The mode stays — handles and tray appear only while customising — but it ends
with Done instead of Save and Cancel, since there is no draft left to throw
away. A hand-written host answers with autosavesWidgetLayout().
Edit-mode API
->startEditingWidgets(): void // open the mode, from the current arrangement->saveWidgetLayout(): void // keep it->cancelEditingWidgets(): void // discard the draft->resetWidgetLayout(): void // forget the layout; back to the declaration->moveWidget(string $key, int $position): void->resizeWidget(string $key, int $width, int $height): voidpublic bool $editingWidgetspublic array $widgetLayoutDraft
All of them are no-ops on a dashboard that never opted in — they are public Livewire methods, so the browser can call them whenever it likes.
What a hand-written WithWidgets host overrides to answer the questions a
Dashboard answers with defaultLayout(), autosave() and maxWidgets():
protected function defaultWidgetLayout(): ?array // a default-layout spec, or null for the declarationprotected function autosavesWidgetLayout(): bool // default false — Save and Cancelprotected function maxWidgets(): ?int // default null — no limitpublic function hasStoredWidgetLayout(): bool // the user has a layout of their ownpublic function widgetLimitReached(): bool // as many placed as maxWidgets() allows
The tray
The widgets a user can put on the dashboard but has not. It appears with edit mode, and it is one drag with the grid — a tile dragged out goes back to the tray, one dragged in goes on the dashboard. Buttons do the same two things for anybody not using a pointer.
StatsOverviewWidget::make() ->key('revenue') ->heading('Revenue') ->group('Money') ->sizes([[2, 1], [4, 2]]) ->stats([Stat::make('Total', $this->revenue())])
group() is the tray's heading, and nothing else. It does not group anything
on the dashboard, where the user's own order decides. Widgets that name no group
come first, in one plain run — one group is a heading over everything, which is a
heading that says nothing.
sizes() narrows what the widget is offered at, as [width, height] pairs.
A sparkline row is not a 1×1 tile and a single figure is not a 4×6 one, and
letting a user find that out by dragging is worse than not offering it. The first
pair is the size the widget arrives at when it is added. Declaring none leaves
the whole grid — 1–4 columns by 1–6 rows.
The pairs are the offer, not a promise about storage: a layout that arrives carrying a size outside them is still clamped to the grid rather than rejected, because a stored layout can outlive the declaration that shaped it.
The steppers are that offer, drawn. The width buttons walk the offered
widths — taking the height that width is offered at, so a declaration of
[[2, 1], [4, 2]] has both pairs reachable — and the height buttons walk the
heights offered at the current width, so a height button never moves a tile
sideways under the reader's hand. A button with nowhere left to go is drawn
disabled, which is the one place a user sees the offer.
A width is bounded by the dashboard's own columns() too. A tile wider than
its grid does not clip: CSS Grid adds the missing column, and every other tile
on the dashboard is squeezed into what is left. So a three-column dashboard
never offers a fourth, and whatever the browser sends is snapped to the same
answer on the way in — one owner, Widgets\Support\WidgetSizeOffer, for both
bounds and for the buttons.
"Removed" and "available" are the same state. Nothing records that a widget was taken off — it is simply not in the layout any more, which is what puts it in the tray. One rule doing two jobs, so the two cannot disagree.
A widget a policy hides is never offered: a tray listing something that vanishes when you add it is worse than one that does not list it.
The tray says what a widget shows. Its description() is drawn under the
heading — the tray is where somebody decides what to put on their dashboard, and
a heading alone rarely tells them. Sizes can be named, by keying the pairs:
StatsOverviewWidget::make() ->key('queue') ->heading('Testing queue') ->description('Pieces waiting for the test bench, oldest first.') ->sizes(['S' => [1, 1], 'M' => [2, 1], 'L' => [4, 1]]) ->stats([Stat::make('Waiting', $this->waiting())])
The editor then draws S M L in place of the steppers. Names are all or
nothing — one unnamed pair keeps the steppers — and each button says the size as
it is on this grid: on a two-column dashboard M and L are the same size, and
the first name wins, as the first pair does.
A limit is said in the tray. maxWidgets() on the dashboard (or the host)
refuses a widget past the limit on the server; the tray then shows why and drops
its add buttons, rather than offering a click that does nothing. It bounds what a
user adds — a default or stored layout from before the limit is left as it is.
Tray API
->group(?string $group) // the tray heading this widget is offered under->sizes(array $sizes) // [[w, h], …] or ['S' => [w, h], …] — named sizes become buttons->getGroup(): ?string->getSizes(): array->getSizeLabel(int $width, int $height): ?string // the name a declared size goes by->hasNamedSizes(): bool // every offered size has a name->getDefaultSize(): array // the first offered pair, or [1, 1]
on the host:
->placeWidget(string $key, int $position): void // add, or move if already there->removeWidget(string $key): void->getAvailableWidgets(): array // group => widgets, for the tray->widgetLimitReached(): bool // the tray then offers no add buttons->widgetGridData(?int $columns = null): array // everything the grid view needs
widgetGridData() is one call rather than five keys to assemble, because four of
them have to agree: a grid rendered without available draws an empty tray, and
one rendered without trayGroup puts the tray and the grid in different drag
groups so a tile cannot cross between them. Neither is an error anywhere.
public function render(){ return view('wire-core::widgets.widget-grid', $this->widgetGridData(2));}
What the browser may change
The host holds four public properties, because a drag makes round trips and a
widget cannot remember anything across one: the draft being arranged, whether
the editor is open, each widget's filter selection and which deferred widgets
have been fetched. All four are #[Locked] — every change has a method, and
a method is where a key is checked against the declaration and a size against
the grid:
moveWidget() placeWidget() resizeWidget() removeWidget() // the arrangementstartEditingWidgets() saveWidgetLayout() cancelEditingWidgets() resetWidgetLayout()filterWidget() loadWidget() callWidgetAction() refreshWidget()
Saving narrows what it writes to the keys the dashboard declares, which also cleans a stored layout left by an older declaration — a widget you renamed stops being carried around the first time that user saves.
More than one arrangement
savedLayouts() is the second half of the opt-in, and it is separate because the
wishes are: customisable() is "you may rearrange this", this is "you may keep
several arrangements".
class SalesDashboard extends Dashboard{ public function customisable(): bool { return true; } public function savedLayouts(): bool { return true; } }
It rides the same store — a saved layout is this dashboard's layout under a
name, which is the view dimension the preference store has carried since it
was a table's. The controls gain a "Save as…" beside Customise, and a switcher
once there is something to switch to.
| Method | What it does |
|---|---|
saveWidgetLayoutAs(string $name) |
Keeps the arrangement on screen under a name, replacing one of that name |
applyWidgetLayout(string $name) |
Puts a saved arrangement back on the dashboard |
deleteWidgetLayout(string $name) |
Forgets one; the dashboard on screen is untouched |
getWidgetLayoutNames(): array |
The names this user has saved, for a switcher of your own |
Applying is a copy, not a pointer. The saved arrangement is written onto the current layout, so nothing has to remember which name is in use and the answer survives a reload without a second piece of stored state that could disagree with the first. Choosing a name is a verb, not a mode you are then in.
Saving while the editor is open keeps the draft — what the user is looking at. On a dashboard nobody has arranged, what is captured is what the grid shows, since a name over "the declaration" would restore nothing.
Ready-made controls
The methods are yours to call from wherever your layout puts buttons — but the common case ships:
@include('wire-core::widgets.partials.widget-layout-controls')@include('wire-core::widgets.widget-grid')
Both render nothing on a dashboard nobody may rearrange, so including them
unconditionally is safe and there is no condition to keep in step. DashboardPage
in wire-panels already includes both, so a declared dashboard that says
customisable() is rearrangeable with no view of your own at all.
There is no separate package for this. The store is wire-core's, the page is
wire-panels', and the widgets are the application's — a module would have owned
nothing, and "optional" is already what customisable() and the driver config
mean.
What the whole thing needs to work
// config/wire-core.php — or leave it, and layouts live for the page only'preferences' => ['default' => 'database'],
php artisan vendor:publish --tag="wire-core::migrations" # for the database driverphp artisan migrate
class SalesDashboard extends Dashboard{ public function customisable(): bool { return true; } public function widgets(): array { return [ StatsOverviewWidget::make()->key('revenue')->heading('Revenue') ->group('Money')->sizes([[2, 1], [4, 2]]) ->stats([Stat::make('Total', $this->revenue())]), ]; }}
Every widget on a customisable dashboard needs its own key(). Without one the
key is derived from the widget's position, and a stored layout addresses
widgets by key — so inserting a widget at the top of the declaration would make
every saved layout describe different widgets than it did yesterday, while the
page still renders. A customisable dashboard refuses rather than letting that
happen.
Authorization
Widgets inherit authorization from HasVisibility which uses the HasAuthorization trait. See Authorization for details.
StatsOverviewWidget::make() ->permission('view-dashboard-stats') ->stats([...]) ChartWidget::make() ->authorize('view-revenue-chart') ->heading('Revenue') CustomWidget::make() ->authorizeUsing(fn ($user) => $user->hasRole('manager')) ->view('dashboard.manager-panel')
Unauthorized widgets are automatically excluded from getVisibleWidgets().
Widget API Reference
Widget (base class)
Widget::make(): static // static factory->heading(?string $heading): static->getHeading(): ?string->description(?string $description): static->getDescription(): ?string->key(string $key): static->getKey(): ?string->usesPartialAnchor(): bool // true when a tick, a load or a filter targets it->render(): View->toHtml(): string
Inherited from traits:
->columnSpan(int|string $span): static // HasColumnSpan->getColumnSpan(): int|string ->rowSpan(?int $span): static // HasRowSpan — 1–6, null is one row sized by content->getRowSpan(): ?int->spansRows(): bool ->group(?string $group): static // tray heading it is offered under->sizes(array $sizes): static // [[w, h], …] or ['S' => [w, h], …] — the sizes it may take->getGroup(): ?string->getSizes(): array->getSizeLabel(int $width, int $height): ?string->hasNamedSizes(): bool->getDefaultSize(): array ->ignoresDashboardFilters(bool $ignores = true): static // CanIgnoreDashboardFilters->isIgnoringDashboardFilters(): bool ->extraAttributes(array $attrs): static // HasExtraAttributes->getExtraAttributes(): array ->pollingInterval(?string $interval): static // HasPolling->pollingOnlyVisible(bool $only = true): static ->lazy(bool $condition = true): static // CanBeLazy->isLazy(): bool ->filter(array $options, ?string $default = null): static // HasWidgetFilter->activeFilter(?string $filter): static->applyFilter(?string $filter): static // ignores a key the widget does not offer->getFilterOptions(): ?array->getActiveFilter(): ?string->hasFilter(): bool ->items(array|Closure $items): static // HasWidgetItems — fn (?string $filter): array->emptyState(?string $message): static->getItems(): array->hasItems(): bool->getEmptyState(): string ->headerActions(array $actions): static // HasActions — array<ActionContract>->actions(array $actions): static // the same list, under its canonical name->action(ActionContract $action): static // append one->getActions(): array // the visible ones, in declaration order->hasActions(): bool->getFieldAction(string $name): ?ActionContract->getRenderableActions(): array // the ones with somewhere to dispatch to — a key is all it takes->hasRenderableActions(): bool->getActionExpression(ActionContract $action): ?string ->hidden(bool|Closure $hidden): static // HasVisibility + HasAuthorization->visible(bool|Closure $visible): static->permission(?string $permission): static->authorize(?string $ability): static->authorizeUsing(?Closure $callback): static->isVisible(): bool->isAuthorized(): bool
Blade Components
<x-wire::widget-grid :widgets="$widgets" :columns="2" />
The views each widget renders, publishable with
vendor:publish --tag=wire-core::views:
wire-core::widgets.stats-overviewwire-core::widgets.chartwire-core::widgets.bar-chartwire-core::widgets.bar-chart.vertical-financewire-core::widgets.bar-chart.vertical-systemwire-core::widgets.bar-chart.horizontal-systemwire-core::widgets.progresswire-core::widgets.listwire-core::widgets.customwire-core::widgets.widget-gridwire-core::widgets.widget-cellwire-core::widgets.partials.widget-headerwire-core::widgets.partials.widget-filterwire-core::widgets.partials.widget-actionswire-core::widgets.partials.widget-placeholderwire-core::widgets.partials.list-item
In This Section
| Page | What it covers |
|---|---|
| Stats | StatsOverviewWidget and the Stat value object — figures with a trend |
| Charts | ChartWidget, BarChartWidget and ChartItem |
| Progress | ProgressWidget and the ProgressItem value object — a fill against a target |
| Lists | ListWidget and the ListItem value object — a short feed of records or events |
| Tables And Custom Views | TableWidget and CustomWidget |
| Dashboards | Composing widgets on a component, and declaring a dashboard as an owner |
Related
- Panels: Pages —
DashboardPage, the page that renders a declared dashboard - Actions — buttons a widget can carry
- Authorization — the gate a widget's visibility consults
- Tables — what a table widget embeds