Make your feature reachable from the admin sidebar, give it breadcrumbs, and put summary cards on the dashboard — or take core items away.
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).
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');
});MenuItems API| Method | Purpose |
|---|---|
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) |
MenuManager API| Method | Purpose |
|---|---|
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.
access {alias} and each child with its own permission (TruCommerceMenu::register()).[key, label, icon, route, permission] tuples and loop — it keeps long menus (Booking's two groups hold 21 children) readable.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 />.
Dashboard registry)A dashboard is a named list of rows; a row holds items. The view calls @dashboard('name'). Facade App\Facades\Dashboard:
| Call | Meaning |
|---|---|
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 |
| Dashboard | Row (order) | Items (id) |
|---|---|---|
admin.dashboard | counters (10) | admin.counters |
main (30) | admin.graph (col-xl-8), admin.posts-list (col-xl-4) | |
users (50) | admin.users-list | |
user.dashboard | account-overview (10) | app.user-overview |
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(),
]);
}
}$params as their config array (params: ['limit' => 5]).async: true and a lightweight placeholder(); the core's graph widget is an example. Async widgets are fetched after page load through the package's own route (arrilot/load-widget), a separate request.CountersWidget, GraphWidget, …) call Theme::set('admin') in their constructor because an async request does not pass through AdminTheme. If your widget renders theme views asynchronously, do the same.Dashboard::user()) renders in the front theme: write markup for Echo, not CoreUI.// 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.