Developer docs
Start
Build with AI
Themes
Apps
- App development
- App development best practices
- Hooks and filters
- The App API
- Sending email from apps
- Admin bar for apps and themes
- Public routes for apps
- Admin screens, routes and UI components
- Dashboard widgets
- Data tables
- AJAX actions
- Media and uploads for apps
- App permissions
- Content types, blocks and widgets
- Content types, taxonomies and custom fields
- Search
- When an app fails: fault isolation and recovery
API and AI agents
Security and contributing
Admin bar for apps and themes
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.
analyticsbecomes{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.contextis 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).
| Method | What it adds | Its 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'] |
itemsisfn($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
valuereturns the value, or['value' => …, 'tone' => …, 'label' => …]. Tones areneutral,info,success,warninganddanger. A count of 0 shows nothing. Badges arelazyby default: all of them load in one request after the page.'lazy' => falsecomputes 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.
| Condition | Matches |
|---|---|
single | any post, page or item |
post, page, event… | an item of that content type |
home | the home page, a static one too |
archive | any archive; archive:author, or archive:event for a type's archive |
taxonomy:category | a term page of that taxonomy |
search, 404 | search results, a missing page |
app, app:shop | apps' 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
| Hook | Use |
|---|---|
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: off | A 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.
| App | Shows |
|---|---|
page-stats | An 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-check | An SEO score panel and an item in core's page menu, on posts and pages, for SEO managers |
comment-queue | A Discussion menu with a pending-comment count, a panel of the newest pending comments, and a notice in the bell |
site-health | A 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-storeandVary: 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.
Related
- App development and App development best practices.
- Content types, taxonomies and custom fields: app types get the bar's page menu automatically.
- Search: the engine behind quick search.
- App permissions, Theme reference.
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.
Leave a comment