Skip to content
Developer docs

App development

Updated 8 min read

An app adds behaviour to a Basehim site: admin screens, front-end features, content types, background jobs, API scopes and MCP tools for AI agents. It's a folder of PHP inside content/apps/. Basehim loads it on every request once a site owner activates it.

New to apps? Build your first app takes about twenty minutes and touches most of what's on this page.

Folder layout

content/apps/event-manager/
├── app.json        required: the manifest
├── src/
│   └── App.php     the entry class
├── views/          PHP templates rendered with $this->view() / adminView() / SitePage::view()
└── assets/         css, js, images: the only folder browsers can reach

Assets are served at /content/apps/{slug}/assets/…. Always build their URLs with $this->asset('css/app.css'), which respects subfolder installs.

The manifest: app.json

{
  "name": "Event Manager",
  "slug": "event-manager",
  "version": "1.0.0",
  "description": "Adds an Events content type and an upcoming-events widget.",
  "developer": { "name": "Your Name", "url": "https://you.example.com" },
  "company": { "name": "Your Company", "url": "https://company.example.com" },
  "namespace": "YourCompany\\EventManager",
  "icon": "assets/icon.png",
  "permissions": ["posts.write", "schedule"],
  "requires": {
    "php": ">=8.1",
    "basehim": ">=1.2.51",
    "apps": { "some-other-app": ">=2.0" }
  }
}
FieldRequiredNotes
name, slug, versionyesslug: letters, digits, - and _, 2–80 characters. Keep the folder name the same
namespacein practice, yesBasehim maps it to src/; without it your class isn't found. Don't use core's own App\ namespace
entrynoEntry class name inside the namespace. Default App
srcnoSource folder. Default src
descriptionnoPlain text, up to 600 characters. Shown on the Apps and Updates screens
developer, companyno{ "name", "url" }; the URL must be http(s)://
iconnoA square PNG or GIF in assets/, 256×256 or 512×512, up to 1 MB
permissionsnoDeclaring any opts the app into enforcement. See App permissions
requiresnoChecked when the app is installed and again when it is activated. Dependencies must be installed and active

Version constraints understand >=1.2, <=, >, <, !=, ^1.2, 1.4.*, comma-separated ranges (">=1.0, <2.0") and a bare "8.1" meaning a minimum.

The entry class

<?php

namespace YourCompany\EventManager;

class App extends \App\Core\App
{
    public function boot(): void
    {
        // Runs on every request while the app is active.
        $this->addAdminMenu(['url' => '/admin/events-report', 'label' => 'Event report', 'icon' => 'calendar']);
        $this->adminGet('/events-report', [$this, 'report']);
        $this->router()->site()->get('/events', [$this, 'eventsPage']);   // a public page
        $this->addAction('post.created', [$this, 'onPostCreated']);   // receives the new post row
    }

    public function onInstall(): void {}                          // first time the app is seen
    public function onActivate(): void {}                         // switched on: create tables here
    public function onDeactivate(): void {}                       // switched off
    public function onUpgrade(string $from, string $to): void {}  // version changed on disk
    public function onUninstall(): void {}                        // just before removal: clean up
}

Extending \App\Core\App is optional but strongly recommended: it gives you the helpers below and the right constructor.

What boot() is for

boot() runs on every request: page views, API calls, AJAX, admin. Use it to register things, which is cheap:

  • hooks and filters;
  • routes and admin pages;
  • AJAX actions;
  • scheduled tasks;
  • content types and widgets, including dashboard cards;
  • MCP tools.

Do real work later: in a route handler, a hook callback or a scheduled task. If boot() throws, Basehim removes everything the app registered in that request (routes, hooks, menu items, AJAX actions, widgets) and switches the app off, with a report for the site owner. The rest of the site carries on. Catch what you expect to fail, such as a remote service or an optional file. See When an app fails.

Lifecycle methods

MethodWhenTypical use
onInstall()The first time Basehim sees the appDefaults
onActivate()The site owner activates it, after its code has passed Basehim's checksCREATE TABLE IF NOT EXISTS
onDeactivate()DeactivatedStop external jobs; keep data
onUpgrade($from, $to)The version on disk changed (also on downgrade)Migrate your tables and settings
onUninstall()Before removalDrop your tables, dropAllSettings()

onUpgrade() fires however the new files arrived: the marketplace, a ZIP upload, FTP or a core patch. It fires at most once per version change. A throw is logged and swallowed.

What the base class gives you

AreaMethodsRead more
HooksaddAction, addFilter, doAction, applyFiltersHooks and filters
Public routesrouter()->site() with get, post, put, patch, delete, group; pages in the theme with SitePage. The older get, post, route, routeGroup still workPublic routes for apps
Admin routesadminGet, adminPostAdmin screens and routes
Admin UIaddAdminMenu, adminView, addAdminStyle, addAdminScriptAdmin screens and routes
AJAXajax, useAjaxClientAJAX actions
Front endaddHead, addFooter, enqueueStyle, enqueueScriptbelow
EditoraddEditorScript, addEditorStyle, addEditorConfig, registerBlockRendererContent types, blocks and widgets
ContentregisterPostType, registerWidgetContent types, blocks and widgets
DashboardregisterWidget with 'surfaces' => ['dashboard']Dashboard widgets
Core APIapi(), schedule()The App API
SettingsgetSetting, setSetting, deleteSetting, allSettings, dropAllSettingsbelow
Databasetable, schema, dbbelow
Viewsview, adminView, assetbelow
Utilitycache, config, request, session, log, allowed, make, slug, path, manifest

Warning: Don't declare your own methods with these names unless you mean to override them with the same signature. An app with, say, a private function schema() or a table() that returns an array can't be loaded by PHP at all. Basehim catches this before the app runs and switches it off with a report, but the app won't work. Name your helpers something specific, such as eventTables().

Settings

Each app has its own settings namespace (app:{slug}), which is removed when the app is uninstalled. Arrays are stored as JSON.

$this->setSetting('feed_url', 'https://example.com/feed');
$url  = $this->getSetting('feed_url', '');          // default when missing
$seen = (array) $this->getSetting('seen', []);
$this->deleteSetting('feed_url');

Your own tables

Name tables with $this->table('things'). That gives app_{slug}_things with the site's DB_PREFIX, and dashes in the slug become underscores. Create them in onActivate():

public function onActivate(): void
{
    $this->schema("CREATE TABLE IF NOT EXISTS `{$this->table('events')}` (
        id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        title VARCHAR(200) NOT NULL,
        starts_at DATETIME NOT NULL,
        KEY starts_at (starts_at)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}

Query with $this->db() and bound parameters, never string concatenation:

$rows = $this->db()->select(
    "SELECT id, title FROM `{$this->table('events')}` WHERE starts_at >= :now ORDER BY starts_at LIMIT 10",
    ['now' => bh_now()]
);
$this->db()->insert($this->table('events'), ['title' => $title, 'starts_at' => bh_to_utc($typed)]);

Inside SQL, write core tables as {posts}, {users} and so on: select(), selectOne(), execute() and schema() expand them with the site's prefix. insert(), update() and delete() take a bare table name and add the prefix themselves. Prefer $this->api() over raw SQL for core content: it runs the same checks and hooks as the admin.

Note: If your app declares permissions, db() and schema() need the db.raw permission. See App permissions.

Views

$this->view('name', $data) renders views/name.php with $data as variables, plus $app (your app instance) and $base. $this->adminView('name', 'Page title', $data) renders the same inside the admin's layout, and SitePage::view($this, 'name', $data, ['title' => …]) inside the active theme (see Public routes). Escape everything you print:

<p>Hello, <?= htmlspecialchars($userName) ?></p>

Front-end output

Add markup to every public page through the theme's bh_head() and bh_footer() calls. The theme doesn't need to know your app exists:

$this->addHead(fn() => '<link rel="stylesheet" href="' . htmlspecialchars($this->asset('app.css')) . '">');
$this->addFooter(fn() => '<script src="' . htmlspecialchars($this->asset('app.js')) . '" defer></script>');

Note: enqueueStyle() and enqueueScript() are meant for this too. But in Basehim 1.2.53 and earlier they have no effect when called from boot(), because they run before the asset helpers exist. Use addHead() and addFooter() as shown until that is fixed.

Logging

$this->log('Imported 12 items', ['feed' => $url]);                 // info
$this->log('Feed unreachable', ['status' => 503], 'warning');      // also copied to the core log

App logs are written to storage/logs/apps/{slug}-YYYY-MM-DD.log and shown under Apps → Logs. Core writes there too, when it refuses one of your routes or widgets, or switches the app off.

When your app fails

Since 1.2.71 Basehim checks an app's code before loading it: at install, at every activation, after updates, and whenever its files change. An app that would stop PHP (a syntax error, or a method that clashes with the base class), or that throws while loading or in boot(), is switched off. Its registrations are removed, the site keeps running, and the site owner gets a report on the Apps screen with a Check and reactivate button. Before you ship, open Apps → your app → Report → Check again on a test site. The details are in When an app fails.

Packaging and publishing

ZIP the app folder so that app.json is at the root of the archive, or one folder deep. Install it under Apps → Upload.

To distribute through the marketplace, publish it on CloudHim. Every site that has the app sees new versions on Admin → Updates, with your release notes. Updates replace the app's files and keep its settings, tables and active state.

Gotchas

  • boot() runs on every request. Keep it to registrations.
  • A boot() that throws switches your app off. Catch what you can live without.
  • Don't reuse the base class's method names (schema, table, get, db…) for your own helpers.
  • Declaring one permission enforces all of them. Declare everything you use, or nothing.
  • Public routes get checks only through site(). Use $this->router()->site() with auth, can and CSRF on by default; routes from the older get()/post() have none. Use adminGet()/adminPost() for admin pages, and AJAX actions for JSON from the admin.
  • HTML your app saves into posts is filtered when no editor or administrator is signed in, for example from a scheduled task. See the post.unfiltered_html filter in Hooks and filters.
  • Don't call your own site over HTTP. Use $this->api(); it's in-process and keeps the session.
  • An app is not a sandbox. It's PHP in the same process as core. Install apps you trust.

Discussion

Leave a comment