Add a "Was this helpful?" widget with AJAX
In this tutorial you'll build Helpful Votes. It adds a small "Was this helpful? Yes / No" box under every post. Readers answer without a page reload, and editors see the totals in the admin.
You'll learn how to:
- add markup to post content with a filter;
- create the app's own table;
- register a public AJAX action with input validation and a rate limit;
- call it from the browser with
Basehim.ajax; - open an admin screen to authors and editors.
You need: a Basehim site you can add files to, and a few published posts. If you haven't built an app before, do Build your first app first.
Step 1: The manifest
content/apps/helpful-votes/app.json:
{
"name": "Helpful Votes",
"slug": "helpful-votes",
"version": "1.0.0",
"description": "Adds a \"Was this helpful?\" question under every post and reports the answers.",
"developer": { "name": "Your Name", "url": "https://example.com" },
"namespace": "Tutorial\\HelpfulVotes",
"requires": { "php": ">=8.1", "basehim": ">=1.2.51" }
}
Step 2: A table for the votes
The app keeps one row per vote. Create the table when the app is activated. $this->table('votes') names it app_helpful_votes_votes, with the site's table prefix:
<?php
namespace Tutorial\HelpfulVotes;
use App\Core\Ajax\AjaxContext;
use App\Core\Ajax\AjaxException;
use App\Core\Request;
use App\Core\Response;
class App extends \App\Core\App
{
/** Create the votes table. Safe to run again: IF NOT EXISTS. */
public function onActivate(): void
{
$this->schema("CREATE TABLE IF NOT EXISTS `{$this->table('votes')}` (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
post_id BIGINT UNSIGNED NOT NULL,
helpful TINYINT(1) NOT NULL,
created_at DATETIME NOT NULL,
KEY post_id (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}
Step 3: Register everything in boot()
public function boot(): void
{
// The browser half: Basehim.ajax on public pages, plus our script and styles.
$this->useAjaxClient();
$this->addHead(fn() => '<link rel="stylesheet" href="' . htmlspecialchars($this->asset('helpful.css')) . '">');
$this->addFooter(fn() => '<script src="' . htmlspecialchars($this->asset('helpful.js')) . '" defer></script>');
// The question under every published post.
$this->addFilter('post.content', [$this, 'appendQuestion'], 20, 2);
// The server half: POST /ajax {"action": "helpful-votes.vote", ...}
$this->ajax('vote', [
'handler' => [$this, 'vote'],
'auth' => 'public',
'input' => [
'post_id' => 'required|int|min:1',
'helpful' => 'required|bool',
],
'rate_limit' => ['max' => 20, 'per' => 3600, 'by' => 'ip'],
]);
// A report for editors.
$this->addAdminMenu(['url' => '/admin/helpful-votes', 'label' => 'Helpful votes', 'icon' => 'hand-thumb-up']);
$this->adminGet('/helpful-votes', [$this, 'report']);
$this->addFilter('admin.area_policy', function (array $map) {
$map['/admin/helpful-votes'] = 'edit_posts'; // editors and authors may read it
return $map;
});
}
What each line does:
useAjaxClient()loadsBasehim.ajaxon public pages. The admin always has it; the public site loads it only when something asks.post.contentat priority 20 runs after core has rendered the post body (at priority 5). The2asks for the second argument, the post row.- The AJAX action is named
helpful-votes.vote: your slug, a dot, the name you gave it.'auth' => 'public'lets visitors call it without signing in.- The
inputschema accepts exactly two fields, of exactly these types. Anything else is refused with422. - The rate limit allows 20 votes per hour from one address. After that the answer is
429.
admin.area_policyopens the report to anyone withedit_posts. Without it, app screens need the app's access capability, which only administrators have by default.
Step 4: Add the question to posts
public function appendQuestion(string $html, ?array $post = null): string
{
if (!$post || ($post['type'] ?? '') !== 'post' || ($post['status'] ?? '') !== 'published') {
return $html;
}
return $html . $this->view('question', ['postId' => (int) $post['id']]);
}
post.content also runs for pages and previews, so the filter checks the type and status first. A filter must always return the value, even when it changes nothing.
views/question.php:
<aside class="hv-box" data-hv-post="<?= (int) $postId ?>">
<p class="hv-q">Was this helpful?</p>
<button type="button" class="hv-btn" data-hv-answer="1">Yes</button>
<button type="button" class="hv-btn" data-hv-answer="0">No</button>
<p class="hv-msg" role="status" aria-live="polite"></p>
</aside>
Step 5: Handle the vote
public function vote(AjaxContext $ctx): array
{
$post = $this->api()->posts()->find($ctx->input('post_id'));
if (!$post || $post['status'] !== 'published') {
throw AjaxException::notFound('That post is not available.');
}
$this->db()->insert($this->table('votes'), [
'post_id' => (int) $post['id'],
'helpful' => $ctx->input('helpful') ? 1 : 0,
'created_at' => bh_now(),
]);
return ['thanks' => true];
}
By the time this runs, the gateway has already checked the request's origin, the CSRF token, the rate limit and the input. post_id is guaranteed to be an integer of at least 1. What the gateway can't know is whether that post exists and is public, so the handler checks.
AjaxException::notFound() becomes a 404 with your message, which is safe to show to people. Any other exception becomes a 500 with no details, and the details go to the log.
Step 6: Call it from the browser
assets/helpful.js:
document.addEventListener('click', function (e) {
var button = e.target.closest('[data-hv-answer]');
if (!button) return;
var box = button.closest('[data-hv-post]');
var msg = box.querySelector('.hv-msg');
box.querySelectorAll('button').forEach(function (b) { b.disabled = true; });
Basehim.ajax.request('helpful-votes.vote', {
post_id: Number(box.dataset.hvPost),
helpful: button.dataset.hvAnswer === '1'
}).then(function () {
msg.textContent = 'Thanks for letting us know.';
}).catch(function (err) {
msg.textContent = err.message; // written for people; safe to show
box.querySelectorAll('button').forEach(function (b) { b.disabled = false; });
});
});
- Send real JSON types.
Number(...)and=== '1'produce an integer and a boolean. The schema is strict: the string"1"is not an int. - The client handles the CSRF token. Before its first POST it fetches one, so only visitors who actually vote get a session, and your pages stay cacheable.
- Use
textContentto show messages, neverinnerHTML.
assets/helpful.css:
.hv-box { margin: 2.5rem 0 0; padding: 1rem 1.25rem; border: 1px solid #e2e8f0; border-radius: .75rem; }
.hv-q { margin: 0 0 .5rem; font-weight: 600; }
.hv-btn { margin-right: .5rem; padding: .35rem .9rem; border: 1px solid #cbd5e1; border-radius: 999px; background: #fff; cursor: pointer; }
.hv-btn:disabled { opacity: .5; cursor: default; }
.hv-msg { margin: .5rem 0 0; color: #475569; font-size: .9em; }
Step 7: The report
public function report(Request $request): Response
{
return Response::html($this->adminView('report', 'Helpful votes', ['rows' => $this->totals(50)]));
}
/** Posts with the most votes first: id, title, yes, no. */
public function totals(int $limit): array
{
$votes = $this->table('votes');
$rows = $this->db()->select(
"SELECT p.id, p.title,
SUM(v.helpful = 1) AS yes,
SUM(v.helpful = 0) AS no
FROM `{$votes}` v
JOIN {posts} p ON p.id = v.post_id
GROUP BY p.id, p.title
ORDER BY COUNT(*) DESC
LIMIT " . max(1, min(500, $limit))
);
return array_map(fn(array $r) => [
'id' => (int) $r['id'], 'title' => $r['title'], 'yes' => (int) $r['yes'], 'no' => (int) $r['no'],
], $rows);
}
}
{posts} is expanded to the core posts table with the site's prefix. The limit is an integer the code clamps itself; it never comes from the request.
views/report.php:
<?php /** @var array $rows */ ?>
<section class="bh-card">
<div class="bh-card__head"><h2 class="bh-card__title">Most-voted posts</h2></div>
<?php if (!$rows): ?>
<div class="bh-empty">
<p class="bh-empty__title">No votes yet</p>
<p class="bh-empty__text">Answers appear here once readers start voting.</p>
</div>
<?php else: ?>
<table class="w-full text-sm">
<thead><tr><th class="text-left p-3">Post</th><th class="p-3">Yes</th><th class="p-3">No</th></tr></thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr class="border-t">
<td class="p-3"><?= htmlspecialchars($r['title']) ?></td>
<td class="p-3 text-center"><?= (int) $r['yes'] ?></td>
<td class="p-3 text-center"><?= (int) $r['no'] ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
Step 8: Try it
- Activate Helpful Votes under Apps. Activation runs
onActivate()and creates the table. - Open a post on the public site. The box appears under the content. Click Yes. The buttons grey out and "Thanks for letting us know." appears.

- Open Helpful votes in the admin to see the count.

Then check what the gateway refuses, from your browser's console on that page:
Basehim.ajax.request('helpful-votes.vote', { post_id: 1, helpful: 'yes' }).catch(e => console.log(e.code, e.fields));
// VALIDATION_FAILED { helpful: ['Must be true or false.'] }
Basehim.ajax.request('helpful-votes.vote', { post_id: 1, helpful: true, author_id: 1 }).catch(e => console.log(e.code, e.fields));
// VALIDATION_FAILED { author_id: ['This field is not accepted.'] }
Basehim.ajax.request('helpful-votes.vote', { post_id: 999999, helpful: true }).catch(e => console.log(e.code, e.message));
// NOT_FOUND That post is not available.
Vote more than 20 times in an hour and the answer becomes RATE_LIMITED, "Too many requests. Wait a moment and try again." A request without a CSRF token, for example from curl, gets CSRF_INVALID.
Ideas to extend it
- Remember that a visitor voted (in
localStorage) and hide the box for that post. - Add an optional comment field: declare it in the schema as
'string|nullable|max:500', and escape it when you show it in the report. - Let AI assistants read the totals: Give your app an MCP tool continues from here.
The reference for everything used here is AJAX actions.
Leave a comment