Laratox
#Laratox
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, instead of TOXICFILTER_PROJECT ->locale('es') // the language it should be in ->surface('listing') // where it appears ->reference("listing_{$listing->id}") ->check();
Also ->actor(), ->withoutAi(), ->rules([...]), ->redact() and ->option($key, $value) for anything else.
If your 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.
Every kind of content:
ToxicFilter::text($body)->check(); ToxicFilter::name($user->username)->check(); ToxicFilter::email($request->email)->check(); ToxicFilter::url($request->website)->check(); ToxicFilter::image($photo->url)->check(); ToxicFilter::imageData($upload->get())->check(); ToxicFilter::prompt($question)->check(); ToxicFilter::conversation($messages)->check(); ToxicFilter::signup(['name' => $name, 'email' => $email, 'bio' => $bio])->check();
The verdict is the PHP SDK's: allowed(), needsReview(), blocked(), reason() (the first one), reasons() (all of them), scores(), flagged(), id(), project()… The rest of the API is there too: ToxicFilter::batch(), records(), resolve(), feedback(), usage(), ping(), and ToxicFilter::client() gives you the SDK client itself.
#When the API cannot answer
check() throws the SDK's exceptions. Catch the ones you want to handle:
use ToxicFilter\Exception\ApiError; use ToxicFilter\Exception\QuotaExhausted; try { $verdict = ToxicFilter::text($comment->body)->check(); } catch (QuotaExhausted $e) { // Out of credits: $e->remaining(), $e->required(), $e->renewsAt(). } catch (ApiError $e) { // Down, wrong key, or a rejected field: $e->status, $e->getMessage(). }
| Exception | When |
|---|---|
AuthenticationError |
The key is wrong |
QuotaExhausted |
Not enough credits for this call |
InvalidRequest |
A field was rejected: $e->fields() |
RateLimited |
Too many calls, after the retries |
ServerError |
The service is down or unreachable, after the retries |
All of them extend ToxicFilter\Exception\ApiError. The validation rule below catches them for you.
#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 ToxicFilter::text(). A block fails the field; a
review passes, and ->orReview() fails it too. Put the rule last, after bail, since it
is a network call.
The message carries ToxicFilter's reason: "The body could not be accepted: contains a phone
number." Reasons are written in English, so by default (rule.show_reason = auto) they are
only shown when your app's locale is English; other languages get the message without one.
To act on the decision afterwards, keep the rule:
$rule = Moderated::text(); $request->validate(['body' => ['required', $rule]]); $comment->hidden = $rule->verdict()?->needsReview() ?? false;
With a wildcard ('tags.*' => [$rule]), ask for each field: $rule->verdict('tags.0').
If the API cannot answer, laratox.rule.on_error decides: allow (default) lets the field
through and logs a warning, refuse fails it.
#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);
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 with an InvalidRequest.
#Configuration
php artisan vendor:publish --tag=laratox-config
| Key | Env | Default |
|---|---|---|
key |
TOXICFILTER_KEY |
|
project |
TOXICFILTER_PROJECT |
your organization's default project |
url |
TOXICFILTER_URL |
https://toxicfilter.com |
timeout |
TOXICFILTER_TIMEOUT |
10 |
connect_timeout |
TOXICFILTER_CONNECT_TIMEOUT |
5 |
retries |
TOXICFILTER_RETRIES |
2 |
rule.on_error |
TOXICFILTER_ON_ERROR |
allow |
rule.show_reason |
auto (true, false, or only when the locale is English) |
Calls go through Laravel's HTTP client, so Http::fake() and your logging see them. A 429 or 5xx is retried with the same idempotency key, so it is judged and billed once; a 402 is never retried.
#Sponsors
Laratox is supported by the following sponsors. Thank you for keeping it growing:
#Author
Created by Edu Lazaro
#License
Laratox is open-sourced software licensed under the MIT license.