Skip to content

Add a "Was this helpful?" widget with AJAX

9 min read

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() loads Basehim.ajax on public pages. The admin always has it; the public site loads it only when something asks.
  • post.content at priority 20 runs after core has rendered the post body (at priority 5). The 2 asks 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 input schema accepts exactly two fields, of exactly these types. Anything else is refused with 422.
    • The rate limit allows 20 votes per hour from one address. After that the answer is 429.
  • admin.area_policy opens the report to anyone with edit_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 textContent to show messages, never innerHTML.

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

  1. Activate Helpful Votes under Apps. Activation runs onActivate() and creates the table.
  2. 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.
The Was this helpful box under a post after clicking Yes, showing Thanks for letting us know
1: the box is views/question.php, added to the content by the post.content filter. 2: the click calls Basehim.ajax.request(), which records the vote and swaps in the thank-you line.
  1. Open Helpful votes in the admin to see the count.
The Helpful votes report in the admin, with yes and no counts per post
1: the Helpful votes item from addAdminMenu(). 2: views/report.php lists each post with its yes and no totals.

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.

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment