Skip to content
Developer docs

Admin bar for apps and themes

Updated 10 min read

The admin bar is part of core and works with every theme: core adds it to the finished page, the same way it adds SEO tags, so a theme needs nothing to support it. Apps add their own menus, live panels, badges, notices and search results through one API, with the same permission checks as core's own sections. This page is for developers. What site owners see and can change is in How to use the admin bar on your site.

Adding to the bar from an app

Apps add to the bar from boot() through $this->api()->adminBar(). They never touch core's templates or scripts.

public function boot(): void
{
    $bar = $this->api()->adminBar();

    // A menu of your own, with an item, a live panel and a badge.
    $bar->menu(['id' => 'analytics', 'label' => 'Analytics', 'icon' => 'chart-bar', 'capability' => 'edit_posts', 'priority' => 60]);
    $bar->item(['id' => 'report', 'parent' => 'analytics', 'label' => 'Full report', 'href' => '/admin/my-analytics']);
    $bar->panel(['id' => 'today', 'parent' => 'analytics', 'label' => 'This page today', 'context' => ['single'],
        'data' => fn($ctx, $user) => ['rows' => [['label' => 'Views', 'value' => $this->views($ctx->contentId())]]]]);
    $bar->indicator(['id' => 'live', 'parent' => 'analytics', 'type' => 'count', 'value' => fn() => $this->visitorsNow()]);

    // An item in core's page menu, on posts and pages, for SEO managers.
    $bar->item(['id' => 'seo', 'parent' => 'context', 'label' => 'Edit SEO', 'context' => ['post', 'page'],
        'capability' => 'manage_seo', 'href' => '/admin/my-seo']);

    // A notice for the bell, and quick-search results.
    $bar->notifications(['id' => 'alerts', 'capability' => 'manage_options', 'items' => fn($user) => $this->alerts()]);
    $bar->search(['id' => 'orders', 'label' => 'Orders', 'capability' => 'access_app:my-shop',
        'search' => fn(string $q, array $user, int $limit) => $this->findOrders($q, $limit)]);
}
  • Ids are your app's. analytics becomes {your-slug}.analytics, so two apps can't clash. An id another owner holds is refused.
  • Parents without a dot are yours, unless they are one of core's menus: site, context, new, search, notifications, user, display. context is the page's own menu. A parent with a dot names another app's component.
  • Refused definitions return false, and the reason is written to your app's log: a bad id, a missing label, a link that isn't a site path or http(s) address.
  • Callables run on the server, only for users who may see the component, and receive the page's context and the user. One that throws hides that component for that request and is logged once; the rest of the bar is unaffected.
  • Cleanup is automatic. Registrations last for the request: when your app is switched off they are gone, and when it fails to boot core takes them back. remove($id) removes one.

Components

Every component takes id, priority (lower first, default 50), capability (one or a list, all required), roles, context (see Conditions) and visible (fn($ctx, $user): bool, for anything else, such as one of your settings).

MethodWhat it addsIts own keys
menu()A top-level entry. A menu with no link, items or panels isn't shown. Users can hide apps' menus.label (required), icon (Heroicons name), href, region (main left, end right for status menus), title, items
item()An entry in a dropdown. With items or panels under it, it opens in place, up to three levels deep.parent, label (required), icon, href, target (_blank), meta (a second line), group (a heading), items
panel()Live information at the top of a dropdown, loaded when it opens, with loading, error and refresh states.parent, label (its heading), data (required), refresh
indicator()A badge on a menu or item.parent, type (count, status, text), value (required), tone, label (for screen readers), lazy
notifications()Notices for the bell.items: fn($user, $ctx) returning ['id', 'title', 'text', 'tone', 'url', 'time']
search()A group of quick-search results.label (required), icon, search: fn($query, $user, $limit) returning ['title', 'url', 'meta', 'icon']
  • items is fn($ctx, $user) returning item definitions (up to 50), built when the bar is drawn. The page-stats example uses it for a "Most read" list.
  • An indicator's value returns the value, or ['value' => …, 'tone' => …, 'label' => …]. Tones are neutral, info, success, warning and danger. A count of 0 shows nothing. Badges are lazy by default: all of them load in one request after the page. 'lazy' => false computes one with the page.
  • Search providers must return only what this user may open: the bar shows what you return.

Conditions

context lists the pages a component appears on; any match shows it.

ConditionMatches
singleany post, page or item
post, page, event…an item of that content type
homethe home page, a static one too
archiveany archive; archive:author, or archive:event for a type's archive
taxonomy:categorya term page of that taxonomy
search, 404search results, a missing page
app, app:shopapps' pages, or one app's pages and routes
route:shop.*routes by name; * at the end matches a prefix

The page's context

Callables receive an App\Core\AdminBar\AdminBarContext: type() (single, home, archive, search, 404, app, route), contentType(), contentId(), postStatus(), authorId(), archiveKind(), taxonomy(), termId(), search(), isHome(), isPreview(), path(), status(), route(), app(), matches([...]) and toArray(). Read the post or term again with its id when you need more; take nothing else from the request.

Panel contents

A panel's data returns any of these. Anything else is dropped, and every string is shown as text, so panels look the same in every theme and can't put markup into the page.

return [
    'text'    => 'A paragraph.',
    'score'   => ['value' => 82, 'max' => 100, 'label' => 'SEO score', 'tone' => 'success'],
    'rows'    => [['label' => 'Views', 'value' => '1,204', 'tone' => '', 'url' => '/admin/...']],
    'list'    => [['title' => 'Title length is good', 'meta' => '…', 'tone' => 'success', 'url' => '…']],
    'actions' => [['label' => 'Full report', 'url' => '/admin/my-analytics']],
    'empty'   => 'Shown when there is nothing else.',
];

Hooks

HookUse
admin_bar.register (action: $ctx, $user)Runs once per request, after core's sections. Use it for components that depend on the page or the user, which boot() doesn't know yet: $this->addAction('admin_bar.register', fn($ctx, $user) => …, 10, 2)
admin_bar.show (filter: true, $ctx, $user)Return false to leave the bar out of a page
Response header X-Basehim-Admin-Bar: offA route that must not have the bar (a full-screen app page, say). Core removes the header before sending.

In the browser, window.BasehimAdminBar has openSearch(), open(id), close() and refresh() (reloads the visible panels).

Example apps

Four complete, installable apps are in Basehim's docs/examples/admin-bar folder, and the screenshots in How to use the admin bar on your site were taken with them. Copy one into content/apps/ and install it to try it.

AppShows
page-statsAn Analytics menu with a live panel for the page, a "Most read" list built when the bar is drawn, and a text badge, all from core's view counter
seo-checkAn SEO score panel and an item in core's page menu, on posts and pages, for SEO managers
comment-queueA Discussion menu with a pending-comment count, a panel of the newest pending comments, and a notice in the bell
site-healthA status menu on the right with a Good / Warning badge, its checks in a panel, notices, and a quick-search source

For themes

A theme needs nothing. The bar adds itself before </body> and gives <html> the class has-bh-admin-bar. In top-bar mode it also sets --bhab-push (the bar's height) and moves a fixed or sticky header at the very top down by that much. A theme that places its header differently can use the class and the variable.

From boot.php, bh_admin_bar() gives themes the same API as apps, with ids under theme.:

bh_admin_bar()->item(['id' => 'hero', 'parent' => 'context', 'label' => 'Edit the hero', 'context' => ['home'],
                      'capability' => 'manage_themes', 'href' => '/admin/customize']);

The bar's styles are scoped to #bh-admin-bar and reset the few things themes commonly style on buttons, links and lists, so it looks the same everywhere.

Security

  • Seeing is not permission. Every component is checked on the server for each request, and every action it leads to is checked again by its own screen or AJAX action. Users only see what they could do anyway.
  • Signed context. The bar's AJAX calls carry the page's context signed with a key from the user's session. A changed context, or one from another session, is refused, and the post or term is read again from the database. A request for a component the user can't see on that page is answered as if it didn't exist, and its code never runs.
  • CSRF. Saves (choices, Quick edit) carry the session's token through the AJAX client, which fetches it when needed. No token is printed in the page. Sign out posts the token too.
  • Escaping. Every label, link and attribute is escaped on the server; the script writes text, never HTML. Links must be site paths or http(s) addresses.
  • No app scripts. Apps' components are links, panels, badges and notices. Anything an app changes goes through its own AJAX actions with their own checks.
  • Private pages. A page with the bar is sent with Cache-Control: private, no-store and Vary: Cookie, so no shared cache keeps a copy with someone's bar in it.
  • Content-Security-Policy. The bar's scripts carry the page's nonce like every other script (Settings → Security).

Performance

  • Visitors cost one check for a session cookie, then nothing: no session, no query, no markup, no files.
  • Signed-in users get one stylesheet and one script, loaded at the end of the page and cached by the browser. Building the bar costs one query for the user (the admin makes the same one) plus its checks.
  • After the page, once the browser is idle: one request for all badges and one for notices, which are reused for two minutes.
  • Only when used: panels load when their menu opens, once per page; search runs as you type, after a short pause.
  • There is no polling, and nothing about a user is cached for anyone else.

Limits

  • There are no revisions or duplicate: Basehim doesn't have them yet, so the bar doesn't offer them.
  • Quick edit covers simple fields. Categories, tags and the publish date are changed in the editor.
  • Core's own analytics are the view counter. Richer numbers are an app's job, as in the page-stats example.
  • The floating button snaps to the nearest corner; free positions aren't kept.
  • On sites with many apps' menus, the phone bar scrolls sideways. Each person can hide apps' menus they don't need from the Display menu.

Colours (1.3.0)

The bar follows each person's appearance: their light, dark or device setting and their accent, unless the site owner fixed its colours under Settings → Admin bar. The admin's named colours (--bh-surface, --bh-text, --bh-muted, --bh-line, --bh-accent, the palette and the statuses) are set on the bar itself (#bh-admin-bar, .bhab-dialog, .bhab-toast), so styles for an app's item or panel can use them, for example color: rgb(var(--bh-success-text)). A theme's own CSS can't: they don't exist on the rest of the page. Panel tones (success, warning, danger) are the status colours, the same in every accent.

Discussion

Leave a comment