Skip to content

App development

7 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()
└── 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
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->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;
  • MCP tools.

Do real work later: in a route handler, a hook callback or a scheduled task. If boot() throws, the error is logged as "App boot() threw" and anything registered after that line is missing for that request.

Lifecycle methods

MethodWhenTypical use
onInstall()The first time Basehim sees the appDefaults
onActivate()The site owner activates itCREATE 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
Routesget, post, route, routeGroup, adminGet, 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
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

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. 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.

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.
  • Declaring one permission enforces all of them. Declare everything you use, or nothing.
  • Routes registered with get() and post() have no access checks. Use adminGet()/adminPost() for admin pages, and AJAX actions for JSON.
  • 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.

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment