Skip to content

Build your first app: an announcement bar

8 min read

In this tutorial you'll build Announcement Bar, a small app that shows a message at the bottom of every page of your site. Site owners edit the message, an optional link and a colour on a screen in the admin.

It's a small app, but it uses the pieces almost every app needs: a manifest, the entry class, an admin screen with a safe form, per-app settings, a view, and output on the public site. It takes about twenty minutes.

You need: a Basehim site you can add files to (a local install is ideal), signed in as an administrator.

Step 1: Create the folder and the manifest

Apps live in content/apps/, one folder each. Create this structure:

content/apps/announcement-bar/
├── app.json
├── src/
│   └── App.php
├── views/
│   ├── settings.php
│   └── bar.php
└── assets/
    └── bar.css

Start with app.json, which tells Basehim what the app is:

{
  "name": "Announcement Bar",
  "slug": "announcement-bar",
  "version": "1.0.0",
  "description": "Shows a short message at the bottom of every page of the site.",
  "developer": { "name": "Your Name", "url": "https://example.com" },
  "namespace": "Tutorial\\AnnouncementBar",
  "requires": { "php": ">=8.1", "basehim": ">=1.2.51" }
}
  • slug matches the folder name.
  • namespace is how Basehim finds your PHP class: it maps Tutorial\AnnouncementBar\ to the src/ folder. Note the doubled backslash, which JSON requires.
  • There's no permissions list. This app only uses its own settings, which never need permission.

Step 2: Write the entry class

Create src/App.php:

<?php

namespace Tutorial\AnnouncementBar;

use App\Core\Request;
use App\Core\Response;

class App extends \App\Core\App
{
    public function boot(): void
    {
        // 1. A sidebar item and the screen it opens (/admin/announcement-bar).
        $this->addAdminMenu([
            'url'   => '/admin/announcement-bar',
            'label' => 'Announcement',
            'icon'  => 'megaphone',
        ]);
        $this->adminGet('/announcement-bar', [$this, 'showSettings']);
        $this->adminPost('/announcement-bar', [$this, 'saveSettings']);

        // 2. The bar itself, on every public page, when it is switched on.
        if ($this->getSetting('enabled', false) && $this->getSetting('message', '') !== '') {
            $this->addHead(fn() => '<link rel="stylesheet" href="' . htmlspecialchars($this->asset('bar.css')) . '">');
            $this->addFooter(fn() => $this->view('bar', [
                'message' => $this->getSetting('message'),
                'link'    => $this->getSetting('link', ''),
                'colour'  => $this->getSetting('colour', '#1d4ed8'),
            ]));
        }
    }
}

boot() runs on every request while the app is active, so it only registers things:

  • addAdminMenu() adds the sidebar item.
  • adminGet() and adminPost() add /admin/announcement-bar behind the admin's sign-in and access checks.
  • addHead() and addFooter() hand markup to the theme's bh_head() and bh_footer() calls. That works with any theme, without editing a template.

The callbacks run later, only when a page is actually rendered.

Step 3: Show the settings screen

Add this method to the class:

    public function showSettings(Request $request): Response
    {
        $html = $this->adminView('settings', 'Announcement bar', [
            'enabled' => (bool) $this->getSetting('enabled', false),
            'message' => (string) $this->getSetting('message', ''),
            'link'    => (string) $this->getSetting('link', ''),
            'colour'  => (string) $this->getSetting('colour', '#1d4ed8'),
            'csrf'    => $this->session()->csrfToken(),
        ]);
        return Response::html($html);
    }

adminView() renders views/settings.php inside the admin layout: sidebar, top bar and flash messages. Create the view:

<?php /** @var bool $enabled @var string $message @var string $link @var string $colour @var string $csrf */ ?>
<form method="post" action="<?= $base ?>/admin/announcement-bar" class="bh-card bh-card--pad" style="max-width: 640px">
    <input type="hidden" name="_csrf" value="<?= htmlspecialchars($csrf) ?>">

    <label class="bh-label">
        <input type="checkbox" name="enabled" value="1" <?= $enabled ? 'checked' : '' ?>>
        Show the announcement bar
    </label>

    <label class="bh-label" for="ab-message">Message</label>
    <input id="ab-message" name="message" class="bh-input" maxlength="200"
           value="<?= htmlspecialchars($message) ?>">
    <p class="bh-help">Plain text, up to 200 characters.</p>

    <label class="bh-label" for="ab-link">Link (optional)</label>
    <input id="ab-link" name="link" class="bh-input" placeholder="https://… or /pricing"
           value="<?= htmlspecialchars($link) ?>">

    <label class="bh-label" for="ab-colour">Background colour</label>
    <input id="ab-colour" name="colour" type="color" value="<?= htmlspecialchars($colour) ?>">

    <p style="margin-top: 1rem">
        <button type="submit" class="bh-btn bh-btn--primary">Save changes</button>
    </p>
</form>

Three details matter:

  • $base is passed to every view. It keeps the form working when Basehim is installed in a subfolder.
  • The hidden _csrf field carries the session's CSRF token. You'll check it when the form comes back.
  • Every value is escaped with htmlspecialchars(). The classes (bh-card, bh-input, bh-btn--primary) are the admin's own components, so the screen matches the rest of the admin.

Step 4: Save the form safely

Add the save handler and a small helper:

    public function saveSettings(Request $request): Response
    {
        // adminPost() routes are signed-in only, but the form token is ours to check.
        if (!$this->session()->verifyCsrf((string) $request->input('_csrf'))) {
            return $this->back('error', 'Your session expired. Reload the page and try again.');
        }

        $message = trim((string) $request->input('message', ''));
        $link    = trim((string) $request->input('link', ''));
        $colour  = (string) $request->input('colour', '#1d4ed8');

        if (mb_strlen($message) > 200) {
            return $this->back('error', 'Keep the message under 200 characters.');
        }
        if ($link !== '' && !preg_match('#^(https?://|/(?!/))#i', $link)) {
            return $this->back('error', 'The link must start with https://, http:// or a single /.');
        }
        if (!preg_match('/^#[0-9a-f]{6}$/i', $colour)) {
            $colour = '#1d4ed8';
        }

        $this->setSetting('enabled', $request->input('enabled') === '1');
        $this->setSetting('message', $message);
        $this->setSetting('link', $link);
        $this->setSetting('colour', $colour);

        return $this->back('success', 'Announcement saved.');
    }

    /** Redirect to the settings screen with a message for the admin's flash bar. */
    private function back(string $type, string $message): Response
    {
        $this->session()->flash('_flash', ['type' => $type, 'message' => $message]);
        $base = defined('BASEHIM_BASE') ? BASEHIM_BASE : '';
        return Response::redirect($base . '/admin/announcement-bar');
    }

What each check protects against:

  • The CSRF check stops another website from submitting this form in a signed-in administrator's name. Core checks tokens on its own forms, but your adminPost() routes are your responsibility.
  • The link check only allows http(s):// addresses and same-site paths. That keeps out javascript: links and protocol-relative //other-site links.
  • The colour check matters because the colour is printed into a style attribute.
  • Redirecting after saving means a page reload doesn't send the form twice. The flash message appears once, at the top of the next page.

setSetting() stores values in the app's own settings namespace (app:announcement-bar), which is removed when the app is uninstalled.

Step 5: Render the bar

Create views/bar.php:

<div class="ab-bar" role="region" aria-label="Announcement" style="background: <?= htmlspecialchars($colour) ?>">
    <?php if ($link !== ''): ?>
        <a href="<?= htmlspecialchars($link) ?>"><?= htmlspecialchars($message) ?></a>
    <?php else: ?>
        <?= htmlspecialchars($message) ?>
    <?php endif; ?>
</div>

And assets/bar.css:

.ab-bar {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 1000;
    padding: .75rem 1rem; text-align: center;
    color: #fff; font: 600 15px/1.4 system-ui, sans-serif;
}
.ab-bar a { color: inherit; text-decoration: underline; }

Files in assets/ are the only part of an app that browsers can load. $this->asset('bar.css') builds the right URL, /content/apps/announcement-bar/assets/bar.css, including any subfolder.

Step 6: Activate and try it

  1. Go to Admin → Apps. Announcement Bar is listed. Click Activate.
The Announcement Bar card under Apps, marked Unrestricted, with the Activate button
1: Unrestricted means the app declares no permissions, so there is nothing to approve. 2: Activate.
  1. Announcement appears in the sidebar. Open it, tick Show the announcement bar, type a message, add a link such as /blog, pick a colour and click Save changes. You should see "Announcement saved."
The Announcement bar settings screen after saving, with the sidebar item, the form and the saved message
1: the sidebar item from addAdminMenu(). 2: the form in views/settings.php. 3: Save changes posts to adminPost(), which calls saveSettings(). 4: the flash message shown after the redirect.
  1. Open your site in another tab. The bar is at the bottom of every page.
The public home page with a teal announcement bar along the bottom edge
1: the bar is views/bar.php, printed where the theme calls bh_footer(). It uses the colour and link you saved.

Now check the safety nets:

  • Enter javascript:alert(1) as the link and save. You get the error message, and nothing is stored.
  • Sign out and open /admin/announcement-bar. You're sent to the sign-in page.
  • Sign in as an author. The screen isn't in the sidebar, and the URL is refused. App screens require the app's access capability, access_app:announcement-bar, which administrators have.

If something doesn't appear, check Apps → Logs for this app and storage/logs/basehim-*.log. An exception in boot() is logged as "App boot() threw".

Step 7: Package it

Zip the announcement-bar folder (so the archive contains announcement-bar/app.json). Any Basehim site can install it under Apps → Upload. To ship an update, raise version in app.json. Sites keep their settings across updates.

What you learned

  • app.json names the app and maps its namespace to src/.
  • boot() registers menus, routes and output on every request; the real work happens in callbacks.
  • adminGet()/adminPost() give you admin screens behind sign-in and per-app access.
  • POST handlers must check the CSRF token; validate everything and escape everything.
  • getSetting()/setSetting() store your app's own data.
  • addHead()/addFooter() put markup on public pages through any theme.

Next steps

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment