Dashboard widgets
Since Basehim 1.2.68 the admin dashboard is a grid of widgets. Core's cards and your app's cards are registered the same way. Each person arranges their own dashboard, and the layout is kept in the database, so it follows them to every browser and device.
A complete, working example ships with Basehim in docs/examples/dashboard-widgets. Copy it to content/apps/dashboard-example and activate it.
The short version
public function boot(): void
{
$this->registerWidget('orders', [
'surfaces' => ['dashboard'],
'title' => 'Orders today',
'description' => 'New orders since midnight.',
'icon' => 'shopping-cart', // a Heroicon name
'capability' => 'access_app:' . $this->slug(),
'dashboard' => [
'view' => 'stat',
'size' => 'third',
'data' => function (\App\Services\Dashboard\WidgetContext $ctx): array {
return ['items' => [
['label' => 'Orders', 'value' => $this->countOrdersToday(), 'href' => $ctx->url('/admin/shop')],
]];
},
],
]);
}
That's all. The card appears on the dashboard of everyone who holds the capability, and under your app's name in Customize → Add widgets, with Refresh, Collapse and Remove. You don't write card markup or touch a core file.
- Keys: a key is namespaced under your app's slug (
ordersbecomesyour-app.orders).core.*belongs to core, and a key belongs to whoever registered it first. A refused registration is written to your app's log. - No separate helper: there's deliberately no
registerDashboardWidget()on theAppbase class. Adding a method there would break every installed app that already declares one with that name. UseregisterWidget()with'surfaces' => ['dashboard']. - Themes: a theme can register dashboard widgets from its
widgets.phpthe same way.
The definition
[
'title' => 'Orders today', // required
'description' => 'Shown in Add widgets.',
'icon' => 'shopping-cart',
'surfaces' => ['dashboard'], // or with 'editor', 'frontend'
'capability' => 'edit_posts', // a string or a list (all required); default 'read'
'fields' => [ … ], // settings, see below
'dashboard' => [
'view' => 'stat', // stat | list | table | chart | feed | status | html
'data' => callable, // fn(WidgetContext $ctx): array
'endpoint' => 'my-app.widget-data', // instead of data: your own AJAX action
'sizes' => ['third', 'half'], // allowed widths; default: all
'size' => 'third', // default width
'priority' => 100, // default order, lower first
'default' => true, // placed on new dashboards (false: only offered)
'multiple' => false, // may be added more than once
'refresh' => 0, // auto-refresh in seconds (0 = off, minimum 60)
'cache' => 0, // seconds, or ['ttl' => 300, 'scope' => 'user'|'role'|'global']
'actions' => [ … ], // see Actions
'empty' => ['title' => '…', 'text' => '…', 'action' => ['label' => '…', 'url' => '/admin/…']],
'configure' => callable, // extra settings validation
'visible' => callable, // fn(array $user): bool, a rule on top of capability
'aliases' => ['my-app.old-key'], // keep saved cards when you rename a key
],
]
The dashboard is a 12-column grid on desktops, six columns on tablets and one column on phones.
| Size | Desktop | Tablet | Phone |
|---|---|---|---|
small | 3 / 12 | half | full |
third | 4 / 12 | half | full |
half | 6 / 12 | full | full |
two-thirds | 8 / 12 | full | full |
full | 12 / 12 | full | full |
Views and their data
Return plain data, and the dashboard draws it.
- Escaping: strings are always put on the page as text, never as HTML, so you don't escape anything.
- Links must be a site path or
http(s)://. Use$ctx->url('/admin/x')to get the install's base path right. - Extras: every view also accepts
summaryand afooterwith a link.
// stat: key numbers
['items' => [['label' => 'Orders', 'value' => 128, 'hint' => '12 waiting', 'tone' => 'blue',
'trend' => ['direction' => 'up', 'text' => '8% on last week', 'good' => true]]]]
// list: rows with a title, a meta line, a time and a badge
['items' => [['title' => 'Order #1042', 'href' => '/admin/shop/1042', 'meta' => 'Ayesha Khan',
'time' => '2026-10-19 08:30:00', 'badge' => ['text' => 'Paid', 'tone' => 'green']]]]
// table: columns and rows; the first column links to _href
['columns' => [['key' => 'name', 'label' => 'Product'], ['key' => 'qty', 'label' => 'Sold', 'align' => 'right']],
'rows' => [['name' => 'Mug', 'qty' => 31, '_href' => '/admin/shop/products/7']]]
// chart: bar or line, up to three series
['type' => 'bar', 'labels' => ['Mon', 'Tue', 'Wed'], 'unit' => 'orders',
'series' => [['name' => 'Orders', 'values' => [4, 9, 6]]]]
// feed: an activity timeline
['items' => [['actor' => 'Ayesha', 'text' => 'placed an order', 'time' => '2026-10-19 08:30:00', 'tone' => 'green']]]
// status: checks that are ok, warn, error or unknown
['items' => [['label' => 'Payment gateway', 'state' => 'ok', 'detail' => 'Connected']]]
// html: your own markup, sanitized like an author's post (no scripts)
['html' => '<p>Anything <strong>simple</strong></p>']
The data provider runs on the server, only after the widget's capability and visibility rule have been checked for this user. Which records the user may see is still your job: core's Recent posts card, for example, shows only the user's own posts unless they hold edit_others_posts. The context gives you:
$ctx->user(),userId()andcan($capability);settings()andsetting($key, $default);forced(), true when the user pressed Refresh;url($path)andmake($service).
Settings
Declare fields, and the dashboard draws the form (⋮ → Settings), validates what's sent and hands the result to your provider:
'fields' => [
['key' => 'count', 'label' => 'Rows', 'type' => 'number', 'min' => 3, 'max' => 20, 'default' => 5],
['key' => 'status', 'label' => 'Show', 'type' => 'select', 'default' => 'open',
'options' => ['open' => 'Open', 'all' => 'Everything']],
['key' => 'mine', 'label' => 'Only mine', 'type' => 'checkbox'],
],
- Field types:
text,textarea,number,select,checkbox,urlandcolor. - Size limit: settings are capped at 4 KB per card.
- Extra rules: for rules the field types can't express, add a
configurecallback that throws\InvalidArgumentException('message').
Actions
'actions' => [
['id' => 'all', 'label' => 'Open orders', 'url' => '/admin/shop', 'icon' => 'list-bullet'],
['id' => 'sync', 'label' => 'Sync now', 'icon' => 'arrow-path', 'capability' => 'manage_shop',
'confirm' => 'Fetch orders from the payment provider now?',
'handler' => function ($ctx): array {
return ['message' => 'Synced ' . $this->sync() . ' orders.', 'refresh' => true];
}],
],
A handler runs on the server. The card must be on the user's dashboard and visible to them, and the action's own capability is checked there.
Loading, empty, unavailable and error states
You don't build these; the card does:
- Loading: a skeleton while data arrives.
- Empty: your
emptymessage when there's nothing to show. - Unavailable: your message when the provider throws
App\Services\Dashboard\WidgetUnavailable('Not connected yet'). - Error: any other exception, with Try again. The real error goes to your app's log, never to the browser.
A provider that is slow or broken affects its own card only.
Caching and refresh
cachekeeps a provider's result for that many seconds, per user by default. Use theglobalscope only for data that doesn't depend on who is asking.refreshreloads the card by itself, only while the tab is visible.- Refresh and actions always bypass and clear the cache.
Layouts and defaults
Each user's layout is stored in the database, so it's the same on every device. A user who never customised gets the site default (an administrator can choose Customize → Use as site default), or every widget they may see that has default => true, in priority order.
When your app is switched off, its cards don't render and don't run. They stay in each user's saved layout, with their settings, and come back where they were when the app returns. Removing a card hides it, and Add widgets offers to restore it. To rename a key without orphaning everyone's card, keep the old key in aliases.
HTML widgets from before 1.2.68
Nothing to change. A widget with a render callback and no dashboard.data still works, inside its own card with the toolbar, sizes and saved layout. Its inline scripts run again after Refresh, too. Give it a capability if it shows anything not everyone should see, because the default is read, which means every signed-in user.
Security
- Card ids only: the browser only ever names a card id. The server works out which widget and settings that is from the user's own stored layout.
- Re-checked every time: every data, settings and action request checks the capability and visibility rule again on the server.
- Errors stay private: exception messages are logged, never sent.
- Safe output: provider strings are drawn as text, links are restricted, and
htmlis sanitized.
Leave a comment