Skip to content

Dashboard widgets

7 min read

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 (orders becomes your-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 the App base class. Adding a method there would break every installed app that already declares one with that name. Use registerWidget() with 'surfaces' => ['dashboard'].
  • Themes: a theme can register dashboard widgets from its widgets.php the 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.

SizeDesktopTabletPhone
small3 / 12halffull
third4 / 12halffull
half6 / 12fullfull
two-thirds8 / 12fullfull
full12 / 12fullfull

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 summary and a footer with 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() and can($capability);
  • settings() and setting($key, $default);
  • forced(), true when the user pressed Refresh;
  • url($path) and make($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, url and color.
  • Size limit: settings are capped at 4 KB per card.
  • Extra rules: for rules the field types can't express, add a configure callback 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 empty message 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

  • cache keeps a provider's result for that many seconds, per user by default. Use the global scope only for data that doesn't depend on who is asking.
  • refresh reloads 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 html is sanitized.

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment