AJAX actions
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
| Option | Default | Meaning |
|---|---|---|
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 |
area | none | An 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_unknown | false | Tolerate undeclared top-level fields (they are dropped) |
rate_limit | 120/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_body | 64 KB | Bytes, up to 1 MB |
idempotent | false | Require an Idempotency-Key header and run once per key |
csrf | true | Only 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(needsitems),object(needsfields). 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_idorroleinto 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 method | Returns |
|---|---|
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": "…"}
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST, MALFORMED_JSON, IDEMPOTENCY_KEY_REQUIRED | Bad body, or a missing idempotency key |
| 401 | AUTH_REQUIRED | Not signed in, or the session ended |
| 401 | UNSUPPORTED_AUTH | A bearer token was sent (use /api/v1 for tokens) |
| 403 | CSRF_INVALID, ORIGIN_REJECTED, FORBIDDEN | Token, origin or capability |
| 404 | ACTION_NOT_FOUND | Unknown action, or its app is inactive |
| 405 | METHOD_NOT_ALLOWED | e.g. GET on a POST-only action |
| 409 | DUPLICATE_REQUEST | An idempotency key that was already used |
| 413, 415 | PAYLOAD_TOO_LARGE, UNSUPPORTED_MEDIA_TYPE | Body too big, or not JSON |
| 422 | VALIDATION_FAILED | Input doesn't match the schema; see error.fields |
| 429 | RATE_LIMITED | Too many requests; see Retry-After |
| 500, 503 | INTERNAL_ERROR, SERVICE_UNAVAILABLE | A 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.
Leave a comment