Skip to content

AJAX actions

7 min read

Basehim has one endpoint for browser AJAX: POST /ajax. A page names a registered action and sends its data. Before your handler runs, core checks the session, the CSRF token, the user's capability, the rate limit and the input. Apps and core register actions the same way, so the security checks live in one place instead of in every hand-written endpoint.

/ajax is for this site's own pages, signed in with the browser session. Other programs use the REST API with tokens.

A complete example

In your app's boot():

$this->ajax('save-excerpt', [
    'handler'    => [$this, 'saveExcerpt'],
    'capability' => 'edit_posts',
    'input'      => [
        'post_id' => 'required|int|min:1',
        'excerpt' => 'required|string|max:300',
    ],
]);
// registers "my-app.save-excerpt" (your slug is the prefix)

The handler:

use App\Core\Ajax\AjaxContext;
use App\Core\Ajax\AjaxException;

public function saveExcerpt(AjaxContext $ctx): array
{
    $post = $this->api()->posts()->find($ctx->input('post_id'));
    if (!$post) {
        throw AjaxException::notFound('That post no longer exists.');
    }
    // The capability says "may edit posts". Whether this user may edit THIS post is your check.
    if ((int) $post['author_id'] !== $ctx->userId() && !$ctx->can('edit_others_posts')) {
        throw AjaxException::forbidden('You can only edit your own posts.');
    }
    $this->api()->posts()->update((int) $post['id'], ['excerpt' => $ctx->input('excerpt')]);
    return ['saved' => true];        // becomes "data" in the response
}

In the browser (the client is loaded on every admin page):

Basehim.ajax.request('my-app.save-excerpt', { post_id: 12, excerpt: textarea.value })
  .then(function (data) { bhToast('Saved'); })
  .catch(function (err) {
    if (err.code === 'VALIDATION_FAILED') console.log(err.fields);   // { excerpt: ['Must be at most 300 characters.'] }
    bhToast(err.message, 'error');                                     // written for people; safe to show
  });

Register actions in boot(). That way they exist only while your app is active, and a deactivated app's actions return 404 on the next request. Calling ajax() anywhere else is refused.

Options

OptionDefaultMeaning
handler(required)A closure, [$this, 'method'] or [SomeClass::class, 'method']
capability(required for signed-in actions)A capability, or a list (all required). 'read' means any signed-in user
areanoneAn admin path such as '/admin/media'. The user must also be allowed into that area
auth'user''public' for visitors who aren't signed in
methods['POST']Add 'GET' for read-only actions
input[]The schema. Fields not declared are refused
allow_unknownfalseTolerate undeclared top-level fields (they are dropped)
rate_limit120/min per user; public 30/min per IP['max' => 10, 'per' => 60, 'by' => 'ip']. by: user, ip or session; fail: open or closed; or false to turn the action's own limit off (signed-in actions only)
max_body64 KBBytes, up to 1 MB
idempotentfalseRequire an Idempotency-Key header and run once per key
csrftrueOnly public actions may turn it off

A typo in an option (capabilty, requred) is refused when you register, and logged to your app's log. It doesn't fail silently later.

Input schemas

'input' => [
    'id'      => 'required|int|min:1',
    'title'   => 'string|max:255',
    'status'  => 'required|string|in:draft,published',
    'email'   => 'string|email|max:190',
    'website' => 'string|url',                 // http and https only
    'score'   => 'number|min:0|max:5',
    'notify'  => 'bool',
    'note'    => 'string|nullable|max:500',
    'tags'    => ['type' => 'array', 'max' => 20, 'items' => 'string|max:50'],
    'seo'     => ['type' => 'object', 'fields' => ['title' => 'string|max:70']],
    'slug'    => ['type' => 'string', 'regex' => '/^[a-z0-9-]+$/'],
],
  • Types: string, int, number, bool, array (needs items), object (needs fields). JSON types are strict: "5" is not an int and "true" is not a bool.
  • Modifiers: required, nullable, min/max (characters, items or value), in:a,b, email, url, regex (array form only).
  • Unknown fields are refused at every level, so a client can't slip author_id or role into a save.
  • Optional fields that weren't sent are absent from $ctx->all(), which makes partial updates easy.

Validation is about shape. A valid string can still contain <script>, so escape it when you print it, and use bound parameters in SQL.

The handler's context

AjaxContext methodReturns
input($key, $default), all(), has($key)Validated input only
user(), userId()The signed-in user (never from the request), or null
can($cap), authorize($cap)Capability checks; authorize() throws FORBIDDEN
requestId(), ip()For logging
log($message, $context, $level)Your app's log, tagged with the request ID

Return an array, a scalar, null or a JsonSerializable. Don't echo anything: output is discarded and logged.

Errors

Throw AjaxException for answers the user should see:

throw AjaxException::notFound('That post no longer exists.');
throw AjaxException::forbidden();
throw AjaxException::invalid('Pick a date in the future.', ['date' => ['Must be in the future.']]);
throw AjaxException::conflict('Someone else changed this post. Reload it.');
throw new AjaxException('QUOTA_EXCEEDED', 'You have used this month’s quota.', 409);

Anything else that's thrown becomes 500 INTERNAL_ERROR with a request ID. The details go to the log, never to the browser.

A response looks like this:

{"success": true, "data": {"saved": true}, "request_id": "9c2e4f0a1b7d3e65"}
{"success": false, "error": {"code": "FORBIDDEN", "message": "You do not have permission to perform this action."}, "request_id": "…"}
StatusCodeWhen
400BAD_REQUEST, MALFORMED_JSON, IDEMPOTENCY_KEY_REQUIREDBad body, or a missing idempotency key
401AUTH_REQUIREDNot signed in, or the session ended
401UNSUPPORTED_AUTHA bearer token was sent (use /api/v1 for tokens)
403CSRF_INVALID, ORIGIN_REJECTED, FORBIDDENToken, origin or capability
404ACTION_NOT_FOUNDUnknown action, or its app is inactive
405METHOD_NOT_ALLOWEDe.g. GET on a POST-only action
409DUPLICATE_REQUESTAn idempotency key that was already used
413, 415PAYLOAD_TOO_LARGE, UNSUPPORTED_MEDIA_TYPEBody too big, or not JSON
422VALIDATION_FAILEDInput doesn't match the schema; see error.fields
429RATE_LIMITEDToo many requests; see Retry-After
500, 503INTERNAL_ERROR, SERVICE_UNAVAILABLEA bug, or the rate-limit store is down

The JavaScript client

Basehim.ajax.request(action, data, options)   // → Promise resolving to data
Basehim.ajax.get(action, data)                // GET, for read-only actions
Basehim.ajax.post(action, data)
Basehim.ajax.newIdempotencyKey()

Options: timeout in ms (default 30000), signal (an AbortSignal), idempotencyKey (true to generate one, or your own string). A rejection is an AjaxError with code, message, status, fields and requestId. The client adds its own codes: NETWORK_ERROR, TIMEOUT, ABORTED and BAD_RESPONSE.

The client handles the CSRF token: it sends it in a header, and if a tab has gone stale it fetches a fresh one and retries once.

Public actions

For visitors who aren't signed in, such as a newsletter form or a vote button:

$this->ajax('subscribe', [
    'handler'    => [$this, 'subscribe'],
    'auth'       => 'public',
    'input'      => ['email' => 'required|string|email|max:190'],
    'rate_limit' => ['max' => 5, 'per' => 3600, 'by' => 'ip'],
]);
$this->useAjaxClient();   // load Basehim.ajax on public pages

Public actions still check a CSRF token by default. The client fetches one just before its first POST, so only visitors who actually use the feature get a session, and cached pages stay cacheable. A theme can also load the client with bh_ajax_client() before bh_head().

'csrf' => false is allowed only on public actions. The action then runs as an anonymous visitor even for signed-in users, so a forged cross-site request can't borrow anyone's session. That suits a view counter, never anything that should know who is asking.

Give public actions the tightest rate limit you can live with. The helpful-votes tutorial builds one end to end.

Idempotent actions

With 'idempotent' => true, a request must carry an Idempotency-Key header. A repeat of the same key within 24 hours gets 409 DUPLICATE_REQUEST, and the handler doesn't run again. Use it wherever a double click would hurt: publishing, payments, sending mail.

Basehim.ajax.request('my-app.send-invite', { email: email }, { idempotencyKey: true });

What stays your job

The gateway enforces what the action declares. It can't:

  • decide whether this user may touch this record (ownership, workflow);
  • make a GET handler read-only;
  • escape your output;
  • make concatenated SQL safe;
  • accept uploads (JSON only; use the media endpoints).

Every action must be safe to call by hand with any input its schema allows. Hiding a button is not a permission check.

Configuration

.env keys AJAX_MAX_BODY, AJAX_RATE_LIMIT, AJAX_RATE_LIMIT_FAIL and TRUSTED_PROXIES are described in Configuration. The full list of actions on a running site, core's and every active app's, is under API → Reference → AJAX actions.

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment