Admin Menu, Breadcrumbs and Dashboard Widgets

Make your feature reachable from the admin sidebar, give it breadcrumbs, and put summary cards on the dashboard — or take core items away.

The sidebar (MenuManager)

The admin sidebar is a collection of App\Services\Menu\MenuItems held by the MenuManager singleton (facade App\Facades\MenuManager). It is filled on every admin request: first by AdminAsideMenu (core), then by every AdminBooting listener (plugins). Register inside an AdminBooting listener, never in boot() (the signed-in user is not known yet there).

A top-level entry with sub-entries

simplified from Modules/Testimonials/app/Helpers/TestimonialsMenu.php

use App\Facades\MenuManager;
use App\Services\Menu\MenuItems;

MenuManager::make('testimonials', function (MenuItems $menu): void {
    $menu->label('testimonials::testimonials.menu')   // translation key, resolved when rendered
        ->menuType(MenuItems::ADMIN)                  // required: ADMIN or FRONT
        ->icon('nav-icon ph ph-quotes')              // Phosphor icon class
        ->route('admin.testimonials.index');         // named route (type becomes "route")
}, 36);                                                // priority: lower = higher in the sidebar

MenuManager::get('testimonials')->subMenu('create', function (MenuItems $menu): void {
    $menu->key('create')
        ->label('testimonials::testimonials.create')
        ->menuType(MenuItems::ADMIN)
        ->icon('nav-icon ph ph-caret-right')
        ->route('admin.testimonials.create');
});
MethodPurpose
key(string)Unique id (used as the DOM id of the link). Required for sub-menus
label(string)Display text or a translation key; passed through trans() when read
menuType(string)MenuItems::ADMIN or MenuItems::FRONT. The admin sidebar shows only ADMIN
icon(string)CSS classes for the <i>. Convention: nav-icon ph ph-{name} (Phosphor)
route(string $name, ?array $params = [])Link to a named route (sets type = route). Unknown route names render as #
params(array)Route parameters
url(string)External or custom URL (sets type = custom); add target('_blank')
badge(?int), badgeClass(string)Counter bubble (primary, danger, …); Patches uses it for available patches
priority(int)Sort order. MenuManager::make($key, $cb, $order) sets it for you
subMenu(string $key, callable)Add a child; call subMenu() with no arguments to read them (sorted)
classes(), tag(), attributes()Extra HTML hooks (used by front-end menus)
MethodPurpose
make($key, $callable, ?int $order = null)Create or replace an entry. Clears the per-user cache key admin_menu_builder_{userId}
get($key) / has($key)Fetch or test an entry (core keys below)
remove($key) / clear()Delete one entry or all of them
all(bool $admin = true), adminMenus()Entries sorted by priority; adminMenus() is what the sidebar renders (name, icon, badge, url, submenus)

Core keys you can get(), modify or remove(): dashboard, pages, posts, appearance, users, advertisements, settings, support-tickets, languages, patches. Because your AdminBooting listener runs after the core registered them, you may add a child to an existing group (MenuManager::get('appearance')?->subMenu(...)) or hide one:

Event::listen(AdminBooting::class, function (): void {
    MenuManager::remove('advertisements');   // the entry disappears for every admin user
});

Hiding is not securing

Removing a sidebar entry only hides the link. The routes are still reachable by URL until you also change their permissions.

Conventions copied from the bundled modules

The admin layout prints Breadcrumbs::render() and builds the browser title from Breadcrumbs::current(). Register one per page route in a file your provider requires:

Modules/Testimonials/routes/breadcrumbs.php

use Diglactic\Breadcrumbs\Breadcrumbs;
use Diglactic\Breadcrumbs\Generator as BreadcrumbTrail;

Breadcrumbs::for('admin.testimonials.index', function (BreadcrumbTrail $trail): void {
    $trail->parent('admin.dashboard');      // defined by the core
    $trail->push(__('testimonials::testimonials.menu'), route('admin.testimonials.index'));
});

Breadcrumbs::for('admin.testimonials.edit', function (BreadcrumbTrail $trail, Testimonial $testimonial): void {
    $trail->parent('admin.testimonials.index');
    $trail->push($testimonial->author_name, route('admin.testimonials.edit', $testimonial));
});

Route-model-bound parameters are passed to the callback in the order of the route's parameters. A page route with no definition throws; a route that only redirects needs none. The front end has its own breadcrumb names (front, pages.show, blog routes) rendered by <x-application-breadcrumbs />.

The dashboard (Dashboard registry)

A dashboard is a named list of rows; a row holds items. The view calls @dashboard('name'). Facade App\Facades\Dashboard:

CallMeaning
Dashboard::admin()The default admin dashboard (admin.dashboard)
Dashboard::user()The visitor account dashboard (user.dashboard)
Dashboard::for('myplugin.dashboard')Your own dashboard, rendered with @dashboard('myplugin.dashboard')
->row($name, int $order = 100, string $class = 'row match-height', ?string $permission = null)Create (or fetch) a row. class is the wrapper's CSS class; permission hides the whole row from users without it
->widget($id, $widget, bool $async = true, array $params = [], int $order = 100, ?string $permission = null, ?string $class = 'col-12')An Arrilot widget (class name or registered name); async loads it after page load
->include($id, $view, $data, ...)A Blade view
->component($id, $component, $data, ...)A Blade component (x-dynamic-component)
->html($id, $html, ...)Raw HTML (trusted)
removeRow($dashboard, $row), removeItem($dashboard, $row, $item), removeDashboard(), clear()Take things away, for example the default row main of admin.dashboard

Default rows you can place around or remove

DashboardRow (order)Items (id)
admin.dashboardcounters (10)admin.counters
main (30)admin.graph (col-xl-8), admin.posts-list (col-xl-4)
users (50)admin.users-list
user.dashboardaccount-overview (10)app.user-overview

Writing a dashboard widget

Modules/Testimonials/app/Widgets/Admin/TestimonialsCounterWidget.php

use Arrilot\Widgets\AbstractWidget;

class TestimonialsCounterWidget extends AbstractWidget
{
    protected array $config = [];                  // typed: Arrilot declares it as array

    // public int|float|bool $cacheTime = 60;      // minutes; false disables widget caching
    // public int|float|bool $reloadTimeout = 60;  // seconds; auto-reload for async widgets

    public function run(): View
    {
        return view('testimonials::widgets.admin.counter', [
            'total' => Testimonial::query()->count(),
            'published' => Testimonial::query()->published()->count(),
        ]);
    }
}

A dashboard of your own

// Provider boot()
Dashboard::for('shop.dashboard')
    ->row('counters', order: 10, class: 'row g-4')
    ->widget(id: 'shop.counters', widget: ShopCountersWidget::class, async: false, class: 'col-12');

// resources/views/admin/dashboard.blade.php
<x-admin-layout>
    @dashboard('shop.dashboard')
</x-admin-layout>

This is exactly how TruCommerce (trucommerce.dashboard) and TruPayments (trupayments.dashboard) build their own dashboard pages.