Building Custom Widgets for the Magento 2 Admin Dashboard
AI generated
M2
di.xml
Magento 2 · Admin Dashboard
Custom Widgets for the Admin Dashboard
KPI tiles, Chart.js integration, and a caching strategy for expensive queries

Magento's standard dashboard shows revenue, bestsellers, and returning customers, but there's no ready-made tile for business KPIs like stock alerts or open RMA requests. Understanding the dashboard architecture, data aggregation, and a clean caching strategy lets you build custom widgets that don't hit the full database on every single dashboard load, even at scale.

11 min read Dashboard Tabs KPI Tiles Chart.js Cache Strategy AJAX Refresh

1. How the Magento dashboard is structured internally

The admin dashboard is built on the Magento_Dashboard module and renders its tabs and tiles through classic blocks, not UI components. The main page, Magento\Dashboard\Block\Grids, acts as a container that iterates and renders individual tabs, the chart area, bestsellers, and recently ordered products via getChildNames. For a custom widget that means: it's not enough to just register another block somewhere, the widget has to be explicitly hooked into the existing dashboard layout structure as a child block.

In layout terms that means extending adminhtml_dashboard_index.xml with a custom referenceBlock on dashboard.grids, containing a block element pointing to the custom widget class. That class usually extends Magento\Backend\Block\Template, or in more complex cases with custom data aggregation, Magento\Backend\Block\Dashboard\AbstractDashboardWidget, which already provides some conventions for period filters and chart data.

2. Registering a custom widget via layout XML

The registration itself is pure layout configuration and requires no plugins or preferences, in line with the principle of extending additively rather than invasively. A custom template renders the tile with the metric, a trend arrow, and optionally a small sparkline chart, while the actual data lookup lives entirely in the PHP block and isn't computed inside the template itself.

Correct positioning via sortOrder within the dashboard container matters, otherwise new tiles appear unpredictably between existing standard widgets. For a stock alert widget, placing it right next to the bestsellers tile makes sense, since both are closely tied to the current assortment.


<!-- app/code/Mironsoft/DashboardWidgets/view/adminhtml/layout/adminhtml_dashboard_index.xml -->
<referenceBlock name="dashboard.grids">
    <block class="Mironsoft\DashboardWidgets\Block\Widget\StockAlerts"
           name="dashboard.widget.stock_alerts"
           template="Mironsoft_DashboardWidgets::widget/stock_alerts.phtml"
           after="dashboard.bestsellers"/>
</referenceBlock>

3. Data aggregation for the stock alert widget

The actual metric, how many products fall below the defined minimum stock level, isn't computed by iterating a live collection in the block, but through a targeted aggregation query against the inventory tables. With the Magento_InventorySalesApi module in place, that means querying the source item tables instead of relying solely on the older, now legacy cataloginventory_stock_item structure, since multiple warehouses otherwise wouldn't be accounted for correctly.

The block itself stays lean and delegates the actual aggregation to a dedicated, injectable service class that can be reused independently of the dashboard, in a CLI command or a notification. This separation pays off as soon as the same metric is later also needed as an email report or a dedicated admin notification.


<?php

declare(strict_types=1);

namespace Mironsoft\DashboardWidgets\Model;

use Magento\Framework\App\ResourceConnection;

/**
 * Aggregates the number of products whose stock quantity
 * is below the configured minimum threshold.
 */
class StockAlertAggregator
{
    /**
     * @param ResourceConnection $resourceConnection Database connection access
     */
    public function __construct(
        private readonly ResourceConnection $resourceConnection,
    ) {
    }

    /**
     * Counts products below the minimum stock threshold across sources.
     *
     * @param int $threshold Minimum stock threshold
     * @return int Number of affected products
     */
    public function countBelowThreshold(int $threshold): int
    {
        $connection = $this->resourceConnection->getConnection();
        $select = $connection->select()
            ->from(
                ['source_item' => $this->resourceConnection->getTableName('inventory_source_item')],
                [new \Zend_Db_Expr('COUNT(DISTINCT source_item.sku)')]
            )
            ->where('source_item.quantity < ?', $threshold)
            ->where('source_item.status = 1');

        return (int) $connection->fetchOne($select);
    }
}

4. Open RMA requests as a second KPI tile

If the shop runs a custom RMA module with its own entity, counting open return requests is closer to a classic collection query with addFieldToFilter on the status value, since there's no multi-warehouse concern here, just a simple status per record. Here too it matters not to count via getSize on a fully loaded collection, but through a pure count query, to avoid unnecessarily large result sets.

A raw count rarely tells the full story for a meaningful tile, comparing it against the value from a week ago is what actually delivers the relevant business trend. The same aggregation logic gets called twice with an additional time window parameter, once for the current and once for the previous period, and the difference is rendered as a trend arrow in the template.

5. Integrating Chart.js in the admin context

Magento already ships Chart.js as a bundled dependency for the standard sales chart, and a custom widget can reference that existing library via requirejs-config.js instead of bundling a second copy. The reference goes through a map alias pointing to the existing Chart.js instance, avoiding an extra load of the same library and unnecessary bloat in the admin JavaScript bundle.

Actually rendering the sparkline chart happens through a small Knockout or plain RequireJS component that receives the number series passed from the server via a data attribute and initializes on the DOM-ready event. Per project convention, registerInlineScript on the HyväCsp view model needs to be called after every embedded inline script block, and a comparable CSP hardening applies in the admin context too, so inline scripts should go through the proper mechanisms rather than a raw script tag.


// app/code/Mironsoft/DashboardWidgets/view/adminhtml/web/js/stock-alerts-sparkline.js
define(['jquery', 'chartjsAdmin'], function ($, Chart) {
    'use strict';

    return function (config, element) {
        var ctx = element.getContext('2d');

        new Chart(ctx, {
            type: 'line',
            data: {
                labels: config.labels,
                datasets: [{
                    data: config.values,
                    borderColor: '#f26322',
                    fill: false,
                    tension: 0.3
                }]
            },
            options: {
                plugins: { legend: { display: false } },
                scales: { x: { display: false }, y: { display: false } }
            }
        });
    };
});

6. Caching strategy for expensive dashboard queries

Running an aggregation query over several million inventory rows on every single dashboard load is noticeably slow even with a good index, as soon as several admin users open the dashboard at the same time. So the block doesn't read the metric directly from the aggregation class, but through an intermediate layer that holds the result under a dedicated cache tag in the configured cache backend, usually Redis, for a limited time.

A TTL of five to fifteen minutes is a good compromise between freshness and database load for most KPI tiles, since admin users rarely need second-precise values. A dedicated, descriptive cache tag instead of reusing an existing Magento tag also matters, so the tile can be invalidated specifically and without side effects on other cache areas, for example right after a manual stock import.


<?php

declare(strict_types=1);

namespace Mironsoft\DashboardWidgets\Model;

use Magento\Framework\App\CacheInterface;
use Magento\Framework\Serialize\SerializerInterface;

/**
 * Cached wrapper around expensive dashboard aggregations with a short TTL.
 */
class CachedStockAlertProvider
{
    private const CACHE_TAG = 'mironsoft_dashboard_stock_alerts';
    private const CACHE_LIFETIME = 600;

    /**
     * @param StockAlertAggregator $aggregator Performs the actual aggregation
     * @param CacheInterface $cache Configured cache backend
     * @param SerializerInterface $serializer Serializes the cached result
     */
    public function __construct(
        private readonly StockAlertAggregator $aggregator,
        private readonly CacheInterface $cache,
        private readonly SerializerInterface $serializer,
    ) {
    }

    /**
     * Returns the number of affected products, from cache if present.
     *
     * @param int $threshold Minimum stock threshold
     * @return int
     */
    public function getCount(int $threshold): int
    {
        $cacheKey = self::CACHE_TAG . '_' . $threshold;
        $cached = $this->cache->load($cacheKey);

        if ($cached !== false) {
            return (int) $this->serializer->unserialize($cached);
        }

        $count = $this->aggregator->countBelowThreshold($threshold);
        $this->cache->save(
            $this->serializer->serialize($count),
            $cacheKey,
            [self::CACHE_TAG],
            self::CACHE_LIFETIME
        );

        return $count;
    }
}

7. AJAX refresh pattern for individual tiles

Instead of reloading the whole dashboard page on every refresh, it's worth building a dedicated, lean controller for individual tiles that returns only the current metric as JSON. A small JavaScript interval based on setInterval, or more cleanly an Alpine-style polling mechanism, queries this endpoint at a configurable interval and updates only the number and the trend arrow in the DOM without re-rendering the whole page.

The controller itself reads from the same cached provider as the initial server-side rendering, so the caching strategy and data source are maintained in exactly one place. A polling interval that's too short, say every five seconds, makes little sense against a ten-minute cache TTL and just generates unnecessary requests, so the interval should be matched to the actual cache lifetime.

8. ACL and permissions for custom dashboard widgets

Not every admin user should automatically see every KPI tile, especially when RMA numbers or stock details are business-sensitive. So the block checks the current admin role against the matching ACL resource via AuthorizationInterface before rendering, and simply returns no content if the permission is missing, rather than showing an empty tile or an error message.

The ACL resource itself is declared in acl.xml like for any new module, and referenced in the system.xml menu as a dedicated configuration section for widget settings, such as the thresholds for the stock alert. This coupling ensures that both the widget's visibility and access to its configuration are controlled consistently through the same permission structure.

9. Testing and performance monitoring

For the aggregation class itself, an integration test that checks the generated SQL query against a known test data set is worth more than just testing the template rendering. That catches regressions in the counting logic early, for example after a change to the inventory structure, before they show up as a wrong number on the live dashboard.

For production, it's also worth checking the MySQL slow query log after deploying a new widget, especially when the underlying table has several million rows. A missing index on the filter column often goes unnoticed on a local test system with a few thousand test rows, but quickly becomes a noticeable performance problem on a production catalog for every admin user logged in at the same time.

Widget Type Data Source Refresh Recommended Cache TTL
Stock alert inventory_source_item aggregation AJAX polling 5-10 minutes
Open RMA requests custom RMA collection with status filter AJAX polling 2-5 minutes
Revenue sparkline (custom period) sales_order aggregation on page load 10-15 minutes
Failed bulk operations OperationRepositoryInterface AJAX polling 1-2 minutes
New customer registrations customer_entity aggregation on page load 10-15 minutes

Mironsoft

Magento development, module consulting, and system architecture

A Magento project that needs a second opinion or experienced execution?

We build custom Magento modules, advise on architecture decisions, and take on complex implementations, from service contract planning to production-ready deployment.

Architecture Consulting

Have module and system architecture thought through properly before you build.

Custom Module Development

Build custom Magento modules cleanly, following best practices.

Code Review & Audit

Have existing modules reviewed for performance, security, and maintainability.

10. Summary

Custom Dashboard Widgets in Magento 2: The Essentials

Architecture

Hook custom blocks into dashboard.grids via referenceBlock in adminhtml_dashboard_index.xml.

Data aggregation

Use targeted count and aggregation queries instead of fully loaded collections.

Visualization

Reference the existing Chart.js instance via requirejs-config.js instead of bundling a new one.

Operations

Short cache TTL with a dedicated tag, AJAX refresh instead of a full page reload, ACL-checked rendering.

11. FAQ: Custom Dashboard Widgets in Magento 2: The Essentials

1Can a custom dashboard widget be built with UI components instead of classic blocks?
The standard dashboard is fully built on classic blocks, a UI component widget could technically be embedded in parallel, but it doesn't integrate cleanly with the existing getChildNames iteration and is therefore rarely implemented that way in practice.
2Why does my widget show up in the wrong place on the dashboard?
Usually an explicit sortOrder or after attribute is missing in the referenceBlock, without it the order depends on layout XML processing order, which can differ between Magento versions.
3Does every dashboard tile need to be cached?
No, for very fast queries with a good index and low data volume a cache is often unnecessary complexity, but once query runtime becomes noticeable or several admin users are active at once, a cache clearly pays off.
4How does the cache get invalidated after a relevant data update?
The dedicated cache tag lets the area be cleared specifically via cache:clean with the tag name, and an observer on the relevant event, such as an import finishing, can additionally proactively delete the cache entry.
5Is Chart.js necessarily the right choice for custom charts in the admin area?
Since Magento already bundles Chart.js for the standard sales chart, reusing it saves an extra dependency, but for very specific chart types another library can still make more sense.
6How often should an AJAX polling interval refresh a tile?
The interval should be tied to the cache TTL of the underlying data, a shorter interval just generates extra requests without new information, since the cache returns the same value anyway.
7How is sensitive KPI data prevented from being visible to all admin roles?
The block checks the matching ACL resource via AuthorizationInterface before rendering and returns no content without permission, the assignment happens through acl.xml like for any module.
8Can multiple dashboard widgets share the same aggregation class?
Yes, that's actually recommended, a lean service class for the aggregation can be reused in the dashboard block as well as in a CLI command or an email notification.
9What happens to the widget once the underlying table grows very large?
Without a matching index on the filter column, the aggregation query gets progressively slower as the table grows, checking the slow query log after deployment helps catch this early.
10Is a custom dashboard widget worth building for a KPI that only matters once a day?
In that case a cron-generated email report or an entry in the admin notification area is often the better fit, a live dashboard widget with frequent polling pays off mainly for metrics that actually change throughout the day.