Laratox

Laratox is content moderation for Laravel, powered by ToxicFilter. Every check returns one of three decisions (allow, review, block), the categories that fired (spam, scams, harassment, personal data, fifteen in all) and the reason in words.
Installation
Requires PHP 8.2+ and Laravel 12 or 13.
composer require edulazaro/laratox
Create a key in your ToxicFilter account (the free plan is enough) and add it to .env:
TOXICFILTER_KEY=tf_test_...
A tf_test_ key does the same work and is never charged. Check it with:
php artisan laratox:ping
Checking content
Say what it is, then check():
use ToxicFilter; $verdict = ToxicFilter::text($comment->body)->check(); if ($verdict->blocked()) { return back()->withErrors(['body' => $verdict->reason()]); } $comment->hidden = $verdict->needsReview();
Options chain before check():
ToxicFilter::text($listing->description) ->policy('marketplace') // one of your policies, instead of the default ->project('shop') // one of your projects ->locale('es') // the language it should be in ->surface('listing') // where it appears ->effort('high') // low, medium or high: when the model reads it ->reference("listing_{$listing->id}") ->restriction('removal') // what you will do if it is refused, for the statement ->check();
Every kind of content has its own method: text(), name(), email(), url(), image(), imageData(), prompt(), conversation() and signup(). The verdict is the PHP SDK's: allowed(), needsReview(), blocked(), reason(), reasons(), scores(), flagged(), project(), and effort(), modelRead() and modelWhy() for whether a model read it.
effort decides when the model reads: low never (instant checks only, one credit), medium only what the instant checks left in doubt, high everything not already refused on hard evidence. Without it, text and conversations are medium, images and prompts high, and names, emails, signups and links low; a policy on ToxicFilter can set its own.
In a form
use EduLazaro\Laratox\Rules\Moderated; $request->validate([ 'body' => ['bail', 'required', 'string', Moderated::text()->policy('comments')], 'website' => ['bail', 'nullable', 'url', Moderated::url()], ]);
The rule chains the same options as the facade. A block fails the field; a review passes, and ->orReview() fails it too. Put it last, after bail, since it is a network call. If the API cannot answer, laratox.rule.on_error decides: allow lets the field through and logs a warning, refuse fails it.
Statements of reasons and appeals
When the project writes statements of reasons (a switch in the ToxicFilter panel), a refused verdict brings the explanation the Digital Services Act says you owe its author, already written in their language. Send it as it is:
if ($verdict->blocked() && $text = $verdict->statementText()) { $comment->author->notify(new ContentRemoved($text)); }
The rest of that flow is on the facade: ToxicFilter::statement($id, 'es') rebuilds the statement months later, ToxicFilter::appeal($id, $reason) files the author's complaint, ToxicFilter::resolveAppeal($id, 'reversed', $moderator, $why) decides it and returns the reasoned decision in appealDecision(), and ToxicFilter::transparency($since, $until) hands back a period in the European Commission's format. If your site does something other than remove the content, say so with ->restriction(): one measure, or one plus account_suspended.
Projects
If one ToxicFilter organization moderates several sites, give each app its project once in .env:
TOXICFILTER_PROJECT=forum
Every check and every ToxicFilter::batch() is filed there, with its own activity, review queue and webhooks. ->project() overrides it for one call.
Testing
$fake = ToxicFilter::fake(); // everything allowed $fake->shouldBlock('spam', 'Contains a referral link'); // block everything $fake->shouldReview('toxicity', when: fn ($request) => // or only some requests str_contains($request['body']['content'] ?? '', 'idiot')); $fake->assertSent(fn ($request) => $request['path'] === '/api/v1/text'); $fake->assertSentCount(1);
$fake->withStatements() makes refusals carry a statement, and the fake answers the statement, appeal and transparency calls, so the code that notifies the author can be tested without a network.
The fake replaces only the network: the SDK's client and verdicts are the real ones. Like the API, it refuses more than ten locales, so a test fails where production would.
built and maintained by Edu Lazaro · MIT license