Developer docs
Start
Build with AI
Themes
Apps
- App development
- App development best practices
- Hooks and filters
- The App API
- Sending email from apps
- Admin bar for apps and themes
- Public routes for apps
- Admin screens, routes and UI components
- Dashboard widgets
- Data tables
- AJAX actions
- Media and uploads for apps
- App permissions
- Content types, blocks and widgets
- Content types, taxonomies and custom fields
- Search
- When an app fails: fault isolation and recovery
API and AI agents
Security and contributing
App development best practices
This is the guide to building Basehim apps well: apps that are safe, fast on shared hosting, easy for a site owner to trust, and that keep working through core updates. It collects the rules that are spread across the reference pages, explains why each one exists, and ends with a checklist to run before every release. It is up to date with Basehim 1.2.87.
If you have never built an app, start with Build your first app and App development, then come back here. Every API on this page is documented in detail on the reference pages it links to.
Contents
- The ten rules
- Before you write code
- Folder layout and the manifest
- Organising your code
- What belongs in boot()
- Lifecycle, tables and migrations
- Permissions: ask for the least
- Settings, data and transactions
- Security
- Admin screens and UI
- AJAX actions
- Public pages, endpoints and webhooks
- Uploads and media
- Scheduled and background work
- Dashboard widgets and data tables
- Hooks and filters
- MCP tools for AI agents
- Performance on shared hosting
- Errors and logging
- Versions and compatibility
- Testing your app
- Packaging and publishing
- Anti-patterns
- Pre-release checklist
The ten rules
If you remember nothing else, remember these. The rest of the page explains them.
boot()registers; it doesn't work. It runs on every request. Register hooks, routes, actions, tasks and widgets there, and do real work in handlers.- Never name a method after one of the base class's.
schema,table,db,get,post,view,log,settingsand the rest are taken. A clash stops PHP loading your app. - Use core's front doors.
$this->api()for content,$this->ajax()for browser calls,$this->router()->site()for public routes, the media API for files. Each one carries checks you would otherwise have to remember. - Ask for the fewest permissions, and declare all of them. Declaring one opts you into enforcement of every one.
- Check capability and ownership. A capability says what kind of thing a user may do; only your handler knows whether this user may touch this record.
- Validate input on the way in, escape output on the way out. Every time, in every view.
- Never trust a path, a file name or a file's claimed type. Use
appStorageDir(),UploadedFile::moveTo()andmedia()->path(). - Make every migration, task and webhook safe to run twice. They will be.
- Fail small. Catch what you expect to fail, degrade when a permission is missing, and let real defects surface in your log.
- Clean up after yourself. Your tables, settings and files belong to you; remove them in
onUninstall().
Before you write code
Check what core already does
Core gives apps a large surface: content and custom types, media, users, comments, terms, menus, mail, outbound HTTP, a cache, scheduled tasks, an AJAX gateway, public and admin routes, dashboard widgets, data table columns, editor blocks and MCP tools. Before you build a feature, look for the hook, filter or API that already does it (Hooks and filters, The App API). An app that extends core is smaller, safer and survives updates better than one that rebuilds it.
Never edit core
Files outside content/apps/{your-slug}/ belong to core, the theme or other apps. Updates replace core's files, so an edit there is lost on the next update and may break the site in between. If you need something core doesn't offer, a hook or filter is the right request: open an issue on GitHub and explain the use case.
Decide the shape up front
Write down, before coding:
- Who uses it. Administrators only, editors, signed-in members, or visitors. This decides your capabilities and whether you need public routes.
- What it touches. This becomes your permission list.
- What it stores, and where. Settings, its own tables, private files, or the media library.
- What runs without a person. Scheduled tasks, webhooks, hooks fired by other people's actions.
- What happens on uninstall. If you can't answer this, you'll leave rubbish behind.
Folder layout and the manifest
content/apps/event-manager/
├── app.json the manifest (required)
├── src/
│ ├── App.php the entry class: thin, registrations only
│ ├── Services/ your business logic
│ ├── Http/ route and AJAX handlers
│ └── Support/ small helpers
├── views/ PHP templates
│ └── admin/
├── assets/ css, js, images, icon.png: the only folder browsers can reach
└── README.md for people reading the code
- The folder name must be the manifest's
slug. Since 1.2.85 a folder copied in by FTP isn't an install; an app is installed by uploading its ZIP, from the marketplace, or by developer tools that register it. - Only
assets/is public. Keep everything else, including example data and notes, out of it. Build asset URLs with$this->asset('css/app.css'), which handles subfolder installs. - Ship no development files. No
.git,node_modules, test fixtures with real data,.envfiles or editor folders in the ZIP.
A complete manifest
{
"name": "Event Manager",
"slug": "event-manager",
"version": "1.0.0",
"description": "Adds an Events content type, an upcoming-events widget and reminders by email.",
"namespace": "YourCompany\\EventManager",
"icon": "assets/icon.png",
"developer": { "name": "Your Name", "url": "https://you.example.com" },
"company": { "name": "Your Company", "url": "https://company.example.com" },
"permissions": ["posts.read", "mail.send", "schedule", "db.raw"],
"permission_reasons": {
"mail.send": "Emails attendees a reminder the day before an event.",
"schedule": "Checks once an hour for events that need a reminder.",
"db.raw": "Keeps attendee lists in its own tables."
},
"requires": { "php": ">=8.1", "basehim": ">=1.2.87" }
}
| Field | Best practice |
|---|---|
slug | Lower case, specific and permanent: it names your settings namespace, your tables, your AJAX actions, your widget keys and your log. Changing it later means a new app. Avoid core's reserved names (media, posts, admin, api and so on): an app with one of those slugs can't register AJAX actions. |
namespace | Your vendor plus the app: YourCompany\EventManager. Never App\, which is core's. It's what lets Basehim find your classes. |
version | Semantic versioning: patch for fixes, minor for features, major for breaking changes. onUpgrade() receives it, so be consistent. |
description | One or two plain sentences saying what the app does for the site owner, not how it's built. Up to 600 characters. |
icon | A square PNG or GIF in assets/, 256×256 or 512×512, up to 1 MB, transparent background. Crop empty margins before resizing so the mark fills the square; it's shown small. |
permissions | The minimum you need, all of it. See Permissions. |
permission_reasons | One honest sentence per permission, up to 200 characters, in the site owner's words. It's shown in the permission window as "why". |
requires.basehim | The lowest core version that has every API you use. If you call media()->fromUpload(), that's >=1.2.87. A wrong minimum means a fatal error on an older site instead of a clear message at install. |
requires.apps | Only for apps you really can't run without. A dependency must be installed and active. |
Organising your code
Keep the entry class thin
App.php should read like a table of contents: what the app registers and where each piece lives. Put logic in service classes under src/ and keep handlers short.
<?php
declare(strict_types=1);
namespace YourCompany\EventManager;
use YourCompany\EventManager\Http\EventController;
class App extends \App\Core\App
{
public function boot(): void
{
$this->addAdminMenu(['url' => '/admin/events-report', 'label' => 'Event report', 'icon' => 'calendar']);
$this->adminGet('/events-report', [$this, 'report']);
// A class given by name is loaded only when the route is used.
$this->router()->site()->get('/events', [EventController::class, 'index'], ['name' => 'events.index']);
$this->ajax('rsvp', [
'handler' => [$this, 'rsvp'],
'capability' => 'read',
'input' => ['event_id' => 'required|int|min:1'],
'rate_limit' => ['max' => 10, 'per' => 60],
]);
$this->api()->schedule()->hourly('send-reminders', [$this, 'sendReminders']);
}
}
Name your methods so they can't clash
Your entry class extends \App\Core\App, which already has many public and protected methods: get, post, route, routeGroup, table, schema, db, log, view, adminView, asset, icon, settings, cache, config, request, session, api, schedule, ajax, allowed, make, slug, path, manifest, appStorageDir and more. If you declare a method with one of those names and a different visibility or signature, PHP refuses to load the class. Basehim catches this before it runs and switches the app off, but your app won't work.
- Name helpers for what they do in your domain:
eventTables(),reminderLog(),renderEventCard(). - Better still, put helpers in your own classes, where the base class can't collide with them.
- Core no longer adds new methods to the base class for this reason, so a name that is free today stays free.
No code that runs on include
A class file should declare classes and nothing else: no echo, no database calls, no define() at the top level. Basehim's isolated load test includes your files in a process with no database and no .env; code that runs on include fails there, and it also runs on every request that autoloads the class.
Write modern, strict PHP
- Declare
strict_typesand give parameters and returns real types. Most bugs a reviewer would find become errors you find first. - Prefer small final classes with constructor injection.
$this->make(SomeClass::class)resolves core services from the container when you need one. - Target the PHP version in
requires.phpand nothing newer.
What belongs in boot()
boot() runs for every active app on every request: page views, admin screens, the REST API, AJAX and MCP. On a busy shared host, a slow boot() is multiplied by every visitor.
| Do in boot() | Don't do in boot() |
|---|---|
| Register hooks and filters | Query the database to decide what to register |
| Register admin and public routes, AJAX actions | Call a remote service |
| Register scheduled tasks, widgets, content types, table columns, MCP tools | Read or write files |
| Add admin menu items, head and footer output callbacks | Send mail, create content, run migrations |
- Registration is cheap and idempotent. Registering the same route or task twice is one route or task, so don't guard it.
- A
boot()that throws switches your app off. Basehim removes everything it registered in that request and shows the site owner a report. Catch only what you expect to fail and can live without, such as an optional integration, and log it. - Pass callables, not output.
addHead(fn() => …)builds its markup only when a page is actually rendered. - Register AJAX actions only in
boot().ajax()called from anywhere else is refused, by design: a deactivated app's actions disappear on the next request.
Lifecycle, tables and migrations
| Method | Use it for | Rules |
|---|---|---|
onInstall() | Default settings | Runs once. If it throws, it runs again at the next activation. |
onActivate() | Create tables, folders | Runs before the app is marked active; a throw keeps it off with the reason shown. Must be idempotent: it runs on every activation. |
onUpgrade($from, $to) | Migrate tables and settings | Runs once per version change, however the files arrived, also on downgrade. Must be idempotent. |
onDeactivate() | Stop external jobs | Keep data: deactivating is often temporary. |
onUninstall() | Drop tables, settings, private files | The last chance to clean up. Failures are logged, never block removal. |
Your own tables
public function onActivate(): void
{
$this->schema("CREATE TABLE IF NOT EXISTS `{$this->table('attendees')}` (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
event_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY event_user (event_id, user_id),
KEY user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}
- Always name tables with
$this->table('name'). It adds the site's prefix and your slug, so two apps can't collide and uninstall knows what's yours. - InnoDB,
utf8mb4, an index for every column you filter or sort by, and a unique key wherever duplicates would be a bug. A unique key is the only reliable guard against a double insert under load. - Store times in UTC (
bh_now(),bh_to_utc()) and convert for display.
Migrations that survive anything
public function onUpgrade(string $from, string $to): void
{
if (version_compare($from, '1.1.0', '<')) {
$cols = $this->db()->select("SHOW COLUMNS FROM `{$this->table('attendees')}` LIKE 'status'");
if (!$cols) {
$this->schema("ALTER TABLE `{$this->table('attendees')}` ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'going'");
}
}
if (version_compare($from, '1.2.0', '<')) {
// Settings moved from one key to two.
$old = $this->getSetting('reminders', null);
if ($old !== null) {
$this->setSetting('reminder_hours', (int) ($old['hours'] ?? 24));
$this->deleteSetting('reminders');
}
}
}
- One block per version, compared with
version_compare. A site can jump from 1.0.0 to 1.4.0 in one update; every block in between must run. - Check before you change. A column that already exists, a key already moved: a retry starts from the top.
- A throw is a real stop. Core keeps
schema_versionseparately from the files' version and moves it only whenonUpgrade()returns. If yours throws, an active app is switched off (its new code expects the new schema) and the site owner sees why. Throw only when continuing would corrupt data. - Think about downgrades.
onUpgrade()also fires when an older version is installed over a newer one. Usually the right answer is to do nothing and keep data the old code can ignore. - Large data changes go in batches, through a scheduled task, not in one request that a host's time limit will kill halfway.
Uninstall completely
public function onUninstall(): void
{
$this->schema("DROP TABLE IF EXISTS `{$this->table('attendees')}`");
$this->dropAllSettings();
// Remove anything you wrote under storage/apps/{slug} as well.
}
Content you created through the API (posts, media, terms) belongs to the site, not to your app. Leave it unless the site owner chose to remove it.
Permissions: ask for the least
Permissions are a policy layer over $this->api(), not a sandbox. Their value is trust: the site owner sees, in plain words, what your app will touch, and your app can't exceed that by accident. Details in App permissions.
- Declare everything or nothing. An app with no
permissionsruns unrestricted and is badged as such. Declaring one opts you into enforcement of all of them, so a missing line becomes a broken feature. New apps should declare. - Ask for the least. Each line is one more thing an operator must accept, and a long list teaches people to click through. A write implies its read, so don't list both.
- Don't declare what's already yours. Your own settings, cache and log never need a permission.
- Treat
db.rawas a big ask. It allows arbitrary SQL and bypasses everything else. You need it for your own tables (db()andschema()), but use$this->api()for core content so the same checks and hooks run as in the admin. - Explain each one in
permission_reasons, honestly and specifically. - Degrade when one is withheld. An operator can refuse individual permissions. API calls then return
null,false,0or an empty array and log the reason, so check return values, and test with each permission refused.
if ($this->allowed('mail.send')) {
$this->api()->mail()->send($to, $subject, $html);
} else {
$this->log('Reminder not sent: mail.send is not allowed', ['event' => $id], 'warning');
}
- Adding a permission in an update needs re-approval. Bundle new permissions into releases that need them and say why in the release notes.
db(),schema()andagents()throwAppPermissionMissingwhen not allowed. Core answers that for you: your admin screen shows the permission window, your AJAX action answers 403APP_PERMISSION_MISSING, and aboot()that needs it waits for approval instead of being marked broken. Don't catch and hide it.
Settings, data and transactions
Your settings vs the site's
$this->getSetting()/setSetting()are yours: namespaced toapp:{slug}, no permission needed, removed on uninstall. Always pass a default togetSetting()and cast what comes back.$this->api()->settings()is the whole site's configuration. Read it withsettings.readif you must; write it only if your app's purpose is to change site configuration.- Large or frequently written values (a cache of remote data, a sync cursor, a counter) should be saved with
autoload = false, so they aren't loaded on every request:$this->api()->settings()->set('app:' . $this->slug(), 'feed_cache', $data, false). - Counters and lists are races. Two requests that read, change and write the same value lose one update. Use
updateOwn(), which locks the row; keep the function pure, it may run more than once.
$sent = $this->api()->settings()->updateOwn('reminders_sent', fn($n) => (int) $n + 1, 0);
Queries
- Bound parameters, always. Never concatenate a value into SQL. The only safe inlining is a list of integers you have just cast with
intval. - Write core tables as
{posts},{users}insideselect(),selectOne(),execute()andschema();insert(),update()anddelete()take a bare name and add the prefix. - Prefer the API for core content.
$this->api()->posts()->create()runs core's validation, filtering and hooks; a rawINSERTinto{posts}skips all of them and breaks other apps that listen. - Mind the caps.
all()returns at most 500 rows by default. For more, usepaginate()and loop.
$rows = $this->db()->select(
"SELECT a.user_id FROM `{$this->table('attendees')}` a WHERE a.event_id = :id AND a.status = :status",
['id' => $eventId, 'status' => 'going']
);
Writes that belong together
$this->api()->transaction(function () use ($eventId, $userId) {
$this->db()->insert($this->table('attendees'), [
'event_id' => $eventId, 'user_id' => $userId, 'created_at' => bh_now(),
]);
$this->api()->settings()->updateOwn('rsvps_total', fn($n) => (int) $n + 1, 0);
});
// Send the confirmation email here, after the commit, never inside.
A transaction is retried up to three times on a deadlock, so never send mail or call another server inside one.
Security
An app runs in the same PHP process as core, with core's rights. Basehim's permissions, scanner and fault isolation limit accidents; they can't stop a careless app from opening a hole in someone's site. These rules are not optional. See also Security for developers.
Escape everything you print
- Text into HTML:
htmlspecialchars($value, ENT_QUOTES). Attributes too, including URLs you build. - Data into JavaScript:
json_encode()withJSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT, or adata-attribute you escape and read withdataset. - In the browser, set text with
textContent, notinnerHTML, for anything that came from a user or the server. - Validation is about shape. A string that passed
string|max:200can still contain<script>.
Validate every input
- Through AJAX actions, declare an
inputschema: undeclared fields are refused, which stops a client addingroleorauthor_idto a request. - In route handlers, validate each field yourself: type, length, allowed values, and URLs restricted to
httpandhttps. - Never pass request data to
include, a class name, a method name or a callable.
Capability and ownership
- Give every admin route, AJAX action and widget the capability it needs. Defaults are deliberately narrow (
access_app:{slug}); widen on purpose. - An ID in a request is a claim, not a right. Load the record and check that this user may act on it before you change it.
- Hiding a button is not a permission check. Every action must be safe to call by hand with any input the schema allows.
CSRF
- AJAX actions and
site()routes check it for you. Use them. - An
adminPost()handler must check it itself:$this->session()->verifyCsrf((string) $request->input('_csrf')). - GET must never change anything; it isn't CSRF-checked.
- Turn CSRF off only for endpoints other servers call, and verify their signature instead (see webhooks).
Files and paths
- Never build a path from user input. A file name, a folder or an ID that becomes part of a path is how
../../.envgets read. - Private files go in
$this->appStorageDir('exports'):storage/apps/{slug}/…, never served, with a deny-all.htaccessadded for you. - Media files are found with
$this->api()->media()->path($id), which refuses paths outside the uploads folder. - Never execute uploaded content: no
includeof an uploaded file, no extracting archives into a web-served folder, no running anything a user sent. - Downloads are streamed by your route after its own checks, with a fixed file name you chose; they never need to sit in a public folder.
Secrets and personal data
- Never store a user's password, a session ID or a sign-in token. If you need to act for a user later, store their user ID and check their capability at that time.
- Third-party API keys the site owner gives you go in your settings. Show them masked in your settings screen, never print them back in full, and never put them in URLs, logs, error messages or AJAX responses.
- Collect only what you need about visitors, say so in your description, and delete it on uninstall.
- Read configuration through core (
$this->config()), never by opening.env.
Outbound HTTP
- Use
$this->api()->http()with a shorttimeout(). A remote service that hangs must not hang your visitors' page. - Never disable TLS verification.
- If a URL comes from a user, restrict it to
https(orhttp) and be careful about letting users make your server fetch arbitrary addresses: that's how internal services get probed. - Don't call your own site over HTTP. Use
$this->api(); it's in-process and keeps the session.
Stay clear of what the scanner flags
Basehim scans app code at install for constructs that reach around the API: eval, shell execution, new PDO, direct .env reads, disabled TLS verification and a few more. The scanner is a heuristic, but a flag is the first thing a site owner sees. If your app needs none of these, and almost none do, it should contain none of them.
Admin screens and UI
Routes and access
$this->addAdminMenu(['url' => '/admin/events-report', 'label' => 'Event report', 'icon' => 'calendar']);
$this->adminGet('/events-report', [$this, 'report']);
$this->adminPost('/events-report/settings', [$this, 'saveSettings']);
public function report(): string
{
return $this->adminView('report', 'Event report', ['rows' => $this->reportRows()]);
}
adminGet()andadminPost()require a signed-in user withaccess_app:{slug}. Administrators have it; others are granted it under Users → Access.- To open a screen to a wider role, map its path with the
admin.area_policyfilter, e.g.['/admin/events/my-tickets' => 'read'], rather than inventing your own check. - Don't use plain
get()/post()for admin pages: they have no checks at all.
Use the admin's components
Apps should look like part of Basehim. Use the shared components from Admin screens, routes and UI components instead of your own CSS:
| Need | Use |
|---|---|
| Buttons | bh-btn with --primary (one per screen), --secondary, --danger, --ghost; --sm, --lg, --icon (with an aria-label) |
| Sections | bh-card, bh-card__head, bh-card__title, bh-card__body |
| Fields | bh-label with for=, bh-input, bh-select, bh-textarea, bh-help; aria-invalid="true" on errors |
| On/off settings | bh-switch (a real checkbox with role="switch"), bh-choices, bh-seg |
| Settings pages | The helpers in admin/views/settings/_ui.php: bh_set_card_open(), bh_set_toggle(), bh_set_row(), bh_set_save(). They also give you the phone layout. |
| Status | bh-badge with a status modifier (--green success, --amber warning, --red danger, --blue info), or the palette's bh-text-bg-* classes (1.3.0) |
| Nothing yet | bh-empty with an icon, a title, a sentence and one action |
| Confirm, alert, notify | bhConfirm(), bhAlert(), bhToast(). Never confirm() or alert(). |
| Waiting | bhBusy() or bhLoader() with done()/fail(). Don't add a spinner of your own. |
| Server calls | Basehim.ajax.request(), .get(), .upload(). Never a hand-written fetch() to /ajax. |
Light, dark and colours (1.3.0)
Each person picks light, dark or their device's setting and an accent, so never assume a white page or blue:
- No
#fff,whiteor hex colours in your CSS. Use core's classes, the named variables (rgb(var(--bh-surface)),rgb(var(--bh-text)),rgb(var(--bh-accent))) or a Tailwind build ontailwind.preset.js. - Status is the palette, never the accent: success green, warning orange, danger red, info blue, the same in every accent.
- Charts take their colours from
Basehim.appearance.get().paletteand redraw onBasehim.appearance.onChange(). - A stylesheet one screen needs goes in through
HeadStyles::add()in the view, never a<link>in the body. - Core partials (the data table) work with
requireorView::include(); don't depend on$thisin markup you share. - Check every screen in light and dark, in two accents, on desktop and at 390 px.
Details: Light, dark and accent and Colour palette.
Write for the person using it
- Sentence case. Name buttons by what they do: "Save changes", "Send reminder", "Delete".
- Destructive actions ask first with
bhConfirm(message, { danger: true }), and say what will be lost. - Error messages say what happened and what to do next, in plain words, never a stack trace or SQL.
- Every screen works on a phone, with a keyboard, and with labels a screen reader can announce.
AJAX actions
For any browser call that returns JSON, register an action with $this->ajax(). The gateway checks the session, origin, CSRF token, capability, area, rate limit, input schema and idempotency key before your handler runs. A hand-written route has to remember every one. Reference: AJAX actions.
use App\Core\Ajax\AjaxContext;
use App\Core\Ajax\AjaxException;
$this->ajax('attendee.remove', [
'handler' => [$this, 'removeAttendee'],
'capability' => 'edit_posts',
'area' => '/admin/events-report',
'input' => ['id' => 'required|int|min:1'],
'rate_limit' => ['max' => 30, 'per' => 60],
]);
public function removeAttendee(AjaxContext $ctx): array
{
$row = $this->db()->selectOne(
"SELECT id, event_id FROM `{$this->table('attendees')}` WHERE id = :id",
['id' => $ctx->input('id')]
);
if (!$row) throw AjaxException::notFound();
if (!$this->userManagesEvent($ctx->userId(), (int) $row['event_id'])) {
throw AjaxException::forbidden('You can only manage attendees of your own events.');
}
$this->db()->delete($this->table('attendees'), ['id' => (int) $row['id']]);
$ctx->log('Attendee removed', ['id' => (int) $row['id']]);
return ['removed' => true];
}
- Always set
capability('read'means any signed-in user) and, for an action that serves an admin screen,area, so the action is never more open than the screen. - Declare the full
inputschema. Optional fields that weren't sent are absent from$ctx->all(), which makes partial updates safe. An object whose keys are data is sent as a JSON string and decoded and checked in the handler. - Set a
rate_limitthat fits the action: tight for anything that sends mail or costs money, looser for reads. methods => ['GET']only for reads. GET isn't CSRF-checked.- Mark actions that create something
idempotent(an order, a booking) so a double click or a retry runs once. - Public actions (
'auth' => 'public') are for visitors: keep their input small and their rate limit low, and assume anyone can call them. - Return arrays; never echo. Throw
AjaxExceptionfor an error the user should see. Any other exception becomes a generic 500 with the details in your log. - Put the logic in a service that the AJAX handler, your admin screen and your MCP tool all call.
bhBusy(button, true, 'Removing…');
Basehim.ajax.request('event-manager.attendee.remove', { id: 42 })
.then(function () { row.remove(); bhToast('Removed'); })
.catch(function (err) { bhToast(err.message, 'error'); }) // safe to show
.then(function () { bhBusy(button, false); });
Public pages, endpoints and webhooks
Register visitor-facing routes with $this->router()->site(). It gives you per-route auth, can, CSRF (on by default for changes), rate and idempotent, and protects core's and other apps' routes from yours. Reference: Public routes for apps.
use App\Core\Http\SitePage;
$site = $this->router()->site();
$site->get('/events/{slug}', [EventController::class, 'show'], [
'name' => 'events.show',
'where' => ['slug' => '[a-z0-9-]+'],
]);
$site->post('/api/event-manager/rsvp', [EventController::class, 'rsvp'], [
'auth' => true, 'rate' => [10, 60], 'idempotent' => true,
]);
// In the controller: render inside the active theme.
return SitePage::view($app, 'event', ['event' => $event], ['title' => $event['title']]);
- Constrain parameters with
where. A path that doesn't match isn't your route. - Pages render in the theme with
SitePage, so your page has the site's header, footer and SEO tags. - Build links with names:
$this->router()->url('events.show', ['slug' => $slug])includes the install's base path. - Your own API lives under
/api/your-slug/…. Core reserves/ajax,/api/v1,/mcp,/oauth,/.well-known,/uploads,/contentand/storage. - Prefer
site()over the olderget()/post()helpers, which have no automatic checks.
Webhooks
use App\Core\Http\Webhook;
$site->post('/event-manager/webhooks/pay', [$this, 'paid'], ['csrf' => false, 'rate' => [120, 60]]);
public function paid(Request $request): Response
{
$body = Webhook::payload();
$sig = Webhook::header($request, 'Pay-Signature');
if (!Webhook::verifyTimestamped($body, $sig, (string) $this->getSetting('webhook_secret', ''))) {
return Response::json(['error' => 'bad signature'], 400);
}
$event = json_decode($body, true);
$this->api()->idempotency()->once('pay', (string) $event['id'], fn() => $this->markPaid($event));
return Response::json(['ok' => true]);
}
- Verify every signature (
verifyHmac()orverifyTimestamped(), both constant-time) before reading anything. - Providers retry. Handle each event once with
idempotency()->once(). - Answer fast. If the work is slow, record the event and finish it in a scheduled task.
Uploads and media
Since 1.2.87 apps take files through the AJAX gateway and extend the media library with hooks, without their own upload routes. The full reference is Media and uploads for apps.
Accepting files
$this->ajax('import', [
'handler' => [$this, 'import'],
'capability' => 'manage_options',
'files' => ['file' => ['types' => ['csv'], 'max_size' => 5 * 1024 * 1024]],
]);
public function import(AjaxContext $ctx): array
{
$file = $ctx->file('file'); // already checked: arrived whole, type, size, content
$path = $file->moveTo($this->appStorageDir('imports'), 'import-' . date('Ymd-His') . '.csv');
return ['stored' => basename($path), 'rows' => $this->countRows($path)];
}
- Declare the narrowest
typesand a realmax_size. Addmimesto check the detected content type too. Executable extensions can never be declared. - Use
UploadedFile:name()is for display only; store under a name you choose withmoveTo(), which refuses names a web server would run. - Upload from the browser with
Basehim.ajax.upload(action, data, files, { onProgress }).
The media library
- Add a file with
media()->fromUpload($file, $meta); every library rule and filter applies, and the person must holdupload_media. - Change a file in place with
replaceFile($id, $path, ['expect_sha1' => $sha1]): same URL, sizes rebuilt, refused if the file changed since you read it. It keeps no copy: back up toappStorageDir()first if your app offers undo. - When a call returns null, show
media()->lastError(); it's written for people. - Hook in rather than replace.
media.upload.check,media.upload.file,media.sizes,media.item,media.uploaded,media.deletingand the rest run for every way into the library: the media screen, the editor, REST, imports and other apps. Treat a browser-supplied upload context as a hint, not proof; contexts with a colon (core:api,app:{slug}) are set by the server. - Clean up your own data on
media.deleting(backups, cached results) so a deleted item leaves nothing behind.
Scheduled and background work
$this->api()->schedule()->hourly('send-reminders', [$this, 'sendReminders']);
$this->api()->schedule()->daily('cleanup', [$this, 'cleanup']);
$this->api()->schedule()->everyMinutes(15, 'sync', [$this, 'sync']);
- Register in
boot(). Handlers are never stored, so a task whose app isn't running is skipped, never fired. - Design for traffic-driven timing. Without a real cron job, tasks run after page responses, so a quiet site runs nothing. Tell site owners that punctual tasks need the cron URL from Admin → System.
- Small batches. At most five tasks run per sweep and hosts kill long requests. Process a bounded batch, save a cursor (with
autoload = false), and continue next run. - Safe to run again. A run killed by a time limit is retried when its 15-minute lease expires. Mark work done as you go so a retry doesn't send the same email twice.
- Let failures fail. A task that throws is logged, counted and backed off, and Admin → System shows the error. Don't swallow exceptions you can't handle.
- Scheduled tasks run with no signed-in user. HTML your app saves into posts is filtered then; see the
post.unfiltered_htmlfilter in Hooks and filters.
Dashboard widgets and data tables
Dashboard widgets
- Use
registerWidget()with'surfaces' => ['dashboard']and a native view (stat,list,table,chart,feed,status) rather thanhtml. You return data; the card handles loading, empty, error and refresh states. - The default capability is
access_app:{slug}. Set'capability' => 'read'only for data every signed-in user may see. - Throw
WidgetUnavailable('Not connected yet')for expected states; any other exception shows a generic error and logs the details. - Use
cachefor expensive providers (scopeglobalonly for data that doesn't depend on who's asking), and leaverefreshat 0 unless the data really changes every minute.
Reference: Dashboard widgets.
Data table columns
- Add a column to a core list screen with the
datatable.columns.{table}filter, anidnamespaced by your slug, and acapabilityif the data is sensitive. A gated column is removed from the response, not hidden. - Follow the two-phase contract:
prepare(array $rows)runs once per page and does one bulk query;render($row, $prepared)is pure, with no database, HTTP or file access. - Make new columns
default_visible => falseunless every user of the screen needs them. - Escape everything you interpolate in
render(); the output is sanitised as well, but that isn't a substitute.
Reference: Data tables.
Hooks and filters
- A filter must return a value of the type it received, even when you change nothing.
- Keep callbacks cheap. Hooks like
post.createdrun inside someone else's request. Queue slow work for a scheduled task. - Accept the arguments core passes and nothing more; check the list in Hooks and filters.
- Pass the argument count when your callback takes more than one.
addAction()andaddFilter()hand a callback one argument unless you say otherwise:$this->addFilter('post.content', fn(string $html, array $post) => $html, 10, 2). Without the2, the callback fails, core logs the error and carries on, and your hook silently does nothing. - Don't throw from a hook unless the hook's contract says a throw means "refuse" (the upload check filters do). Log and return.
- Make your app extensible the same way. Fire your own hooks with names under your slug,
event-manager.reminder.before_send, and document them.
MCP tools for AI agents
An app can give AI assistants tools through the built-in MCP server. Contribute definitions to the mcp.tools filter and answer calls on mcp.call.{name}:
$this->addFilter('mcp.tools', function (array $tools, array $scopes): array {
$tools[] = [
'name' => 'event_manager_upcoming',
'description' => 'List the next upcoming events with their dates and attendee counts. Read-only.',
'inputSchema' => [
'type' => 'object',
'properties' => ['limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 50]],
'additionalProperties' => false,
],
'_scope' => 'posts:read',
];
return $tools;
}, 10, 2); // 2 = the callback takes two arguments
$this->addFilter('mcp.call.event_manager_upcoming', function ($result, array $args) {
$limit = max(1, min(50, (int) ($args['limit'] ?? 10)));
return $this->events()->upcoming($limit); // arrays are returned as JSON
}, 10, 2);
- Prefix tool names with your app; core's tools win any collision.
- Gate with
_scope. A tool is listed and callable only for API keys that hold its scope. - Validate arguments yourself in the call handler, as you would any request input. The schema helps the assistant; it isn't enforcement.
- Write descriptions for a model: what the tool does, what it returns, whether it changes anything.
- Reuse your service layer. The tool should call the same method as your admin screen and AJAX action.
More in Give your app an MCP tool and MCP and AI agents.
Performance on shared hosting
Basehim runs on shared hosting with limited memory, time and database connections. Build for that.
- Lazy everything. Pass controller classes by name so they load only when used; build head and footer output in callbacks.
- Cache remote and expensive results with
$this->api()->cache()->remember($key, $ttl, $fn); keys are namespaced to your app. - No N+1 queries. One query with
IN (…)beats one per row, in tables, widgets and lists alike. - Index what you filter. A missing index is invisible with ten rows and fatal with a hundred thousand.
- Keep autoloaded settings small; everything else
autoload = false. - Load assets only where they're used, on your own admin screens or the public pages that need them.
- Bound every loop over data you don't control, and paginate.
Errors and logging
- Log with context:
$this->log('Feed unreachable', ['status' => 503], 'warning'). Warnings and above are copied to the core log; info and debug stay in yours, under Apps → Logs. - In AJAX handlers use
$ctx->log(), which tags the line with the request ID a user can quote. - Never log secrets, passwords, tokens, full request bodies or personal data you don't need.
- Show people sentences, keep details in the log.
AjaxExceptionmessages,WidgetUnavailablemessages andlastError()are for people; exception traces are not. - Check return values from the API. It degrades rather than throws, so
0fromcreate()means it didn't happen, and the reason is in your log.
Versions and compatibility
- Set
requires.basehimto the oldest version you've tested and that has every API you call. - Use only documented APIs. Core's internal classes and private methods can change in any release; the base class helpers,
$this->api(), documented hooks and the gateway are what core keeps stable. - Never redefine a core class or put files in core's namespace.
- Keep your own hooks and settings stable across versions; when you must change them, migrate in
onUpgrade()and say so in the release notes. - Write release notes for site owners: what changed for them, any new permission and why, anything they must do.
Testing your app
Test on a real Basehim install with the site in Development mode, then run through these:
- The code check: Apps → your app → Report → Check again passes, including the isolated load test.
- The full lifecycle: install from the ZIP, activate, use, deactivate, reactivate, uninstall. After uninstall, your tables, settings and private files are gone.
- Upgrades: install the previous release, add data, upload the new one. Data survives,
onUpgrade()runs once, and running it twice changes nothing. - Permissions: refuse each permission in turn. Nothing white-screens; features degrade with a clear message.
- Roles: use every screen and action as an administrator, an editor and a subscriber. Call your actions by hand with another user's IDs.
- Hostile input: HTML and quotes in every field, oversized values, wrong types, a file with a lying extension, a path with
../. - Hosting: a subfolder install, the lowest PHP version you support, no real cron, a tight memory limit.
- Logs: after all of the above, Apps → Logs has no warnings you didn't expect.
For automated tests, keep your logic in plain service classes that take their dependencies through the constructor; they can then be unit-tested without a running site.
Packaging and publishing
- ZIP the app folder so
app.jsonis at the root of the archive or one folder deep, and the folder is named after the slug. - Include only what the app needs to run, plus a README.
- Bump
versionfor every release that goes to a site, even a one-line fix: updates andonUpgrade()depend on it. - Install the final ZIP on a clean site before you publish it.
- Distribute through CloudHim so every site with your app sees new versions, with your release notes, on Admin → Updates. Updates replace your files and keep settings, tables and the active state.
Anti-patterns
| Don't | Do instead |
|---|---|
A private schema(), table() or get() helper on your App class | A specific name, or a helper in your own class |
Database queries or HTTP calls in boot() | Register in boot(), work in handlers and tasks |
| A hand-written JSON route with its own CSRF check | $this->ajax() |
Your own $_FILES handling and upload route | 'files' on an AJAX action and UploadedFile |
Raw INSERT into {posts} | $this->api()->posts()->create() |
| String-concatenated SQL | Bound parameters |
| Calling your own site over HTTP | $this->api() |
Files saved under assets/ or the web root | $this->appStorageDir() |
| Paths built from request data | Fixed names, IDs looked up through core |
Reading .env, new PDO, eval, shell commands | The API, config(), db() |
| Site settings for your app's own values | getSetting() / setSetting() |
| Read, change, write on a counter | updateOwn() |
| Sending mail inside a transaction | Send after commit |
| One giant scheduled run | Small batches with a cursor |
confirm(), alert(), your own spinner and CSS | bhConfirm(), bhToast(), bhLoader(), bh-* components |
| Declaring some permissions and not others | All of them, or none |
| Leaving tables and settings after uninstall | Drop them in onUninstall() |
| Editing core files | A hook, a filter, or a feature request |
Pre-release checklist
Copy this into your release process.
Manifest and package
- ☐
slugmatches the folder;namespaceis yours, notApp\. - ☐
versionbumped;requires.basehimandrequires.phpare accurate. - ☐ Icon is a square, transparent 256 or 512 px PNG that fills its frame.
- ☐ Description says what it does for the site owner.
- ☐ ZIP has
app.jsonat the root and no development files.
Code
- ☐ No method on the App class shares a name with a base class method.
- ☐
boot()only registers; nothing in it can be slow. - ☐ Hook callbacks that take more than one argument pass the count (
addFilter($tag, $fn, 10, 2)). - ☐ Class files have no top-level side effects.
- ☐ Check again passes, including the load test.
Permissions and security
- ☐ Every permission used is declared, each with a reason; nothing extra.
- ☐ The app works, or degrades clearly, with each permission refused.
- ☐ Every route, action and widget has the right capability; handlers check ownership.
- ☐ Every POST is CSRF-checked (automatic through
ajax()andsite()). - ☐ Every input is validated; every output is escaped.
- ☐ No path is built from user input; private files are in
appStorageDir(). - ☐ No secrets in logs, URLs or responses; no user passwords or tokens stored.
- ☐ Outbound HTTP has timeouts and TLS verification on.
- ☐ Webhooks verify signatures and handle repeats once.
Data and lifecycle
- ☐ Tables use
$this->table(), are indexed and created idempotently. - ☐
onUpgrade()handles every older version and can run twice safely. - ☐ Scheduled tasks work in batches and are safe to re-run.
- ☐ Uninstall removes tables, settings and private files.
Experience
- ☐ Every screen checked in light and dark mode and in two accents, with no fixed colours in your CSS (1.3.0).
- ☐ Screens use the admin components and work on a phone and with a keyboard.
- ☐ Destructive actions confirm; errors are plain sentences.
- ☐ Apps → Logs is clean after a full test run.
- ☐ Release notes say what changed, any new permission and why.
Further reading
- App development: the manifest, entry class and base class helpers.
- The App API, Hooks and filters, App permissions.
- AJAX actions, Public routes for apps, Admin screens, routes and UI components.
- Media and uploads for apps, Data tables, Dashboard widgets, Content types, blocks and widgets.
- When an app fails, Security for developers, MCP and AI agents.
- Building with an AI assistant? See Build with AI.
Leave a comment