Moderating Comments in Laravel with Laratox
A validation rule, a facade and a fake: build a comment wall in Laravel that publishes, holds or refuses each comment, and publishes held ones when a person approves them.
A comment box is the first place spam and abuse arrive on a site, and the last place anybody wants to read every entry by hand. This post builds a small comment wall in Laravel, with Laratox and moderates it with ToxicFilter: every comment is checked before it is shown, and one of three things happens.
- Allow: it is published at once. That is nearly all of them.
- Review: it is held, and a person decides in your ToxicFilter dashboard. A signed webhook tells the app, which publishes it or drops it.
- Block: it is refused, and the author is told why, in words.
Three outcomes and not two on purpose. Forced to choose between publishing and deleting, a strict threshold deletes real comments and a lenient one publishes the abuse; the middle outcome is where the uncertain cases wait for a person. The whole app is in toxicfilter/examples/laravel.
This is one of four posts building the same app with a different client: plain PHP, Flask and Express. The code of all four is in toxicfilter/examples.
Laratox wraps the PHP SDK for Laravel: a validation rule, a facade that reads as a sentence, and a fake for your tests. With it, moderating a comment is one more rule in the request you already validate.
Install and configure
composer require edulazaro/laratox
Create a key in your dashboard; the free plan is enough. A
tf_test_ key costs nothing and runs every free check, which is what settles most
comments, but it never asks the model: use a live key to see what the model adds. The key is a
credential for the whole account, so it lives in the environment, on the server, and never in
a page.
TOXICFILTER_KEY=tf_test_...
TOXICFILTER_WEBHOOK_SECRET=whsec_...
Check the key is good before writing any code:
php artisan laratox:ping
Moderating a comment in validation
$id = $this->comments->nextId();
$rule = Moderated::text()->surface('comment')->reference("comment_{$id}");
$data = $request->validate([
'name' => ['required', 'string', 'max:60'],
'body' => ['bail', 'required', 'string', 'max:2000', $rule],
]);
// No verdict means the API could not answer and the rule let the field through
// (`laratox.rule.on_error` is `allow` by default). Hold it rather than publish it unread.
$verdict = $rule->verdict();
$held = $verdict === null || $verdict->needsReview();
$this->comments->add($id, $data['name'], $data['body'], $held ? 'held' : 'published', $verdict?->id());
return redirect()->route('wall')->with('held', $held);
- A block fails the field like any other rule, with ToxicFilter's reason in
the message: "The body could not be accepted: contains a referral link." The form shows it
with
@error('body'), nothing new to learn. - A review passes validation, and the rule keeps the verdict, so the
controller asks
$rule->verdict()afterwards and holds the comment.->orReview()would fail the field instead. - The rule chains the same options as the facade:
surface()says where the text appears,reference()carries the app's own id so the webhook can find the comment later, andpolicy(),project()andlocale()are there when you need them. - If the API cannot answer, the rule lets the field through (
laratox.rule.on_errorisallowby default, and logs it) and there is no verdict, so the controller holds the comment. Set it torefuseto fail the field instead. - Put the rule last, after
bail: it is a network call, and a field that already failed a cheaper rule does not need it.
Outside a form, the facade says the same thing in one line:
$verdict = ToxicFilter::text($comment->body)->surface('comment')->check();
if ($verdict->blocked()) {
return back()->withErrors(['body' => $verdict->reason()]);
}
When a person decides: the webhook
A held comment waits in your review queue. When somebody approves or rejects it, ToxicFilter
sends moderation.resolved, signed. The handler uses the SDK's verifier, which
Laratox installs:
$event = Webhooks::event(
$request->getContent(), // the RAW body
(string) $request->header('X-ToxicFilter-Signature', ''),
(string) config('services.toxicfilter.webhook_secret'),
);
if ($event === null) {
return response('', 400);
}
if ($event['event'] === 'moderation.resolved' && preg_match('/^comment_(\d+)$/', $event['data']['reference'] ?? '', $m)) {
$event['data']['action'] === 'approved'
? $comments->publish((int) $m[1])
: $comments->remove((int) $m[1]);
}
return response('', 204);
The route is excluded from CSRF in bootstrap/app.php, since ToxicFilter cannot send
a token; the signature is what authenticates it. $request->getContent() is the raw
body, which is what was signed.
To try it on your machine, expose the app with a tunnel (cloudflared tunnel --url
http://localhost:8000, for instance), add the tunnel's address followed by
/webhooks/toxicfilter as an endpoint in Webhooks, and put the
signing secret it shows you in TOXICFILTER_WEBHOOK_SECRET. Then post a comment that
lands in review, approve it from the review queue, and watch it appear.
Two details make the handler correct rather than merely working. The signature is checked over
the raw body, because a body parsed and encoded again is a different string
and would never verify. And the comment is found by its reference, the id the app
sent with the check, because the webhook carries the verdict and never the comment: ToxicFilter
does not keep what it moderates.
Testing without calling the API
ToxicFilter::fake() replaces only the network: the SDK's client and its verdicts
are the real ones, so a test exercises the same code production runs. Tell it what to answer
and assert on what was sent:
public function test_an_allowed_comment_is_published(): void
{
$fake = ToxicFilter::fake();
$this->post('/comments', ['name' => 'Ana', 'body' => 'Lovely post, thanks.'])->assertRedirect('/');
$this->get('/')->assertSee('Lovely post, thanks.');
$fake->assertSent(fn ($request) => $request['body']['reference'] === 'comment_1');
}
public function test_a_comment_in_review_is_held(): void
{
ToxicFilter::fake()->shouldReview('toxicity', 'Contempt aimed at the reader.');
$this->post('/comments', ['name' => 'Bo', 'body' => 'You clearly know nothing.'])
->assertRedirect('/')
->assertSessionHas('held', true);
$this->get('/')->assertDontSee('You clearly know nothing.');
$this->assertSame('held', app(Comments::class)->find(1)['status']);
}
The demo's suite covers every path this way, the signed webhook included, and runs in a fraction of a second with no key.
When the API cannot answer
Outside the rule, check() throws the SDK's exceptions, all extending
ApiError: QuotaExhausted when the account is out of credits (never
retried), RateLimited and ServerError after the retries,
InvalidRequest with the fields that were rejected. Calls go through Laravel's HTTP
client, so Http::fake() and your logging see them, and a retry carries the same
idempotency key, so it is judged and billed once.
Where to go from here
- Your own rules: a policy moves the thresholds per category, adds your own banned words, or measures subjects like gambling or crypto that are not harmful but may not belong on your site. Name it in the call.
- Several sites in one account: give each one a project, with its own activity, review queue and webhooks.
- More than text: the same client checks images, usernames, whole signups and conversations, where a pile-on or an approach that no single message shows becomes visible.
Keep reading
Moderating Comments in Express with the JavaScript SDK
Build a comment wall in Express that publishes, holds or refuses each comment with its reason, keeps the key o...
Moderating Comments in Flask with the Python SDK
Build a comment wall in Flask that publishes, holds or refuses each comment with its reason, verifies the revi...
Moderating Comments in Plain PHP with the ToxicFilter SDK
No framework: a comment wall in plain PHP that publishes, holds or refuses each comment, tells the author why,...