Skip to content
Developer docs

App development best practices

Updated 36 min read

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

  1. The ten rules
  2. Before you write code
  3. Folder layout and the manifest
  4. Organising your code
  5. What belongs in boot()
  6. Lifecycle, tables and migrations
  7. Permissions: ask for the least
  8. Settings, data and transactions
  9. Security
  10. Admin screens and UI
  11. AJAX actions
  12. Public pages, endpoints and webhooks
  13. Uploads and media
  14. Scheduled and background work
  15. Dashboard widgets and data tables
  16. Hooks and filters
  17. MCP tools for AI agents
  18. Performance on shared hosting
  19. Errors and logging
  20. Versions and compatibility
  21. Testing your app
  22. Packaging and publishing
  23. Anti-patterns
  24. Pre-release checklist

The ten rules

If you remember nothing else, remember these. The rest of the page explains them.

  1. 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.
  2. Never name a method after one of the base class's. schema, table, db, get, post, view, log, settings and the rest are taken. A clash stops PHP loading your app.
  3. 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.
  4. Ask for the fewest permissions, and declare all of them. Declaring one opts you into enforcement of every one.
  5. 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.
  6. Validate input on the way in, escape output on the way out. Every time, in every view.
  7. Never trust a path, a file name or a file's claimed type. Use appStorageDir(), UploadedFile::moveTo() and media()->path().
  8. Make every migration, task and webhook safe to run twice. They will be.
  9. Fail small. Catch what you expect to fail, degrade when a permission is missing, and let real defects surface in your log.
  10. 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, .env files 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" }
}
FieldBest practice
slugLower 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.
namespaceYour vendor plus the app: YourCompany\EventManager. Never App\, which is core's. It's what lets Basehim find your classes.
versionSemantic versioning: patch for fixes, minor for features, major for breaking changes. onUpgrade() receives it, so be consistent.
descriptionOne or two plain sentences saying what the app does for the site owner, not how it's built. Up to 600 characters.
iconA 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.
permissionsThe minimum you need, all of it. See Permissions.
permission_reasonsOne honest sentence per permission, up to 200 characters, in the site owner's words. It's shown in the permission window as "why".
requires.basehimThe 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.appsOnly 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_types and 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.php and 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 filtersQuery the database to decide what to register
Register admin and public routes, AJAX actionsCall a remote service
Register scheduled tasks, widgets, content types, table columns, MCP toolsRead or write files
Add admin menu items, head and footer output callbacksSend 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

MethodUse it forRules
onInstall()Default settingsRuns once. If it throws, it runs again at the next activation.
onActivate()Create tables, foldersRuns 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 settingsRuns once per version change, however the files arrived, also on downgrade. Must be idempotent.
onDeactivate()Stop external jobsKeep data: deactivating is often temporary.
onUninstall()Drop tables, settings, private filesThe 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_version separately from the files' version and moves it only when onUpgrade() 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 permissions runs 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.raw as a big ask. It allows arbitrary SQL and bypasses everything else. You need it for your own tables (db() and schema()), 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, 0 or 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() and agents() throw AppPermissionMissing when not allowed. Core answers that for you: your admin screen shows the permission window, your AJAX action answers 403 APP_PERMISSION_MISSING, and a boot() 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 to app:{slug}, no permission needed, removed on uninstall. Always pass a default to getSetting() and cast what comes back.
  • $this->api()->settings() is the whole site's configuration. Read it with settings.read if 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} inside select(), selectOne(), execute() and schema(); insert(), update() and delete() 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 raw INSERT into {posts} skips all of them and breaks other apps that listen.
  • Mind the caps. all() returns at most 500 rows by default. For more, use paginate() 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() with JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT, or a data- attribute you escape and read with dataset.
  • In the browser, set text with textContent, not innerHTML, for anything that came from a user or the server.
  • Validation is about shape. A string that passed string|max:200 can still contain <script>.

Validate every input

  • Through AJAX actions, declare an input schema: undeclared fields are refused, which stops a client adding role or author_id to a request.
  • In route handlers, validate each field yourself: type, length, allowed values, and URLs restricted to http and https.
  • 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 ../../.env gets read.
  • Private files go in $this->appStorageDir('exports'): storage/apps/{slug}/…, never served, with a deny-all .htaccess added for you.
  • Media files are found with $this->api()->media()->path($id), which refuses paths outside the uploads folder.
  • Never execute uploaded content: no include of 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 short timeout(). 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 (or http) 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() and adminPost() require a signed-in user with access_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_policy filter, 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:

NeedUse
Buttonsbh-btn with --primary (one per screen), --secondary, --danger, --ghost; --sm, --lg, --icon (with an aria-label)
Sectionsbh-card, bh-card__head, bh-card__title, bh-card__body
Fieldsbh-label with for=, bh-input, bh-select, bh-textarea, bh-help; aria-invalid="true" on errors
On/off settingsbh-switch (a real checkbox with role="switch"), bh-choices, bh-seg
Settings pagesThe 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.
Statusbh-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 yetbh-empty with an icon, a title, a sentence and one action
Confirm, alert, notifybhConfirm(), bhAlert(), bhToast(). Never confirm() or alert().
WaitingbhBusy() or bhLoader() with done()/fail(). Don't add a spinner of your own.
Server callsBasehim.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, white or 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 on tailwind.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().palette and redraw on Basehim.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 require or View::include(); don't depend on $this in 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 input schema. 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_limit that 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 AjaxException for 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, /content and /storage.
  • Prefer site() over the older get()/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() or verifyTimestamped(), 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 types and a real max_size. Add mimes to 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 with moveTo(), 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 hold upload_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 to appStorageDir() 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.deleting and 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_html filter 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 than html. 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 cache for expensive providers (scope global only for data that doesn't depend on who's asking), and leave refresh at 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, an id namespaced by your slug, and a capability if 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 => false unless 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.created run 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() and addFilter() hand a callback one argument unless you say otherwise: $this->addFilter('post.content', fn(string $html, array $post) => $html, 10, 2). Without the 2, 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. AjaxException messages, WidgetUnavailable messages and lastError() are for people; exception traces are not.
  • Check return values from the API. It degrades rather than throws, so 0 from create() means it didn't happen, and the reason is in your log.

Versions and compatibility

  • Set requires.basehim to 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.json is 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 version for every release that goes to a site, even a one-line fix: updates and onUpgrade() 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'tDo instead
A private schema(), table() or get() helper on your App classA 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 SQLBound parameters
Calling your own site over HTTP$this->api()
Files saved under assets/ or the web root$this->appStorageDir()
Paths built from request dataFixed names, IDs looked up through core
Reading .env, new PDO, eval, shell commandsThe API, config(), db()
Site settings for your app's own valuesgetSetting() / setSetting()
Read, change, write on a counterupdateOwn()
Sending mail inside a transactionSend after commit
One giant scheduled runSmall batches with a cursor
confirm(), alert(), your own spinner and CSSbhConfirm(), bhToast(), bhLoader(), bh-* components
Declaring some permissions and not othersAll of them, or none
Leaving tables and settings after uninstallDrop them in onUninstall()
Editing core filesA hook, a filter, or a feature request

Pre-release checklist

Copy this into your release process.

Manifest and package

  • ☐ slug matches the folder; namespace is yours, not App\.
  • ☐ version bumped; requires.basehim and requires.php are 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.json at 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() and site()).
  • ☐ 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

Discussion

Leave a comment