Adding a Dashboard Widget
Add a card to the admin dashboard by returning a descriptor from one registration hook, with no core file touched.
Overview
Dashboard widgets are not a module type: there is no widget module and no base class. get_widgets() builds them out of privilege checks, one template shows them, hooks extend them.
You return an array from register:admin.dashboard_widgets. The core gives it a rank, applies the operator's saved layout, and hands it to the shared card shell. Four more hooks reach the rest.
Prerequisites
- A module with a working
hooks.php; see Registering Hooks from a Module. - Your content as an HTML string; the body is echoed as is.
- A privilege key if the card should be restricted.
Structure
get_widgets() builds the list and merges your hook's return; get_statistics() builds the figures strip.
get_widget_content, behind the refresh button.
Walkthrough
Register the Card
- Listen on
register:admin.dashboard_widgetsfrom yourhooks.phpand return one descriptor array. - Give it at least
name,titleandcontent; everything else has a default. - Check the privilege inside the listener: return
falsewhen it fails, or setallowedto the result.
Build the Body
- Produce the HTML with heredoc and read your strings from the module's language file.
- Build it inside the listener, not in the file body, so a dashboard without your card costs nothing.
- Reserve the height of anything that fills in later.
Control Placement and Size
- Set
rankfor the order in the markup; left out, the core appends yours last. - Set
sizetowidefor a full-width card; any other value gives the standard half-width one. - For the strip, the surrounding markup or someone else's cards, use the four other hooks below.
Reference
The Five Dashboard Hooks
| Hook | Call site | Your return |
|---|---|---|
register:admin.dashboard_widgets | Hook::run, no arguments | one descriptor becomes one card; false registers nothing |
filter:admin.dashboard.widgets | Hook::runRefs, $widgets by reference | edit in place: reorder, drop, retitle |
filter:admin.dashboard.statistics | Hook::run, $result | a non-empty return replaces the whole array |
ui:admin.dashboard.top | Hook::run, no arguments | a string above the figures strip |
ui:admin.dashboard.statistics.after and ui:admin.dashboard.bottom | same, no arguments | a string after the strip, and after the grid |
The call site keeps the last non-empty listener return and assigns it over the whole result. Change the key you care about and return the whole array; returning only your own key wipes the strip, which still looks plausible.
The Descriptor Array
data-id, the saved-layout key, and the refresh argument. Default wt{rank}, never rely on it.
Untitled Widget; wrapped in a link when you set link.
bi bi-box.
open or close, default open. A closed card shows its header only.
wide adds the full-width class; any other value keeps the standard width.
['create' => ['name' => …, 'link' => …, 'icon' => …]]. create gets a plus icon; other keys use icon, or the name as text.
What the Core Fills In
$hook = Hook::run("register:admin.dashboard_widgets");
if ($hook)
{
$last = end($widgets);
$rank = $last["rank"] ?? 10;
foreach ($hook as $h)
{
$rank++;
$wn = "wt" . $rank;
if (isset($h["name"]) && $h["name"]) $wn = $h["name"];
if (!isset($h["allowed"])) $h["allowed"] = true;
if (!isset($h["status"])) $h["status"] = "open";
if (!isset($h["rank"])) $h["rank"] = $rank;
$widgets[$wn] = $h; // your name is the key: it can REPLACE a built-in card
}
}
The Refresh Button
public function get_widget_content(Operation $operation): bool;
// GET {dashboard}?operation=get_widget_content&name={your name}
$widgets = $this->get_widgets(); // the WHOLE list is rebuilt, hook included
if (!isset($widgets[$wn])) throw new Exception("Invalid widget");
// One card re-rendered through the same shell, returned as an HTML string.
return $operation->output($this->view->chose("admin")->render("inc" . DS . "template-widget-item", [
'widget' => $widgets[$wn],
], true));
Putting a Table in a Card
$t = new \WISECP\Components\Table("myAddonWidget", [
'preset' => 'invoiceList', // row rendering comes from the MAIN list
'hideActions' => true,
'perPage' => false,
'search' => false,
'info' => false,
'pagination' => false,
]);
foreach ($t->getColumns() as $k => $v) $t->setColumn($k, ['sortable' => false]);
$t->deleteColumn("selection");
$t->setRows($rows);
$html = $t->build();
A widget table borrows the main list's row builder, which reads far more keys than the columns you kept. A missing key shows an empty cell instead of failing. Derive the list from the preset file; a change there reaches your card too.
Example
A module's own queue: registered, gated, ranked and refreshable.
<?php
Modules::Load('Addons', 'MyAddon', true);
$my_config = Modules::Config('Addons', 'MyAddon') ?: [];
if (!($my_config['status'] ?? false)) return;
/*
* One card on the dashboard. The listener takes no arguments and returns ONE descriptor;
* it runs on every dashboard render and on every refresh of this card, so it stays cheap
* and builds the module instance only after the privilege check has passed.
*/
Hook::add('register:admin.dashboard_widgets', 10, function () {
if (!Admin::isPrivilege('TOOLS_ADDONS')) return false;
$module = Modules::getInstance('Addons', 'MyAddon');
return [
'name' => 'myaddon_queue',
'title' => $module->lang['widget-title'] ?? 'My Addon Queue',
'icon' => 'bi bi-list-check',
'rank' => 6,
'status' => 'open',
'link' => LinkGenerator::admin('tools-2', ['addons', 'MyAddon']),
'buttons' => [
'create' => [
'name' => Language::gc('admin/index/button-create-a-new'),
'link' => LinkGenerator::wQS(LinkGenerator::admin('tools-2', ['addons', 'MyAddon']), ['trigger' => 'create']),
],
],
'content' => $module->render_dashboard_widget(),
];
});
/* A figure on the strip. Read what you were given, change one key, return ALL of it. */
Hook::add('filter:admin.dashboard.statistics', 10, function ($result) {
if (!is_array($result)) return false;
$result['static_blocks']['myaddon_pending'] = [
'title' => 'Pending syncs',
'value' => (int) WDB::select('COUNT(id) AS total')->from('MyAddon_queue')
->where('status', '=', 'pending')->build() ? (int) (WDB::getAssoc()['total'] ?? 0) : 0,
];
return $result;
});
public function render_dashboard_widget(): string
{
$rows = '';
foreach ($this->queue_preview(5) as $row) {
$label = htmlspecialchars((string) ($row['label'] ?? ''), ENT_QUOTES);
$state = htmlspecialchars((string) ($row['status'] ?? ''), ENT_QUOTES);
$rows .= <<<HTML
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-truncate">{$label}</span>
<span class="badge text-bg-light">{$state}</span>
</li>
HTML;
}
if ($rows === '')
$rows = '<li class="list-group-item px-0 text-body-secondary">' . ($this->lang['widget-empty'] ?? 'Nothing queued.') . '</li>';
// The class carries the reserved height (see the stylesheet below), which is what stops
// the whole grid from repacking once the list fills in.
return <<<HTML
<ul class="list-group list-group-flush myaddon-queue">{$rows}</ul>
HTML;
}
/* The final height, declared before the content exists. Ship it through
ui:admin.head.css from the same hooks.php that registers the card. */
.myaddon-queue { min-block-size: 220px; }
The reading side is the card shell: the fallback for a missing content, and why the body is not escaped.
$w_name = $widget["name"] ?? "widget" . $w_rank;
$w_title = ($widget["title"] ?? '') ?: 'Untitled Widget';
$w_icon = ($widget["icon"] ?? '') ?: 'bi bi-box';
$w_content = $widget["content"] ?? '';
if ($w_name == "orders_chart") {
// ... a long chain of built-in names, one branch each
}
else
echo $w_content ?: $w_name; // your HTML, unescaped, or the name when you forgot it
Pitfalls
The layout is masonry and re-measures every card on each pass. A body that fills in later changes its card's height, and unrelated cards slide across the screen. Measured: a chart box growing 54 pixels moved two other cards 733 pixels.
A stored rank, collapsed state or hidden flag for your widget name beats the descriptor, and it travels in a cookie: a card registered open can be collapsed for one administrator, open for a colleague. A collapsed card's content is in the page, hidden by CSS.
It also runs for each refresh of any card, because that operation rebuilds the entire list. That is what makes the refresh button work, and it means a query there is paid every time. Do the privilege check first and cache anything expensive.
The merge keys by name, so notes or tasks as your widget name takes over the built-in card. Prefix the name with your module.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.