Devflow dashboard widgets let application code, plugins, site plugins, and the active theme add cards to the administration dashboard. A widget defines its identity, presentation metadata, default placement, sort order, optional permission, and a renderer that produces its body markup.

The public API is implemented by:

  • Infrastructure/Services/Dashboard/DashboardWidget.php
  • Infrastructure/Services/Dashboard/DashboardWidgetRegistry.php
  • Shared/Helpers/hook.php, which provides dashboard_widgets()

Register extension widgets on the dashboard_widgets_init action. Devflow fires this action after active plugins and the active theme have loaded and after the native widgets have been registered.

Quick start

Add this listener from code that runs during application, plugin, or theme bootstrap:

<?php

declare(strict_types=1);

use App\Infrastructure\Services\Dashboard\DashboardWidget;
use App\Infrastructure\Services\Dashboard\DashboardWidgetRegistry;
use Qubus\EventDispatcher\ActionFilter\Action;

use function Codefy\Framework\Helpers\trans_html;

Action::getInstance()->addAction(
    hook: 'dashboard_widgets_init',
    callback: static function (DashboardWidgetRegistry $widgets): void {
        $widgets
            ->register(
                id: 'acme.project-status',
                title: trans_html('Project Status'),
                renderer: static fn (): string => '<p>Everything is running normally.</p>',
            )
            ->description(trans_html('A summary of the current project state.'))
            ->icon('fa fa-list-check')
            ->column(DashboardWidget::COLUMN_RIGHT)
            ->priority(20)
            ->permission('view:project-status');
    },
    priority: 10,
    arguments: 1,
);

The final arguments: 1 tells the action dispatcher to pass the registry to the callback. It is the dispatcher's argument count, not a widget setting.

After this code loads, visit the administration dashboard. Users without a saved layout receive the widget in its configured default column. Users who already saved a layout can add a newly registered widget from the inactive-widget picker.

Registration lifecycle

Every dashboard display and dashboard-layout save follows this sequence:

  1. Retrieve the singleton DashboardWidgetRegistry.
  2. Clear its current widget collection.
  3. Register Devflow's native widgets.
  4. Fire dashboard_widgets_init, passing the registry as the action's only argument.
  5. Filter widgets for the current user's permissions.
  6. Resolve and sanitize the current user's saved layout.
  7. Render the active widgets and expose authorized inactive widgets to the picker.

The registry is deliberately rebuilt for each dashboard operation. Do not register a widget directly during an earlier bootstrap phase like this:

// Too early: the dashboard controller clears this registration before rendering.
dashboard_widgets()->register('acme.example', 'Example', static fn (): string => '...');

Instead, attach a callback to dashboard_widgets_init. Calling dashboard_widgets() from inside that callback is valid because the helper and the callback argument resolve the same registry instance:

use Qubus\EventDispatcher\ActionFilter\Action;

use function App\Shared\Helpers\dashboard_widgets;

Action::getInstance()->addAction(
    'dashboard_widgets_init',
    static function (): void {
        dashboard_widgets()->register(
            'acme.example',
            'Example',
            static fn (): string => '<p>Example content</p>',
        );
    },
);

Receiving the typed registry argument is generally clearer and easier to test.

dashboard_widgets_init action

Action::getInstance()->doAction(
    'dashboard_widgets_init',
    $widgets,
);

The action receives one DashboardWidgetRegistry argument and does not use a callback return value. Attach listeners with Action::getInstance()->addAction() or the equivalent __observer()->action->addAction() API. Listener priority defaults to 10; lower priorities run first.

Because all active plugin classes and the active theme have already executed their handle() methods, those methods are the natural place to attach the listener. Wrapping this registration in an additional plugins_loaded, theme_loaded, or after_setup_theme listener is unnecessary; attaching it directly guarantees that it exists before the dashboard controller fires dashboard_widgets_init.

Native widgets

Devflow registers these widgets before extension callbacks run:

ID Default column Priority
devflow.welcome Left 10
devflow.content-overview Left 20
devflow.quick-actions Right 10
devflow.system-info Right 20

Native widgets do not currently declare widget-level permissions, although their renderers may conditionally show individual actions. Extensions can inspect, modify, replace, or remove these registrations during dashboard_widgets_init.

Creating a widget

1. Choose a stable ID

Widget IDs must match this pattern:

^[a-z0-9][a-z0-9._-]*$

An ID must start with a lowercase ASCII letter or digit. The remaining characters may be lowercase letters, digits, periods, underscores, or hyphens. Uppercase letters, whitespace, slashes, and colons are invalid.

Use a vendor or extension prefix to avoid collisions:

acme.recent-orders
my-theme.editorial-status
analytics.weekly-summary

An ID is also the durable key stored in each user's dashboard layout. Changing it makes the old ID unavailable and causes the renamed widget to be treated as a new inactive widget for users with saved layouts.

Registering another widget with the same ID replaces the earlier registry entry. This can be used intentionally to replace a native widget, but accidental collisions are silent, so namespacing is strongly recommended.

2. Supply a title and renderer

register() requires a non-empty title and a callable renderer:

$widget = $widgets->register(
    'acme.release-notes',
    trans_html('Release Notes'),
    static fn (): string => '<p>No pending release notes.</p>',
);

The renderer is invoked with no arguments when the dashboard needs the widget body. It may return a string, print output, or do both:

$widgets->register(
    'acme.output-example',
    trans_html('Output Example'),
    static function (): string {
        echo '<p>This output was printed.</p>';

        return '<p>This output was returned.</p>';
    },
);

Printed output is captured first and concatenated with the returned string. A non-string return value contributes no returned markup. Exceptions are not swallowed; Devflow cleans the output buffer and rethrows the exception.

Keep renderers side-effect free where practical. Registration should only describe the widget; defer queries or expensive calculations to the renderer so they run only when the widget is actually displayed.

3. Configure presentation and access

The DashboardWidget returned by register() supports fluent configuration:

$widgets
    ->register('acme.activity', trans_html('Recent Activity'), $renderer)
    ->description(trans_html('The latest activity across this site.'))
    ->icon('fa fa-clock-rotate-left')
    ->column(DashboardWidget::COLUMN_LEFT)
    ->priority(30)
    ->permission('view:activity');

All fluent setters return the same widget instance.

Rendering dynamic content safely

The registry treats renderer output as HTML. It does not escape or sanitize the returned markup. Escape every dynamic value for the context in which it is inserted.

use function Qubus\Security\Helpers\esc_html;
use function Qubus\Security\Helpers\esc_url;

$widgets->register(
    'acme.latest-report',
    trans_html('Latest Report'),
    static function () use ($report): string {
        return sprintf(
            '<p><a href="%s">%s</a></p>',
            esc_url($report->url),
            esc_html($report->title),
        );
    },
);

Use trans_html() for translated plain-text labels that will be displayed as HTML. Do not put untrusted content directly into a title, description, icon class, URL, attribute, or renderer result. If a renderer builds JavaScript, CSS, or attributes, use the corresponding context-specific escaping mechanism rather than HTML escaping alone.

Permission requirements hide unauthorized widgets before layout resolution and rendering, but they are not a substitute for authorization inside sensitive services. A renderer or service that exposes protected data should still enforce its own access rules when it can be called through another path.

One-off application widget

A one-off widget is useful for installation-specific functionality that does not belong to a reusable plugin or theme. Put the listener in an application bootstrap file or service provider that executes before the dashboard controller handles the request.

<?php

declare(strict_types=1);

namespace Application\Provider;

use App\Infrastructure\Services\Dashboard\DashboardWidget;
use App\Infrastructure\Services\Dashboard\DashboardWidgetRegistry;
use Codefy\Framework\Support\CodefyServiceProvider;
use Qubus\EventDispatcher\ActionFilter\Action;

use function App\Shared\Helpers\admin_url;
use function Codefy\Framework\Helpers\trans_html;
use function Qubus\Security\Helpers\esc_url;

final class DashboardWidgetsServiceProvider extends CodefyServiceProvider
{
    public function boot(): void
    {
        Action::getInstance()->addAction(
            hook: 'dashboard_widgets_init',
            callback: [self::class, 'register'],
            priority: 10,
            arguments: 1,
        );
    }

    public function register(): void
    {
        $widgets = DashboardWidgetRegistry::getInstance();

        $widgets
            ->register(
                'project.editorial-shortcut',
                trans_html('Editorial'),
                static fn (): string => sprintf(
                    '<p><a class="btn btn-primary" href="%s">%s</a></p>',
                    esc_url(admin_url('content/')),
                    trans_html('Open content'),
                ),
            )
            ->description(trans_html('A shortcut to the editorial workspace.'))
            ->icon('fa fa-pen-to-square')
            ->column(DashboardWidget::COLUMN_LEFT)
            ->priority(15)
            ->permission('manage:content');
    }
}

File: ./Cms/Application/Provider/DashboardWidgetsServiceProvider.php

Add Application\Provider\DashboardWidgetsServiceProvider::class to the application's provider list.

For a very small one-off, the anonymous-listener example in Quick start is sufficient.

Plugin widget

Active plugins execute their handle() method before Devflow fires dashboard_widgets_init. Register the listener in handle(), and keep registration and rendering in separate methods when the widget is more than a few lines.

The following is the relevant portion of a plugin class; retain the plugin's existing constructor, metadata, activation, and deactivation code:

<?php

declare(strict_types=1);

namespace Plugin\Reports;

use App\Infrastructure\Services\Dashboard\DashboardWidget;
use App\Infrastructure\Services\Dashboard\DashboardWidgetRegistry;
use App\Infrastructure\Services\Plugin;
use Qubus\EventDispatcher\ActionFilter\Action;

use function Codefy\Framework\Helpers\trans_html;
use function Qubus\Security\Helpers\esc_html;

final class ReportsPlugin extends Plugin
{
    public function handle(): void
    {
        Action::getInstance()->addAction(
            hook: 'dashboard_widgets_init',
            callback: [$this, 'registerDashboardWidgets'],
            priority: 10,
            arguments: 1,
        );
    }

    public function registerDashboardWidgets(DashboardWidgetRegistry $widgets): void
    {
        $widgets
            ->register(
                'acme-reports.pending',
                trans_html('Pending Reports'),
                [$this, 'renderPendingReports'],
            )
            ->description(trans_html('Reports waiting for review.'))
            ->icon('fa fa-chart-line')
            ->column(DashboardWidget::COLUMN_RIGHT)
            ->priority(25)
            ->permission('review:reports');
    }

    public function renderPendingReports(): string
    {
        $count = $this->pendingReportCount();

        return sprintf(
            '<p>%s</p>',
            sprintf(
                trans_html('%s reports are waiting for review.'),
                esc_html((string) $count),
            ),
        );
    }

    private function pendingReportCount(): int
    {
        // Query the plugin's repository or service here.
        return 3;
    }
}

File: ./public/plugins/Reports/ReportsPlugin.php

The example is intended to be merged into a valid plugin class that already implements its required meta() method. It does not replace the plugin's metadata or installation structure.

Methods used as deferred callbacks should be public. The plugin object is created through Devflow's application container, so the renderer can use the dependencies already injected into the plugin. For larger integrations, inject a dedicated renderer or query service and pass one of its public callables to register().

When the plugin is inactive, handle() does not run, its listener is not attached, and its widgets are omitted automatically. Stale IDs in saved user layouts are discarded when those layouts are sanitized.

Global plugins and site plugins follow the same pattern: attach the listener from handle(). A site plugin may extend App\Infrastructure\Services\Site\SitePlugin instead of Plugin, but the dashboard hook and registry code are unchanged.

Theme widget

The active theme also executes its handle() method before dashboard widget registration. A theme uses the same hook and registry API as a plugin:

<?php

declare(strict_types=1);

namespace Theme\Editorial;

use App\Infrastructure\Services\Dashboard\DashboardWidget;
use App\Infrastructure\Services\Dashboard\DashboardWidgetRegistry;
use App\Infrastructure\Services\Theme;
use Qubus\EventDispatcher\ActionFilter\Action;

use function Codefy\Framework\Helpers\trans_html;

final class EditorialTheme extends Theme
{
    public function handle(): void
    {
        Action::getInstance()->addAction(
            hook: 'dashboard_widgets_init',
            callback: [$this, 'registerDashboardWidgets'],
            priority: 10,
            arguments: 1,
        );
    }

    public function registerDashboardWidgets(DashboardWidgetRegistry $widgets): void
    {
        $widgets
            ->register(
                'editorial-theme.guidelines',
                trans_html('Editorial Guidelines'),
                [$this, 'renderGuidelines'],
            )
            ->description(trans_html('Writing and publishing guidance for this site.'))
            ->icon('fa fa-book-open')
            ->column(DashboardWidget::COLUMN_LEFT)
            ->priority(40)
            ->permission('create:content');
    }

    public function renderGuidelines(): string
    {
        return '<p>Use a clear title, a concise summary, and meaningful alternative text.</p>';
    }
}

File: ./public/themes/Editorial/EditorialTheme.php

As with the plugin example, merge these methods into a valid theme class that retains its required meta() implementation.

Only the active theme is loaded, so a theme widget disappears when the theme changes. Use a plugin instead when the widget represents application behavior or data that should survive theme changes. Use a theme widget when its content is genuinely coupled to the theme's editorial or presentation experience.

Public API reference

dashboard_widgets()

function dashboard_widgets(): DashboardWidgetRegistry

Returns the singleton dashboard widget registry. The helper is autoloaded from Shared/Helpers/hook.php.

Prefer the DashboardWidgetRegistry argument provided by dashboard_widgets_init during registration. The helper is useful in callbacks that cannot conveniently accept the argument or in code that needs to inspect the active registry while the dashboard registration lifecycle is running.

DashboardWidgetRegistry

The registry stores widgets by ID.

register()

public function register(string $id, string $title, callable $renderer): DashboardWidget

Creates a widget, stores it under $id, and returns it for fluent configuration. If the ID is already registered, the new widget replaces the old one.

add()

public function add(DashboardWidget $widget): self

Stores an already-created widget and returns the registry:

$widget = DashboardWidget::make('acme.health', 'Health', $renderer)
    ->column(DashboardWidget::COLUMN_RIGHT);

$widgets->add($widget);

As with register(), an existing widget with the same ID is replaced.

remove()

public function remove(string $id): self

Removes a widget when present and returns the registry. Removing an unknown ID has no effect.

Because native widgets are registered before dashboard_widgets_init, an extension can remove one in its hook callback:

$widgets->remove('devflow.welcome');

Use this sparingly. Removing a native widget changes the dashboard for every user for whom the extension is active.

get() and has()

public function get(string $id): ?DashboardWidget
public function has(string $id): bool

Use these methods to inspect or amend a registration:

if ($widgets->has('devflow.system-info')) {
    $widgets->get('devflow.system-info')?->permission('view:system-info');
}

get() returns null for an unknown ID.

all()

public function all(?callable $can = null): array

Returns an array keyed by widget ID and sorted by ascending widget priority, then ascending title. When supplied, $can must have the signature callable(string): bool. Widgets without a permission always remain; widgets with a permission remain only when $can($permission) returns true.

$visible = $widgets->all(
    static fn (string $permission): bool => current_user_can($permission),
);

Without $can, all() returns every registered widget, including permission-protected widgets. The dashboard controller always supplies a current-user permission callback.

defaultLayout()

public function defaultLayout(?callable $can = null): array

Returns the authorized widgets grouped into the left and right columns. Each column contains an ordered list of widget IDs:

[
    'left' => ['devflow.welcome', 'acme.activity'],
    'right' => ['devflow.quick-actions'],
]

This uses each widget's configured default column and the ordering produced by all().

sanitizeLayout()

public function sanitizeLayout(mixed $layout, ?callable $can = null): array

Normalizes untrusted or stored layout data to exactly two columns. It removes:

  • Unknown widget IDs.
  • Unauthorized widget IDs.
  • Non-string entries.
  • Duplicate IDs, preserving the first occurrence encountered while scanning left and then right.
  • Entries under unsupported column keys and malformed left or right column lists.

A non-array input produces two empty columns. This method does not append widgets missing from the submitted layout.

resolveLayout()

public function resolveLayout(mixed $savedLayout, ?callable $can = null): array

Returns defaultLayout() only when $savedLayout is null, which means no preference has been saved. Every non-null value is sanitized. In particular, a saved layout with empty columns is intentional and means that the user removed every widget.

This distinction also means a widget registered after a user saved a layout is not inserted automatically. It is returned by inactive() so the user can opt in.

inactive()

public function inactive(array $layout, ?callable $can = null): array

Returns authorized registered widgets that do not appear in either active column. The returned array is keyed by widget ID and retains the registry's priority-and-title ordering.

Pass a normalized two-column layout, normally the result of resolveLayout() or sanitizeLayout().

clear()

public function clear(): void

Removes every registration. Devflow uses this internally before rebuilding the collection. Extension code should not call it during dashboard_widgets_init, because doing so also removes native widgets and widgets registered by earlier callbacks.

DashboardWidget

Create a widget directly with either the constructor or the named factory:

public function __construct(string $id, string $title, callable $renderer)
public static function make(string $id, string $title, callable $renderer): self

DashboardWidgetRegistry::register() is the shortest and preferred creation path. Construction throws InvalidArgumentException for an invalid ID or an empty title.

Fluent setters

Method Default Meaning
description(string $description): self '' Short explanatory text shown by the dashboard UI.
icon(string $icon): self 'fa fa-square' CSS classes for the widget icon. Devflow's native widgets use Font Awesome-style classes. The value is not validated.
column(string $column): self 'left' Default column. Must be DashboardWidget::COLUMN_LEFT or DashboardWidget::COLUMN_RIGHT; any other value throws InvalidArgumentException.
priority(int $priority): self 10 Default sort weight. Lower values appear before higher values. Equal priorities are ordered by title. Negative values are accepted.
permission(?string $permission): self null Permission required to see, activate, and render the widget. null means no widget-specific requirement.

Use the constants instead of repeating column strings:

DashboardWidget::COLUMN_LEFT;  // 'left'
DashboardWidget::COLUMN_RIGHT; // 'right'

Accessors

Method Return value
id(): string Stable widget ID.
title(): string Display title.
getDescription(): string Description.
getIcon(): string Icon CSS classes.
getColumn(): string Default column.
getPriority(): int Widget sort priority.
getPermission(): ?string Required permission or null.

The ID, title, and renderer cannot be changed after construction. To change one of them, register a replacement with the same ID.

render()

public function render(): string

Invokes the zero-argument renderer, captures printed output, appends a string return value, and returns the combined markup. If the renderer throws, the exception is rethrown after the output buffer is cleaned.

Most widget authors do not need to call this method; the dashboard view calls it when rendering an active widget.

Placement, ordering, and user customization

The values configured by column() and priority() describe the default layout. They determine placement only when the current user has no saved dashboard.widgets preference.

Once a user saves a layout:

  • Their selected columns and order take precedence.
  • Changing a widget's column() or priority() does not move it in that saved layout.
  • New widgets remain inactive until the user adds them.
  • Removed or unauthorized widget IDs are silently discarded during sanitization.
  • An empty saved layout remains empty; it is not replaced by defaults.

For users without a site-user record, including network super administrators in the current dashboard implementation, the dashboard is fixed and uses the default layout rather than a persisted customizable layout.

Widget priority and hook priority solve different problems:

Priority Configured on Effect
Hook priority Action::addAction(..., priority: ...) Determines when a registration callback runs. Lower values run earlier. A later callback can replace or remove an earlier widget.
Widget priority DashboardWidget::priority(...) Determines default widget order and registry listing order. Lower values sort first; titles break ties.

Replacing or modifying a widget

Because native widgets exist before the hook fires, a callback may modify their mutable metadata:

$widgets->get('devflow.welcome')
    ?->column(DashboardWidget::COLUMN_RIGHT)
    ->priority(5)
    ->permission('access:admin');

To replace immutable fields such as the title or renderer, register the same ID again:

$widgets
    ->register(
        'devflow.welcome',
        trans_html('Welcome to Our Editorial Team'),
        static fn (): string => '<p>Review the publishing guide before getting started.</p>',
    )
    ->icon('fa fa-hand-wave')
    ->column(DashboardWidget::COLUMN_LEFT)
    ->priority(10);

Re-registering creates a new object, so repeat every non-default setting that the replacement should retain. If multiple extensions intentionally target the same ID, set hook priorities explicitly so the winning registration is predictable.

Testing

Registration logic can be tested without rendering a full dashboard. The registry is a singleton, so clear it before and after an isolated test:

<?php

use App\Infrastructure\Services\Dashboard\DashboardWidget;
use App\Infrastructure\Services\Dashboard\DashboardWidgetRegistry;

$widgets = DashboardWidgetRegistry::getInstance();
$widgets->clear();

$widgets
    ->register('acme.secure', 'Secure', static fn (): string => '<p>Secret</p>')
    ->column(DashboardWidget::COLUMN_RIGHT)
    ->priority(5)
    ->permission('view:secure');

$widgets
    ->register('acme.public', 'Public', static fn (): string => '<p>Public</p>')
    ->column(DashboardWidget::COLUMN_LEFT)
    ->priority(10);

$can = static fn (string $permission): bool => false;
$layout = $widgets->defaultLayout($can);

assert($layout === [
    DashboardWidget::COLUMN_LEFT => ['acme.public'],
    DashboardWidget::COLUMN_RIGHT => [],
]);
assert($widgets->get('acme.public')?->render() === '<p>Public</p>');

$widgets->clear();

For an integration check:

  1. Activate the plugin or theme, or load the application bootstrap containing the listener.
  2. Sign in as a user who has the required permission and confirm the widget is available.
  3. Sign in as a user without the permission and confirm it is absent from both the dashboard and inactive picker.
  4. Move the widget between columns, reorder it, save, and reload the dashboard.
  5. Remove every widget and confirm the empty layout remains empty after reload.
  6. Disable the plugin or change the theme and confirm its stale saved ID is ignored safely.

Common mistakes

  • Registering before the hook: the controller clears the registry immediately before native and extension registration. Attach a dashboard_widgets_init listener instead.
  • Using an unstable or invalid ID: use a permanent lowercase, namespaced ID containing only letters, digits, ., _, and -.
  • Confusing the two priorities: hook priority controls callback execution; widget priority controls default display order.
  • Expecting defaults to override user choices: a saved layout, including an empty one, takes precedence over default column and priority values.
  • Forgetting the action argument count: declare arguments: 1 when registering a callback that receives DashboardWidgetRegistry.
  • Returning unescaped data: renderer output is trusted as markup. Escape dynamic text, URLs, and attributes for their output context.
  • Doing expensive work during registration: defer data loading to the renderer so inactive or unauthorized widgets do not perform unnecessary work.
  • Relying only on widget visibility for security: retain authorization checks in protected controllers and services.
  • Clearing the registry from an extension: this removes core widgets and registrations from callbacks that ran earlier.
  • Assuming a new widget appears automatically for everyone: users with an existing saved layout must add it from the inactive-widget picker.