Skip to content

Admin screens, routes and UI components

7 min read

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') and adminPost('/x') register /admin/x, behind the same middleware as core's admin screens.
  • Handlers receive the Request, followed by any route parameters. They return a Response; a string or an array also works (arrays become JSON).
  • adminView('report', $title, $data) renders views/report.php inside 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

MethodReturns
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:

HelperUse
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() and route() 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.

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment