App development
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" }
}
}
| Field | Required | Notes |
|---|---|---|
name, slug, version | yes | slug: letters, digits, - and _, 2–80 characters. Keep the folder name the same |
namespace | in practice, yes | Basehim maps it to src/; without it your class isn't found |
entry | no | Entry class name inside the namespace. Default App |
src | no | Source folder. Default src |
description | no | Plain text, up to 600 characters. Shown on the Apps and Updates screens |
developer, company | no | { "name", "url" }; the URL must be http(s):// |
icon | no | A square PNG or GIF in assets/, 256×256 or 512×512, up to 1 MB |
permissions | no | Declaring any opts the app into enforcement. See App permissions |
requires | no | Checked 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
| Method | When | Typical use |
|---|---|---|
onInstall() | The first time Basehim sees the app | Defaults |
onActivate() | The site owner activates it | CREATE TABLE IF NOT EXISTS |
onDeactivate() | Deactivated | Stop external jobs; keep data |
onUpgrade($from, $to) | The version on disk changed (also on downgrade) | Migrate your tables and settings |
onUninstall() | Before removal | Drop 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
| Area | Methods | Read more |
|---|---|---|
| Hooks | addAction, addFilter, doAction, applyFilters | Hooks and filters |
| Routes | get, post, route, routeGroup, adminGet, adminPost | Admin screens and routes |
| Admin UI | addAdminMenu, adminView, addAdminStyle, addAdminScript | Admin screens and routes |
| AJAX | ajax, useAjaxClient | AJAX actions |
| Front end | addHead, addFooter, enqueueStyle, enqueueScript | below |
| Editor | addEditorScript, addEditorStyle, addEditorConfig, registerBlockRenderer | Content types, blocks and widgets |
| Content | registerPostType, registerWidget | Content types, blocks and widgets |
| Core API | api(), schedule() | The App API |
| Settings | getSetting, setSetting, deleteSetting, allSettings, dropAllSettings | below |
| Database | table, schema, db | below |
| Views | view, adminView, asset | below |
| Utility | cache, 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()andschema()need thedb.rawpermission. 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()andenqueueScript()are meant for this too. But in Basehim 1.2.53 and earlier they have no effect when called fromboot(), because they run before the asset helpers exist. UseaddHead()andaddFooter()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()andpost()have no access checks. UseadminGet()/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_htmlfilter 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.
Leave a comment