Skip to content

The App API: content, cache, mail, HTTP and scheduling

7 min read

$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:

MethodReturns
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_html filter (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);
$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

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);
}

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment