The App API: content, cache, mail, HTTP and scheduling
$this->api() is an app's single doorway into core. Every call runs in-process, in the same request, so there's no HTTP round trip to your own site. It goes through the same services the admin uses, so hooks fire, caches are cleared and HTML is filtered exactly as when a person does the same thing.
$id = $this->api()->posts()->create(['title' => 'Hello', 'status' => 'draft']);
One shape for every resource
Content-like resources share the same methods, so learning one teaches you all:
| Method | Returns |
|---|---|
find($id) | one row, or null |
all($filters, $limit = 500) | a list of rows (follows pagination for you, capped) |
paginate($filters, $page, $perPage) | ['data' => [...], 'meta' => ['page', 'per_page', 'total', 'last_page']] |
create($data) | the new id, or 0 |
update($id, $data) | true or false |
delete($id) | true or false |
Nothing throws. Bad input, a missing permission or a database error are logged against your app (see Apps → Logs), and the call returns null, false, 0 or an empty array. Check return values:
$id = $this->api()->posts()->create($data);
if ($id === 0) {
$this->log('Could not create the post', ['title' => $data['title'] ?? ''], 'warning');
return;
}
Resources
$this->api()->posts() // posts
$this->api()->pages() // pages
$this->api()->content('event') // any content type an app registered
$this->api()->media()
$this->api()->users()
$this->api()->comments()
$this->api()->terms() // categories, tags, any taxonomy
$this->api()->menus()
$this->api()->settings() // SITE-wide settings (careful)
$this->api()->cache() // namespaced to your app
$this->api()->mail()
$this->api()->http() // outbound requests
$this->api()->schedule() // also $this->schedule()
Posts, pages and content types
$posts = $this->api()->posts();
// Create. Fields: title, content, content_format (html|markdown|blocks), excerpt,
// status (draft|pending|published), slug, comment_status, featured_media_id, term_ids.
$id = $posts->create([
'title' => 'Release notes 2.4',
'content' => "## What changed\n\n- Faster search",
'content_format' => 'markdown',
'status' => 'draft',
], authorId: 1); // optional: defaults to the signed-in user, else user 1
$posts->update($id, ['excerpt' => 'Search is faster.']);
$posts->publish($id); // same as update($id, ['status' => 'published'])
$posts->draft($id);
$post = $posts->find($id); // null if it's not a post (type-checked)
$same = $posts->findBySlug('release-notes-2-4');
$latest = $posts->paginate(['status' => 'published'], page: 1, perPage: 5)['data'];
$found = $posts->search('export'); // ['data' => …, 'meta' => …]
$terms = $posts->terms($id); // its categories and tags
$inCat = $posts->byTerm($termId); // posts in a category or tag
$posts->delete($id); // to the trash
$posts->restore($id);
$posts->forceDelete($id); // permanent
paginate() filters include status, search and author_id. pages() and content('event') work the same way, bound to their type.
Note: When nobody is signed in (a scheduled task, a webhook), HTML you save is filtered as if an author wrote it: scripts, event handlers and unknown iframes are removed. If your app writes HTML it trusts, allow it with the
post.unfiltered_htmlfilter (see Hooks and filters).
Terms
$terms = $this->api()->terms();
$cat = $terms->firstOrCreate('category', 'Release notes'); // safe when two requests race
$this->api()->posts()->update($postId, ['term_ids' => [(int) $cat['id']]]);
$tags = $terms->inTaxonomy('tag');
$one = $terms->findBySlug('category', 'release-notes');
$id = $terms->create('tag', ['name' => 'Changelog', 'description' => '…']);
term_ids replaces a post's terms. Pass the categories and tags together.
Media
// A file your app generated or downloaded.
$item = $this->api()->media()->uploadFromPath('/tmp/chart.png', 'chart.png', null, [
'alt_text' => 'Revenue by month',
'caption' => 'Q3 report',
]);
// $item['id'], $item['url'] — the thumbnail, medium and large sizes are made too.
$this->api()->posts()->update($postId, ['featured_media_id' => $item['id']]);
$this->api()->media()->update($item['id'], ['alt_text' => 'Revenue, July to September']);
Users
$users = $this->api()->users();
$me = $users->current(); // signed-in user, or null
$ok = $users->currentCan('edit_others_posts');
$u = $users->findByEmail('ana@example.com'); // never includes the password hash
Comments
$comments = $this->api()->comments();
foreach ($comments->all(['status' => 'pending'], limit: 50) as $c) {
if (str_contains($c['content'], 'casino')) $comments->spam((int) $c['id']);
}
$comments->approve($id); // also unapprove(), spam(), trash(), delete()
// create() skips the spam guard, because app code is trusted.
// For input from visitors, run the guard first:
$verdict = $comments->guard($input);
Menus
$menu = $this->api()->menus()->findBySlug('main');
$items = $this->api()->menus()->items((int) $menu['id']);
$this->api()->menus()->addItem((int) $menu['id'], ['title' => 'Events', 'url' => '/events']);
Settings
Use your app's own settings for your app's data:
$this->setSetting('api_token', $token); // stored under app:{slug}
$token = $this->getSetting('api_token', '');
$this->api()->settings() reads and writes site-wide settings, such as general.timezone. Writes to the roles and updates groups, and to other apps' namespaces, are refused.
$tz = $this->api()->settings()->get('general', 'timezone', 'UTC');
Cache
Keys are namespaced to your app automatically. Values can be anything serializable.
$rates = $this->api()->cache()->remember('fx-rates', 3600, function () {
return $this->api()->http()->getJson('https://api.example.com/rates') ?? [];
});
$this->api()->cache()->set('last-sync', bh_now(), 86400);
$this->api()->cache()->delete('fx-rates');
$this->api()->cache()->flush(); // only your app's entries
Mail goes through the site's configured mailer (Settings → Email).
// The site's branded template: header, your heading and body, footer.
$this->api()->mail()->sendTemplate('ana@example.com', 'Your report is ready', 'Report ready',
'<p>The October report is attached to your dashboard.</p>');
// Or your own HTML.
$ok = $this->api()->mail()->send(['ana@example.com', 'li@example.com'], 'Subject', '<p>Body</p>',
['reply_to' => 'support@example.com']);
if (!$ok) $this->log('Mail failed: ' . $this->api()->mail()->lastError(), [], 'warning');
Outbound HTTP
http() wraps curl with short timeouts and never throws. Every method returns the same array:
['ok' => bool, 'status' => int, 'body' => string, 'headers' => array, 'error' => ?string]
$http = $this->api()->http()->timeout(10);
$res = $http->get('https://example.com/feed.xml');
if (!$res['ok']) {
$this->log('Feed unavailable', ['status' => $res['status'], 'error' => $res['error']], 'warning');
}
$data = $http->getJson('https://api.example.com/items', ['page' => 2]); // decoded array, or null
$out = $http->withToken($apiKey)->postJson('https://api.example.com/hooks', ['event' => 'published']);
Also: post() (JSON body), postForm(), put(), delete() and withHeaders([...]). Only http:// and https:// addresses are allowed, and redirects are followed up to 5 times. Calls need the http.outbound permission if your app declares permissions.
Scheduled tasks
Register tasks in boot(). It runs on every request, so the scheduler always knows what each task key means. Registering is cheap.
public function boot(): void
{
$this->schedule()->hourly('import', [$this, 'import']);
$this->schedule()->daily('cleanup', [$this, 'cleanup']);
$this->schedule()->everyMinutes(15, 'poll', [$this, 'poll']);
$this->schedule()->every(300, 'ping', [$this, 'ping']); // seconds; 60 is the minimum
}
public function import(): void
{
// No arguments, no output. Throwing marks the run as failed.
}
Also weekly(), forget($key), tasks() (your tasks with their last and next run) and runNow($key), which returns ['ok' => bool, …].
How tasks fire. After a page has been sent to a visitor, Basehim runs up to five due tasks, one sweep at a time. Nobody waits for them. The consequence: a site with no traffic runs nothing, so "hourly" means "hourly, when someone next visits". For punctual runs, set up the cron line from Installation.
Failures are logged and counted, and the next run is scheduled anyway, so a broken task doesn't retry on every page view. Handlers are never stored in the database: a task whose app is inactive is skipped.
Permissions
If your app declares permissions in app.json, each resource checks them: posts.read, posts.write, media.write, mail.send, http.outbound, schedule and so on. A denied call returns the empty result and logs the missing permission. See App permissions.
if ($this->allowed('mail.send')) {
$this->api()->mail()->send($to, $subject, $html);
}
Leave a comment