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, and public routes.
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.
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.
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, green, amber, red -->
<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".
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'); });
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.
Public routes
Apps can answer URLs on the public site too:
$this->get('/events.ics', [$this, 'calendarFeed']);
$this->get('/events/{slug}', [$this, 'showEvent']); // $slug is passed after $request
$this->post('/webhooks/payments', [$this, 'paymentWebhook']);
$this->route(['GET', 'POST'], '/hooks/{provider}', [$this, 'hook']);
$this->routeGroup(['prefix' => '/shop'], function ($router) {
$router->get('/cart', [$this, 'cart']);
});
Pattern syntax:
{name}matches one path segment;{name?}makes the segment optional;{path:*}matches the rest of the path, slashes included.
App routes are checked before core's, and their first segment can no longer be used as a page or post slug.
Warning: Routes registered with
get(),post()androute()have no checks at all: no sign-in, no capability, no CSRF. Check what you need in the handler. For browser features that return JSON, prefer AJAX actions. For webhooks, verify the sender's signature.
Leave a comment