A WordPress-style admin bar for Statamic, safe with static caching.

#Install

composer require 4rn0/statamic-cp-bar
php artisan statamic:static:clear

Clearing the static cache once makes sure pages cached before the install get the bar too. Then tick View CP Bar on the roles that should have it. Super users have it already. The bar shows for a user from their next login, or after they've opened the control panel once.

The bar's scripts are published to public/vendor/statamic-cp-bar by Composer. If your deploy skips Composer's scripts, publish them yourself:

php artisan vendor:publish --tag=statamic-cp-bar --force

#Who sees it

The bar shows for users with Access the Control Panel and View CP Bar. You find View CP Bar under Control Panel in the role editor. Each item then checks the permissions of the user:

Item Shown when the user may
New › an entry create entries in that collection on this site
New › a term create terms in that taxonomy on this site
New › User create users
Edit entry, Edit term edit this entry or term
Forms › a form view that form's submissions
Cache use the Cache utility
SEO view SEO reports, or edit section or site defaults (SEO Pro)
Create redirect create redirects (SEO Pro or Redirect)
New › a resource, Edit resource create or edit that resource's models (Runway)

What a user may not use is left out, not greyed out.

Every user can switch the bar off for themselves with Show CP Bar in their preferences. As with any preference, you can set it for a role or for everyone.

Roles, multisite and impersonation are Statamic Pro features. On Solo your one user is a super user and gets the whole bar.

#What's in the bar

Your site's name links to the dashboard.

New has one item per blueprint, named after it: Article, Page. The collection of the current page comes first, and clicking New itself opens that one. Taxonomies follow the collections, then User. Hidden blueprints are skipped.

Edit entry or Edit term opens what's on screen in the control panel. On a page that is neither, such as a custom route, search results or a 404, it isn't there. An entry that isn't published says why next to it: Draft, Scheduled or Expired.

Sites appears on a multisite and lists the other sites this entry or term is published on, each linking to the page there.

Forms appears when the entry or term has a form field, also inside Replicator, Bard, Grid or Group. Each form shows its number of submissions and links to them. Forms that are only in the template, like a newsletter form in the footer, aren't found.

Cache appears when static caching is on. The dot is green when this page is in the cache, and grey when it is not. With full measure, the menu also tells how long ago the page was stored. Refresh this page removes this page from the cache, query string variants included, and reloads it. To clear the whole cache, use the control panel's Cache utility.

Your avatar opens your name and email, Edit profile, Preferences and Log out. While impersonating, the bar turns amber and the menu has Stop impersonating. Someone without access to the control panel has no bar, so impersonating them hides it until you stop.

With the keyboard, Tab moves from item to item along the bar. Enter, Space or ↓ opens a menu and goes into it. ↑ and ↓ move through the menu. → and ← open and close a menu in a menu. Escape closes the menu. A menu also opens when its item has focus, so you see what's in it.

In the control panel, View Site opens in the same tab while you have the bar: the bar is the way back.

#With SEO Pro, Redirect or Runway

Installed, they get items of their own.

SEO Pro adds SEO to entries and terms. The dot shows the result of the page in the latest SEO report: green, amber or red. The menu lists what to fix. It links to the report and to the section and site defaults. It follows the report, so it changes when a new report is generated, not when you save.

SEO Pro or Redirect adds Create redirect on a 404, with the address filled in as the source. If the addon logs 404s, a badge shows how often the address was hit. With both installed, Redirect wins. SEO Pro has redirects from version 7.7.

Runway adds its resources to New, and Edit Product (or whatever the resource is called) on the page of a routed model. Hidden and read-only resources are left out.

Tested with SEO Pro 7.14, Redirect 4.2 and Runway 9.7.

#The look

The bar looks like the control panel's header, in the theme color each user picked in their preferences. It uses the language of the user's control panel. It uses the text direction of the control panel, not of the page. On a right-to-left site, the bar stays left to right for an English control panel. In high contrast mode it takes the system's colors.

#Your layout

When the bar shows, <html> gets the class cp-bar, a top margin to make room, and the custom property --cp-bar-height. It also gets a scroll-padding-top of that height, so a link to an anchor stops below the bar. If your site has a sticky header, set scroll-padding-top on html.cp-bar to the two heights together. In print the bar is left out. A header with position: fixed or sticky needs to move down:

.cp-bar .site-header { top: var(--cp-bar-height); }

The bar sits at z-index: 99999, so a dialog of your own with a higher one goes over it. A full-screen overlay below that needs to start under the bar, like the header above.

The bar lives in a shadow DOM, so your site's CSS doesn't reach it and its CSS doesn't reach your site. To style it on purpose, see Styling.

#Placement

The bar is added to every HTML page Statamic renders, 404s included. To place it yourself, publish the config and set inject to false:

php artisan vendor:publish --tag=statamic-cp-bar-config
// config/statamic-cp-bar.php
return [
'enabled' => env('STATAMIC_CP_BAR', true),
'inject' => false,
];

Then put the tag in your layout, {{ cp_bar }} in Antlers or @cpBar in Blade. Laravel routes that don't go through Statamic need the tag too. Placed by hand, the page shifts down once when the bar arrives.

Some sites swap the body instead of loading the next page, with Turbo, Livewire's wire:navigate or htmx. These sites get the bar of each new page too.

#Static caching

The page itself contains only an empty placeholder, a few lines of CSS and a script that checks for a cookie. That is about 1 KB, the same for every visitor. So the page is safe to cache with half or full measure. The rest is in files only a browser with that cookie asks for. The script fetches the bar from /!/statamic-cp-bar, a request that is never cached. Cached pages keep that markup after an update. They get the rest from files that update with the addon. So you do not need to clear the cache after an update. A release that does change the markup says so in the changelog.

Only a browser that has been in the control panel sends that request. The control panel sets a statamic_cp_bar cookie for users who may see the bar. The cookie holds nothing more than that fact. Logging out removes it. Visitors never make it. In those browsers, the request starts from the <head>, before the browser reads the rest of the page. localStorage keeps how the last bar looked: its height and color, and the items that are on every page (your site's name, New and an empty avatar). The bar shows those immediately. The items of the page itself follow when the request returns. They hold no name or picture, so whoever uses the browser next sees nothing of yours, and logging out removes them.

Does your Content Security Policy forbid inline scripts? Then set Statamic's script_delivery to external in config/statamic/static_caching.php (Statamic 6.34 or later). The bar then loads its script as a file. It then needs no inline scripts, inline styles or data: images. Every visitor loads that file, 1.7 KB, once. Without the CSS in the head, the page can move down once when the bar arrives.

#Switching it off

How What it does
STATAMIC_CP_BAR=false No bar anywhere
?cp-bar=off in the address No bar on that page
X-CP-Bar: off request header No bar, on pages from the static cache too, and the response isn't cached

The last two are for end-to-end tests and screenshot tools that run while logged in. The bar never shows in Live Preview, inside an iframe, or in pages generated on the command line, such as with statamic/ssg.

#Extending CP Bar

The bar is a list of items. Each item has an id and a parent, as in the WordPress admin bar. Your code adds, changes and removes items. A site does this in its own service provider. An addon does this in its service provider too, and it does not need CP Bar to work.

#From an addon

Put CP Bar in suggest in your composer.json, not in require. You can also put it in require-dev, for your tests and your static analysis.

"suggest": {
"4rn0/statamic-cp-bar": "Shows broken links in CP Bar."
}

Add your items in bootAddon(). Check for CP Bar first. On a site without CP Bar, class_exists() returns false and your addon skips the rest. The use line does not load a class, so it is safe without CP Bar.

use Arnohoogma\StatamicCpBar\Facades\CpBar;
 
public function bootAddon()
{
 
if (! class_exists(CpBar::class)) {
return;
}
 
CpBar::extend(function ($bar, $context) {
$bar->add([
'id' => 'broken-links',
'title' => __('Broken links'),
'href' => cp_route('broken-links.index'),
]);
});
 
}

The README has a complete service provider. Follow these rules:

  • Call CpBar::extend() in bootAddon(). Do not call it in register(). There, the facade gets an object that the bar does not use, and your items do not show.
  • Call CpBar::extend() one time. Do not call it for each request. With Octane, each call adds one more callback.
  • Start your ids with the handle of your addon: broken-links-report, not report. Then your ids do not collide with the ids of other addons.
  • Give your items a can with a permission of your addon. Then only users who can use your page see the item.

#From a site

Use the boot() method of App\Providers\AppServiceProvider:

use Arnohoogma\StatamicCpBar\Facades\CpBar;
 
CpBar::extend(function ($bar, $context) {
$bar->add(['id' => 'edit', 'title' => 'Bewerken']);
});
 
CpBar::remove('new-content');

A site runs before the addons. To change an item of an addon, put your call in Statamic::booted():

Statamic::booted(fn () => CpBar::extend(fn ($bar) => $bar->add(['id' => 'broken-links', 'priority' => 20])));

#Methods

Method What it does
CpBar::extend($callback) Adds a callback. CP Bar calls it with $bar and $context each time it builds the bar. The callback is a closure, an invokable object or the name of an invokable class. CP Bar gets a class from the container.
CpBar::add($item) Adds an item for all users on all pages. It cannot change a core item: CP Bar adds the core items later, when it builds the bar. Use $bar->add() in a callback for that.
CpBar::remove($id) Removes an item and all items under it. This works for core items too.
CpBar::css($css) Adds CSS to the bar. See Styling.
$bar->add(), $bar->remove(), $bar->css() The same, in a callback, for this user on this page.

CP Bar runs the callbacks when a user opens the bar, not when it renders a page. A callback can use the user and the page, and it costs visitors nothing. CP Bar runs the callbacks in the language of the user's control panel, so __() translates.

CP Bar runs the callbacks in this order:

  1. The core items of CP Bar.
  2. The site, from its boot().
  3. The addons, from their bootAddon(), in the order that Statamic boots them.

An add() with an id that exists changes that item. A later change wins. A remove() wins over each add().

#Context

A callback gets $context. Read its properties. Do not change them.

Property
user The logged-in user.
url The full address of the page, with the query string.
site The site of that address.
page The entry or term at that address, or null.
notFound true on a 404 page.

#Item types

  • Link. An item with an href.
  • Button. An item with an action. A click runs code on the server. See Actions.
  • Menu. An item with items under it. Put an item in a menu with parent. A menu opens under the bar.
  • Submenu. A menu in a menu. It opens next to its item.
  • Line of text. An item with meta.text. It has no link. It can have a subtitle.
  • Status dot. Any item with meta.dot. Always add meta.status too: it tells screen readers what the dot means.

An item shows only if it has an href, an action, items under it, or meta.text. A menu with nothing left in it goes.

$bar->add(['id' => 'acme-tools', 'title' => 'Tools', 'href' => cp_route('utilities.index')]);
$bar->add(['id' => 'acme-seo', 'parent' => 'acme-tools', 'title' => 'SEO']);
$bar->add(['id' => 'acme-seo-check', 'parent' => 'acme-seo', 'title' => 'Check this page', 'action' => SeoCheck::class]);

#Properties

Key Type Default
id string Required. Unique. top-secondary is not allowed.
title string Required for a new item. A change to an item can leave it out. Plain text.
parent string null null for the left side, top-secondary for the right side, or the id of the menu.
href string null The address of the link. A javascript: address is not allowed.
action closure, object or class null Code that runs on the server. See Actions.
confirm string null A question. The user confirms it before the action runs.
icon string null An SVG or an <img>. CP Bar inserts it as HTML. Escape all text that you did not write with e().
priority int or float 100 The order. The lowest comes first.
before, after string null The id of an item in the same menu. The item goes next to it.
can string or closure null A permission, or a closure that gets $context and returns true or false.
meta array [] See below. A value is text, a number, true, false or null.

CP Bar ignores keys that it does not know. A string can also be a Stringable, such as an HtmlString.

meta key
target, rel The target and rel of a link.
badge A number or a short text after the title. It does not show at 0.
dot A CSS color for a status dot.
status The meaning of the dot, in words, for screen readers.
separator true puts a line above the item.
text true makes the item a line of text.
subtitle A second line under a line of text.
initials An avatar with these letters.

An add() with an existing id merges meta. A badge on a core item keeps the dot of that item.

#Visibility and permissions

  • The bar shows for users with Access the Control Panel and View CP Bar, and with Show CP Bar on in their preferences.
  • An item with a can shows only to users with that permission. A closure gets $context and returns true or false.
  • An item under a hidden item is hidden too. The can of a menu protects all items in it.
  • If a can closure throws an exception, CP Bar hides the item and logs a warning.
  • CP Bar builds the bar for each user and each page. Two users can see a different bar on the same page.

#Actions

An item with an action is a button. The action gets $context:

'action' => function ($context) {
BrokenLinks::check($context->page);
 
return ['message' => __('Checked.')];
},

When the user clicks the button, CP Bar sends a POST request with the address of the page and a CSRF token. CP Bar then builds the bar again, for this user and this page. It runs the action only if the user can see the item there. The can of the item and of each menu above it protect the action.

An action returns null or an array with one of these keys:

Key What the bar does
message Shows the text for a few seconds.
reload true reloads the page.
redirect Opens this address. A javascript: address is not allowed.

CP Bar ignores other keys. If the action throws an exception or returns something else, CP Bar logs a warning. The user then sees "That didn't work." To ask the user before the action runs, add a confirm.

#Ordering

  • The lowest priority comes first. The default is 100.
  • before and after put an item next to another item in the same menu. If that item is not there, priority decides.
  • Two items after the same item keep their priority order.
  • The priorities of the core items are in The core items.

#Removing items

CpBar::remove($id) removes an item for all users on all pages. $bar->remove($id) in a callback removes it for this user on this page. Both remove all items under it too. Both work for core items:

CpBar::remove('static-cache');
 
CpBar::extend(function ($bar, $context) {
if (! $context->page) {
$bar->remove('new-content');
}
});

#Styling

CpBar::css(<<<'CSS'
[data-id="broken-links"] .bar__badge { background: #b45309; }
CSS);

CP Bar puts your CSS after its own CSS, so your CSS wins a tie. Each item has its id as data-id. Other hooks are .bar, .is-impersonating, .bar__link, .bar__badge, .bar__dot, .menu and .menu__link. These class names are internal and can change in an update. The data-id stays.

#Errors

CP Bar checks each add(), remove() and css(). If the data is not valid, CP Bar skips that call and logs a warning. The rest of the callback continues. If a callback throws an exception, CP Bar removes all that the callback added and logs a warning. The rest of the bar shows as usual. The page itself does not change.

Each warning starts with CP Bar::

CP Bar: Skipped the item [broken-links-report]: priority is not a number.
CP Bar: Skipped the extension Acme\BrokenLinks\BarItems: Division by zero
CP Bar: Hid the item [broken-links-report]: its can check failed: No such report
CP Bar: The action of [broken-links-check] failed: It returned something other than a message, a reload or a redirect.

CP Bar logs these warnings when it builds the bar for a user. A page view of a visitor never logs a warning. CP Bar cannot catch a fatal error, a timeout, or output from echo, dump() or dd() in a callback.

#The core items

id parent priority
site-name 10
new-content 30
new-entry-{collection}, new-entry-{collection}-{blueprint} new-content 10
new-term-{taxonomy}, new-term-{taxonomy}-{blueprint} new-content 12
new-runway-{resource} new-content 15
new-user new-content 20
edit, create-redirect 40
forms 45
sites 50
site-{handle} sites 10
form-{handle} forms 10
seo-pro 55
seo-pro-{rule}, seo-pro-report, seo-pro-section-defaults, seo-pro-site-defaults seo-pro 10, 20, 30, 40
static-cache 500
static-cache-status, static-cache-refresh static-cache 10, 20
my-account top-secondary 1000
user-info, edit-profile, preferences, stop-impersonating, logout my-account 1, 10, 15, 20, 30

#Stability

CP Bar follows semantic versioning. A change that breaks the public API comes only in a new major version.

This is the public API:

  • The facade Arnohoogma\StatamicCpBar\Facades\CpBar with extend(), add(), remove() and css().
  • The classes Arnohoogma\StatamicCpBar\CpBar and Arnohoogma\StatamicCpBar\Context, for type hints. On $bar: add(), remove() and css(). On $context: the properties above.
  • The signatures of a callback ($bar, $context), an action ($context) and a can closure ($context).
  • The keys of an item, the meta keys, and the keys that an action returns.
  • The ids, parents and priorities of the core items.
  • The rules on this page: the default priority, the order of the callbacks, and "a removal wins".
  • For sites:
    • {{ cp_bar }} and @cpBar
    • the config keys enabled and inject, and STATAMIC_CP_BAR
    • ?cp-bar=off and the X-CP-Bar header
    • the permission view cp bar and the preference cp_bar
    • the class cp-bar and the property --cp-bar-height on <html>, and data-id
    • the cookie statamic_cp_bar and the address /!/statamic-cp-bar
    • the publish tags and lang/vendor/cpbar
    • the class name Arnohoogma\StatamicCpBar\ServiceProvider

All code with @internal can change in any release. So can the JSON of the bar, the POST request of an action, and the CSS class names. There are no events.

A minor version can add items, keys and meta keys. CP Bar ignores keys that it does not know, so data that 1.0 accepts stays valid in each 1.x.

#Translations

Publish the translations to change them or add a language:

php artisan vendor:publish --tag=cpbar-lang

They land in lang/vendor/cpbar/{locale}/cp.php.

#Troubleshooting

No bar. Check, in this order:

  1. The role has Access the Control Panel and View CP Bar, and the user hasn't switched off Show CP Bar in their preferences.
  2. You've opened the control panel since installing, so the cookie is set.
  3. The page isn't a cached copy from before the install: php artisan statamic:static:clear.
  4. The page's HTML contains <cp-bar. If not, the response doesn't go through Statamic (use @cpBar) or inject is off.
  5. The network tab shows /!/statamic-cp-bar. A 401 means that you are not logged in. A 403 means a missing permission, or the bar is off in the preferences. A 404 means that the bar is off, or that the request has an X-CP-Bar: off header. Is there no request, or a 200 without a bar? Then loader.js or bar.js is missing from public/vendor/statamic-cp-bar/build. Publish them, see Install.

The bar covers a fixed header. See Your layout.

Only one domain of a multisite shows the bar. You're logged in per domain.

#Limits

  • A Route::statamic() page that loads an entry has no Edit: the bar finds pages by their URL.
  • Forms only finds forms in the entry's own fields.

#Uninstalling

composer remove 4rn0/statamic-cp-bar

Then remove public/vendor/statamic-cp-bar. Remove config/statamic-cp-bar.php and lang/vendor/cpbar if you published them. Remove each {{ cp_bar }} and @cpBar, and clear the static cache. The View CP Bar permission stays in resources/users/roles.yaml, and the Show CP Bar preference stays in the preferences of the users. Both do nothing.