Technical Oct 2, 2026 · 6 min read

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.

Eduardo Lázaro
Eduardo Lázaro
Founder of ToxicFilter
Moderating Comments in Laravel with Laratox

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, and policy(), project() and locale() are there when you need them.
  • If the API cannot answer, the rule lets the field through (laratox.rule.on_error is allow by default, and logs it) and there is no verdict, so the controller holds the comment. Set it to refuse to 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.

Put it in front of your real traffic

Allow, review or block, and the reason in words. On the free plan: 2,000 credits a month, no card. A check costs 1 credit, about 8 if the model reads it, about 10 for an image.