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 screens, routes and UI components
Most apps need a screen in the admin: settings, a report, a list of records. This page shows how to add one, decide who may open it, and handle forms safely. It also covers the UI components that make your screen look like the rest of the admin. Public pages and endpoints have their own guide: Public routes for apps.
A sidebar item and a screen
public function boot(): void
{
$this->addAdminMenu([
'url' => '/admin/event-report',
'label' => 'Event report',
'icon' => 'calendar', // a Heroicon name
]);
$this->adminGet('/event-report', [$this, 'report']);
$this->adminPost('/event-report', [$this, 'saveReport']);
}
public function report(\App\Core\Request $request): \App\Core\Response
{
$html = $this->adminView('report', 'Event report', [
'rows' => $this->rows(),
'csrf' => $this->session()->csrfToken(),
]);
return \App\Core\Response::html($html);
}
adminGet('/x')andadminPost('/x')register/admin/x, behind the same middleware as core's admin screens.- Handlers receive the
Request, followed by any route parameters. They return aResponse; a string or an array also works (arrays become JSON). adminView('report', $title, $data)rendersviews/report.phpinside the admin layout: sidebar, top bar and flash messages. Your view only produces the page body.- An app's admin route can't replace one of core's admin screens: if
/admin/xalready exists in core, core's screen wins, and the refusal is logged and shown in the app's report.
Who can open your screen
Every admin route requires a signed-in user. On top of that, adminGet()/adminPost() routes require your app's access capability, access_app:{slug}. Roles with manage_apps or manage_options have it. Anyone else needs it granted under Users → edit → Access. The sidebar item is hidden from users who lack it.
To open a screen to more people, map its path to a capability with the admin.area_policy filter. The longest matching path prefix wins:
$this->addFilter('admin.area_policy', function (array $map): array {
$map['/admin/event-report'] = 'edit_posts'; // authors and above
return $map;
});
Inside a handler, check finer rules yourself:
$user = $this->api()->users()->current();
if (!$this->api()->users()->can($user, 'manage_options')) {
return \App\Core\Response::html('Not allowed', 403);
}
The capability list is in Security for developers.
If your app is ever switched off after a failure, its admin screens lead the administrator to the app's report instead of a missing page. The report is drawn by core, so it opens even when your code can't load (see When an app fails).
Forms, CSRF and flash messages
Every POST handler must check the CSRF token. The admin middleware doesn't do it for app routes.
// views/report.php
<form method="post" action="<?= $base ?>/admin/event-report">
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($csrf) ?>">
<label class="bh-label" for="er-title">Report title</label>
<input id="er-title" name="title" class="bh-input" value="<?= htmlspecialchars($title ?? '') ?>">
<button type="submit" class="bh-btn bh-btn--primary">Save changes</button>
</form>
public function saveReport(\App\Core\Request $request): \App\Core\Response
{
if (!$this->session()->verifyCsrf((string) $request->input('_csrf'))) {
return $this->back('error', 'Your session expired. Reload the page and try again.');
}
$title = trim((string) $request->input('title', ''));
if ($title === '' || mb_strlen($title) > 120) {
return $this->back('error', 'Enter a title of up to 120 characters.');
}
$this->setSetting('report_title', $title);
return $this->back('success', 'Saved.');
}
private function back(string $type, string $message): \App\Core\Response
{
// The admin layout shows this once, at the top of the next page.
$this->session()->flash('_flash', ['type' => $type, 'message' => $message]);
$base = defined('BASEHIM_BASE') ? BASEHIM_BASE : '';
return \App\Core\Response::redirect($base . '/admin/event-report');
}
Flash types: success, error and info. Redirect after every successful POST, so a page reload doesn't submit the form again.
Tip: For buttons that save without reloading the page, register an AJAX action instead of an
adminPost()route. The gateway checks CSRF, capability, input and rate limits for you.
The Request object
| Method | Returns |
|---|---|
input($key, $default) | A value from the JSON body, the form body or the query string |
query($key), post($key) | Only the query string, only the form body |
string($key), integer($key), boolean($key) | Typed reads with defaults |
only([...]), all() | Several values |
file($key) | An uploaded file ($_FILES entry) |
header($name), bearerToken() | Headers |
isMethod('POST'), path(), ip(), wantsJson() | Request details |
Responses
use App\Core\Response;
return Response::html($html); // 200 text/html
return Response::html('Not found', 404);
return Response::json(['ok' => true]); // application/json
return Response::redirect($base . '/admin/x'); // 302
return (new Response($csv))->header('Content-Type', 'text/csv')
->header('Content-Disposition', 'attachment; filename="report.csv"');
UI components
The admin ships a small set of components. Use them so your screen looks and behaves like core's.
<section class="bh-card">
<div class="bh-card__head"><h2 class="bh-card__title">Upcoming events</h2></div>
<div class="bh-card__body"> … </div>
</section>
<button type="submit" class="bh-btn bh-btn--primary">Save changes</button>
<a href="…" class="bh-btn bh-btn--secondary">Cancel</a>
<button type="button" class="bh-btn bh-btn--danger">Delete</button>
<button type="button" class="bh-btn bh-btn--ghost bh-btn--sm">More</button>
<label class="bh-label" for="ev-name">Name</label>
<input id="ev-name" name="name" class="bh-input">
<p class="bh-help">Shown on the public page.</p>
<select class="bh-select bh-select--auto">…</select>
<textarea class="bh-textarea"></textarea>
<span class="bh-badge bh-badge--green">Published</span> <!-- gray, blue (info), green (success), amber (warning), red (danger) -->
<div class="bh-empty">
<p class="bh-empty__title">No events yet</p>
<p class="bh-empty__text">Events you add appear here.</p>
</div>
Use one primary button per screen. Label buttons by what they do ("Save changes", "Delete", "Send invite"), in sentence case. Mark invalid fields with aria-invalid="true".
Light, dark and accent (1.3.0)
Each person chooses Light, Dark or System (their device's setting, the default) and an accent palette in their account menu. The <html> element carries the choice: data-theme is light or dark (what is drawn), data-appearance is what they chose (including system), and data-accent names the palette.
If you use core's components and classes, there is nothing to do. Every colour class in the admin stylesheet (bg-white, text-slate-700, border-slate-200, bg-accent-600…) reads a CSS variable, so the same markup is right in light and dark and in every accent. blue-* means the accent too.
Your own CSS uses the named variables. They hold r g b channels, so wrap them in rgb() and add an alpha when you need one:
.my-card { background: rgb(var(--bh-surface)); color: rgb(var(--bh-text)); border: 1px solid rgb(var(--bh-line)); }
.my-hint { color: rgb(var(--bh-muted)); }
.my-button { background: rgb(var(--bh-accent)); color: rgb(var(--bh-on-accent)); }
.my-chip { background: rgb(var(--bh-accent) / .12); color: rgb(var(--bh-accent-text)); }
.my-chart .series-2 { fill: rgb(var(--bh-palette-2)); }
[data-theme="dark"] .my-photo { opacity: .9; } /* only for what variables can't express */
| Variable | Use |
|---|---|
--bh-page | The page behind cards |
--bh-surface | Cards, menus and inputs (white in light mode) |
--bh-raised | Hover rows and subtle fills |
--bh-text, --bh-strong, --bh-muted | Body text, headings, hints |
--bh-line, --bh-line-strong | Borders and dividers |
--bh-accent, --bh-on-accent | Buttons and links, and the text drawn on them (4.5:1 in every palette) |
--bh-accent-soft, --bh-accent-text | A light accent fill, and accent text that reads on it |
--bh-accent-hover | A pressed or hovered accent button |
--bh-palette-1 … --bh-palette-4 | The accent palette's four colours, for charts |
--bh-success, --bh-warning, --bh-danger, --bh-info | Status colours, the same whatever the accent |
Your own Tailwind build uses core's preset, so your classes mean what they mean in core and dark: follows data-theme. Build only components (core's stylesheet already has the utilities and the reset):
// tailwind.config.js in your app
module.exports = {
presets: [require('../../../tailwind.preset.js')], // from the Basehim folder
content: ['./views/**/*.php', './src/**/*.php', './assets/js/**/*.js'],
corePlugins: { preflight: false },
};
Write class names whole: Tailwind finds them by reading your files, so 'ch-hint--' + kind is invisible to it, while kind === 'error' ? 'ch-hint--error' : 'ch-hint--warn' works.
JavaScript. Basehim.appearance is ready before any app script runs, with real #rrggbb values for chart libraries and <canvas>, which can't read CSS variables:
var a = Basehim.appearance.get();
// { mode: 'system', theme: 'dark', dark: true, accent: 'kiwi',
// colors: { accent: '#028174', onAccent: '#ffffff', text: '#e2e8f0', surface: '#0f172a', success: '…', … },
// palette: ['#028174', '#92de8b', '#0ab68b', '#ffe3b3'] }
Basehim.appearance.color('accent', .2); // 'rgb(2 129 116 / .2)', for a canvas fill
var stop = Basehim.appearance.onChange(function (a) { myChart.setColors(a.palette); myChart.redraw(); });
PHP. $this->api()->appearance() returns the viewer's choice, for something you draw on the server (see The App API).
Checklist for an app screen: no
#fff,whiteor hex colours in your CSS; images and logos with transparent backgrounds checked on dark; charts take their colours from the palette; the screen checked in light and dark and in two accents.
Colour palette (1.3.0)
Ten named colours (red, orange, yellow, green, blue, pink, purple, brown, black, white) and four statuses, the same whatever accent a person picks. The statuses are aliases: success is green, warning orange, danger red and info blue. A rising figure is success and a falling one danger, in every accent. Every combination is readable (4.5:1) in light and dark.
The classes are always in the admin stylesheet, with no build needed:
| Class | Sets |
|---|---|
.bh-bg-{name} | Background: the fill |
.bh-text-bg-{name} | Background: the fill, with text that reads on it |
.bh-bg-{name}-subtle | Background: a tint, for notes, badges and rows |
.bh-text-{name} | Text in the colour, readable on the page and on its tint |
.bh-border-{name}, .bh-border-{name}-subtle | Border: the fill, or a lighter line |
<span class="bh-badge bh-text-bg-success">Paid</span>
<div class="bh-note bh-bg-warning-subtle bh-text-warning">Expires in 3 days</div>
<p class="bh-text-purple">Members only</p>
In a Tailwind build on the preset the same colours are bg-red, bg-red-subtle, text-red, text-on-red, border-red and border-red-subtle, and the same for every name and status (bg-success-subtle text-success). blue-50…blue-950 stay the accent; bare blue is the palette's blue. As variables: --bh-red (the fill), --bh-on-red (text on it), --bh-red-text, --bh-red-subtle and --bh-red-border.
JavaScript helpers
Available on every admin page:
| Helper | Use |
|---|---|
bhToast(message, type) | A short notice. type: success (default), error, warning, info |
await bhConfirm(message, {danger: true}) | Yes or no, in the admin's dialog. Use it instead of confirm() |
await bhAlert(message) | A message to acknowledge |
bhBusy(button, true, 'Saving…') | Spinner in a button; bhBusy(button, false) restores it |
bhLoader(anchor, 'Loading…') | The shared loader; returns {set, done, fail, remove} |
Basehim.ajax.request(action, data) | Call an AJAX action |
var save = document.getElementById('save');
var loader = bhLoader(save, 'Saving…');
Basehim.ajax.request('event-manager.save', { title: field.value })
.then(function () { loader.done('Saved'); })
.catch(function (err) { loader.fail('Not saved'); bhToast(err.message, 'error'); });
Scroll strips and sticky bars (1.3.0)
A row that scrolls sideways (tabs, filter chips, a status switch) is a scroll strip: mark it data-bh-strip. Section tabs are strips already. With a mouse an arrow appears at each end while there is more that way; touch screens keep swiping. The active item (.is-active, aria-current or aria-selected="true") is scrolled into view when the page opens. For a strip you draw after the page loaded, call bhScrollStrip(element).
<div class="my-chips" data-bh-strip>
<a href="…" class="is-active">All</a> <a href="…">Drafts</a> …
</div>
A data table's sticky toolbar and column header stop below every sticky bar above them: the admin header, section tabs, and any bar of your own marked data-bh-sticky.
Your own CSS and JavaScript in the admin
$this->addAdminStyle($this->asset('admin.css')); // in <head> of every admin page
$this->addAdminScript($this->asset('admin.js')); // before </body>, after core's scripts
Scope your CSS to your own classes, because these files load on every admin page.
For a stylesheet only one screen needs, ask for it from the view with HeadStyles::add() (1.3.0). Inside adminView() it is printed in the page's <head>, so the screen never paints unstyled first; in a bare view() fragment it is printed in place. Each stylesheet is printed once, however many views ask for it:
<?= \App\Core\Http\HeadStyles::add($this->asset('report.css')) ?>
Don't print a <link> in your view's body: the browser paints everything above it without its styles, then redraws.
Public routes
Apps answer URLs on the public site through $this->router()->site(). It covers every HTTP method, parameters, named URLs and per-route sign-in, capabilities, CSRF and rate limits, and it can render pages inside the active theme. Core's own URLs, the admin and other apps' routes are protected. Everything is in Public routes for apps.
$site = $this->router()->site();
$site->get('/events', [$this, 'eventsPage'], ['name' => 'events.index']);
$site->get('/events/{slug}', [$this, 'showEvent']); // $slug is passed after $request
$site->post('/webhooks/payments', [$this, 'paymentWebhook'], ['csrf' => false]); // verify the signature yourself
Note: The older
$this->get(),post(),route()androuteGroup()still work and behave as before: no sign-in, capability or CSRF checks. Since 1.2.72 they can no longer register paths under/admin, paths that start with a parameter, or URLs core already serves. Prefersite()for new code.
Leave a comment