"Honestly, anyone still recommending the Seiko 5 in 2026 clearly has no idea what they are talking about." That is a rude reply on a watch forum, but it attacks an opinion, not a person. I sent it to Jev, TypeSafe's typed decision model, and asked which of six moderation categories it belonged to. The answer: fine at 0.55, harassment at 0.44.
A moderation API that returns one label would call that post fine and move on. A human moderator would want to see it. That gap is why I built Jev Moderation for Laravel, a package that acts on the probability of every category rather than the winner, and keeps a full history of what Jev decided and what people did about it.
One label throws the useful part away
Jev does not write text. You give it some state and a typed question, and it answers with numbers. For moderation the right type is a Choice question: a fixed set of options, each with a description, and back comes the top choice, a probability for every option, and a confidence.
It is tempting to threshold on confidence. I nearly did. Then I read how it is calculated: it measures how sharp the distribution is, (p_max - 1/n) / (1 - 1/n), so it depends on how many options the question has and it ignores which category the doubt is about. A confidence of 0.5 means something different with three choices than with six, and it says nothing about whether the runner-up was harassment or off-topic.
The probabilities are what you actually want. "How likely is this to be harassment?" is a question you can set a policy on. So the package keeps confidence for one job only, flagging answers where Jev was genuinely undecided, and makes every other decision from the per-category probabilities.
Thresholds per category
A policy is one question, a set of choices, and a rule for each risky category. Each rule has two thresholds: a low review_above that sends content to a person, and an optional high block_above that removes it without one. Removing something is irreversible from the author's point of view, so that bar should be high. Asking a human to look is cheap, so that bar can be low.
'community' => [
'question' => 'Classify this user-generated forum content in `content`. Use `context`, if present, only to understand it.',
'choices' => [
'spam' => 'Advertising, link farming, scams or repeated promotional content',
'harassment' => 'Insults, threats, intimidation or targeted abuse of a person',
'hate' => 'Attacks or slurs against people for a protected characteristic',
'personal_data' => "Someone's home address, phone number, email address or other private information",
'off_topic' => 'Unrelated to the discussion it was posted in',
'fine' => 'Acceptable content that breaks none of these rules, including disagreement and criticism of ideas',
],
'safe_choice' => 'fine',
'actions' => [
'spam' => ['review_above' => 0.50, 'block_above' => 0.90],
'harassment' => ['review_above' => 0.40, 'block_above' => 0.95],
'hate' => ['review_above' => 0.30, 'block_above' => 0.90],
'personal_data' => ['review_above' => 0.40],
'off_topic' => ['review_above' => 0.80],
],
'uncertain_below' => 0.30,
],
The resolver checks every category that has a rule, not just Jev's top choice. The strongest action wins, so a block on one category beats a flag on another, and a probability equal to a threshold counts as reaching it:
foreach ($policy->rules as $category => $rule) {
$probability = $classification->probabilityOf($category);
$candidate = match (true) {
$rule->blockAbove !== null && $probability >= $rule->blockAbove => new Verdict(ModerationStatus::Blocked, VerdictReason::BlockThreshold, $category, $probability),
$probability >= $rule->reviewAbove => new Verdict(ModerationStatus::Flagged, VerdictReason::ReviewThreshold, $category, $probability),
default => null,
};
if ($candidate !== null && ($verdict === null || self::outranks($candidate, $verdict))) {
$verdict = $candidate;
}
}
Run the Seiko reply through that and harassment at 0.44 crosses 0.40, so it is flagged for a human even though fine came top. The package ships three presets: community for forums, chat with higher thresholds and no confidence floor so a busy queue stays usable, and strict for platforms for young people, where self_harm always goes to a person and is never removed automatically. Someone in distress needs a human, not a deleted post.
Descriptions are the main lever
Jev reads the choice descriptions, and they matter more than the numbers. When I tested the presets against live Jev, friendly gaming banter was flagged as harassment and "did you even read the question?" was blocked on the strict preset. Moving a threshold to fit one sample would have been overfitting. Adding "friendly banter and trash talk" and "corrections and blunt feedback" to the fine descriptions fixed both. The Seiko reply moved too: against the preset's fuller fine description, "including disagreement and criticism of ideas", it came back as fine at 0.86 rather than 0.55.
Moderate the model, not the request
The obvious place to moderate in Laravel is middleware. It is also the wrong one: middleware only sees HTTP requests, and content arrives from queued jobs, API imports, Livewire components and seeders too. The package hooks into Eloquent's model events instead, so anything saved through the model is checked:
use Phoenix1331\JevModeration\Attributes\Moderate;
use Phoenix1331\JevModeration\Concerns\Moderatable;
#[Moderate(fields: ['title', 'body'], policy: 'community')]
class ForumPost extends Model
{
use Moderatable;
public function moderationContext(): ?string
{
return "Thread: {$this->thread}";
}
}
A save records a pending decision and queues the check with afterCommit(), so a rolled-back transaction never reaches Jev. The job carries the decision id, not the content, and rebuilds the content from the model when it runs. If the content has changed since, or a newer decision exists, it stands down. A sha256 of the moderated fields means an update that leaves them alone is never sent again.
moderationContext() is sent alongside the content, labelled so the question can say "use context only to understand it". For a forum that is the thread title; for chat it is the previous couple of messages. It is not part of the hash, so renaming a thread does not re-check every reply in it.
The catch with model events
Model events have a hole: anything that skips them skips moderation. saveQuietly(), raw inserts and seeders do. I found that out building the demo app, when nothing I seeded was moderated. Laravel's default DatabaseSeeder uses the WithoutModelEvents trait, which silently switches off every listener for every seeder it calls. The README warns about it now, and jev-moderation:rescan brings existing content under moderation afterwards.
An append-only history
Every check, failure, user report and moderator review adds a row to one polymorphic table. Nothing is ever updated: a custom Eloquent builder throws if anything tries, and that includes mass updates that skip model events. A post's history reads like this:
$post->flagForReview('reported by user', $reporter);
$post->approve($moderator, 'criticism of a watch, not of a person');
$post->moderationDecisions->pluck('status');
// pending, flagged, approved
ForumPost::query()->get(); // visible content only
ForumPost::onlyFlagged()->get(); // the review queue
Keeping Jev's decision next to the moderator's is the point. Over time it tells you how often people overturn the model, per category and per Jev version, which is exactly the data you need to tune thresholds with evidence rather than guesses. Each decision also stores the versioned model that answered, because jev-latest is an alias that moves when TypeSafe ships a release. While I was building this, TypeSafe's docs said it pointed at 1.13, and every answer I got came from 1.14.
Visibility comes from the latest decision. In post-moderation content shows straight away and disappears once blocked or rejected. In pre-moderation it stays hidden until it passes or someone approves it. When a check gives up after its retries, a separate fail setting decides whether that content shows. Defaulting to open means an outage never hides your whole forum.
What live Jev taught me
The package has a small accuracy suite, 53 synthetic labelled posts run against live Jev with each preset. It is a sanity check rather than a benchmark, but it surfaced things the unit tests never could:
- Jev is decisive. Clear-cut abuse comes back at 0.97 to 1.00, so most posts you might want a human to see clear
block_aboveand are removed outright. If every removal needs a person, setblock_aboveto 1.0 - after tuning the descriptions, nothing harmful got through and nothing was wrongly blocked, across all three presets
- a missing API key returns 403, not the documented 401, so map errors by status, never by body shape
- retry rate limits and overload in the queue job, not in the HTTP client, or a worker sits blocked in backoff
- pin a versioned model once your thresholds are tuned, and record which version made each decision
Summary
A label tells you what a model thought was most likely. A probability per category tells you how worried to be about each risk, and that is what a moderation policy is really made of. Put a low bar on "show a person" and a high bar on "remove it", check every category rather than the winner, hook into the model rather than the request, and never overwrite a decision. Then you can see exactly why each post is where it is, and you have the data to make the thresholds better.
The package is on GitHub with documentation, including an interactive version of the threshold check above. It runs on Laravel 12 and 13 with a fake driver, so your tests never call Jev. A Filament review queue is next. If you are moderating user content in a Laravel app and want a second opinion on the design, get in touch via the contact section.