Jev Moderation for Filament

A review queue that checks its own thresholds

Approve, reject and reclassify what Jev flagged, trace every decision back to the probabilities behind it, and see where your moderators keep disagreeing with your thresholds.

composer require phoenix1331/filament-jev-moderation
The review queue: stat tiles for items waiting, blocks today, median time to review and override rate, above a table of flagged forum posts and chat messages with approve, reject and reclassify buttons, author, category, confidence and time waiting.
The review queue on the seeded demo: forum posts and chat messages from two moderated models, most confident first.

Why a queue that measures itself

Jev Moderation for Laravel sends content to Jev and gets back a probability for every category, then your thresholds decide: pass, flag for a person, or block. The thresholds are a guess until people check Jev's work. This plugin is where they do it.

Every review is recorded through the core, next to the result it judged, so the plugin can pair them up. Group those pairs by how confident Jev was, and a band where moderators overturn most flags is a threshold set too low. The plugin says which threshold, by how much, and which command re-checks the affected content. Nothing changes your config for you: it is read-only advice, backed by your own moderators' decisions.

Review

One queue across every moderated model. Approve, reject or reclassify one record or a selection, with a note.

Trace

Each record's full history: every Jev result with its probabilities, every report, review and rescan.

Calibrate

Override rates per confidence band, with the threshold change and rescan command each problem band suggests.

Quick start

Set up Jev Moderation for Laravel first: the plugin reads its decisions and records reviews through it. Then install the plugin and publish its stylesheet:

composer require phoenix1331/filament-jev-moderation
php artisan filament:assets

Register it on a panel, and say who may use it:

use Phoenix1331\FilamentJevModeration\JevModerationPlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->plugin(
            JevModerationPlugin::make()
                ->authorize(fn (User $user): bool => $user->can('moderate')),
        );
}

A Moderation group appears with the review queue and the thresholds page. Every model using the core's Moderatable trait that has decisions is included. Without authorize() the pages open in the local environment only.

RequiresVersions
PHP8.3 or newer
Laravel12 or 13
Filament4 or 5
phoenix1331/laravel-jev-moderation0.2

The review workflow

The queue lists every record whose latest decision is a flag, across every moderated model, most confident first and then longest waiting. Each row shows the content as it is now (including content the site hides), its author, the category with where the flag came from, the confidence and how long it has waited. A user report shows its reason and the category Jev gave before it.

  • Approve overturns the flag and shows the content.
  • Reject upholds it and removes the content.
  • Reclassify removes it under a corrected category, when Jev picked the wrong one, so calibration counts it fairly.

All three work on one record or a selection across model types, with an optional note. They call the core's approve() and reject(), so the core's ReviewResolved event fires as usual. A record that changed since the page loaded (reviewed by someone else, reported again or rescanned) is skipped, and the notification says how many.

Filter by model type, category, confidence range, source and date queued. Clicking a row opens its history; clicking an author opens theirs.

A chat message's history: content and author, then a moderator's rejection with a note, a rescan that flagged it for sexual content at 55% with every category's probability as a bar, the pending check, and the original pass.
Decision history: why Jev decided, every probability, and who reviewed it.
Jordan Lee's author history: decision counts by status for the last 7 and 30 days, then every decision about their posts and messages, newest first.
Author history: one person's record across every moderated type.

Each queued record also links to its page in your own Filament resource for the model, when there is one the moderator may view or edit. recordUrlUsing() points it somewhere else.

Calibration, explained

Each moderator decision is paired with Jev's latest earlier result for the same record, and the pair lands in a 0.1-wide band by Jev's confidence. Per policy, category and band, the plugin counts how many were checked, how many moderators overturned and upheld, and what Jev missed (content it passed that a moderator later removed after a report).

A band is highlighted when moderators overturned more than 30% of it (overrideWarningRate()) and at least 10 were checked (minimumSample()). Below that sample, a high rate is more likely luck than evidence.

off_topic flags, confidence 0.8 to 0.9 69%
13
9

Moderators overturn most flags in this band: consider raising review_above to 0.90.

The starting numbers are from the seeded demo, where moderators approved 9 of 13 off-topic flags Jev made at 0.8 to 0.9 confidence. Advice is one-sided: reviews only exist for content Jev acted on, so they can show a threshold acting too eagerly, never too late.

Mostly overturned flags suggest raising review_above past the band; mostly overturned blocks, raising block_above; overturned low-confidence flags, lowering uncertain_below. Results are shown for the newest Jev version, with a selector for older ones, because a new version can move every band.

The thresholds page shows each policy's thresholds from config beside every band, and for each suggestion the command to run after you change config. It names the status the change affects, so content your moderators already approved or rejected is never re-checked:

php artisan jev-moderation:rescan "App\Models\ForumPost" --status=flagged

The core's rescan command also takes --dry-run, --since and --chunk.

The thresholds page: the community policy's version, uncertain_below and review and block thresholds per category, then every calibration band with one highlighted band suggesting review_above 0.90 and the rescan command to run.
Thresholds: config and evidence side by side, read-only.
The calibration widget below the queue, listing only the highlighted off-topic band with its suggestion.
Below the queue, only the bands to look at.

Widgets

Three widgets frame the queue. Each follows the plugin's authorisation, even when added somewhere else.

WidgetShows
ModerationOverviewRecords waiting and the oldest wait, blocks since midnight, median time to review and the override rate over 7 days, counted exactly as jev-moderation:stats counts it. Refreshes every minute
DecisionVolumeChartDecisions per day stacked by status, over 7, 30 or 90 days, with each total as text. Colours checked for colour vision deficiency in both themes
CalibrationTableEvery band per policy and category; under the queue, only the highlighted ones

Add the overview to your own dashboard by listing it in your panel's widgets:

use Phoenix1331\FilamentJevModeration\Widgets\ModerationOverview;

->widgets([ModerationOverview::class])
Four stat tiles: 9 waiting with the oldest 3 days, 2 blocked today, a median review time of 20 hours 11 minutes, and a 22% override rate.
ModerationOverview
A stacked bar chart of decisions per day over 30 days, coloured by passed, approved, flagged, rejected, blocked and check failed, with totals above.
DecisionVolumeChart

Authorisation

Two layers, both failing closed. authorize() decides who sees the pages and widgets. Its callback must return exactly true; anything else denies. Pages return 403 on every request and widgets on every update, not only in navigation.

Review actions are checked on top, per record, bulk actions included. In order, the first that is set decides:

SourceHow
authorizeActionsUsing()A callback taking $action, $decision and $user, returning exactly true
A ModerationDecision policyThe Gate checks the abilities approve, reject and reclassify, so Filament Shield and hand-written policies work
NeitherAnyone authorize() allows may review
use Phoenix1331\FilamentJevModeration\Enums\ReviewAction;

JevModerationPlugin::make()
    ->authorize(fn (User $user): bool => $user->can('moderate'))
    ->authorizeActionsUsing(fn (ReviewAction $action, User $user): bool =>
        $action === ReviewAction::Approve || $user->can('remove-content'))

Plugin options

Every option accepts a closure, and an invalid value fails when the panel boots rather than on a moderator's screen.

OptionDefaultDoes
authorize()local onlyWho may use the pages and widgets
authorizeActionsUsing()policy, else allowedWho may approve, reject and reclassify
models([...])every type with decisionsLimit the plugin to some moderated models
includeBlocked()offQueue automatic blocks as well as flags, so a person sees every removal
modelLabels([...])class name in sentence caseName model types in columns, filters and headings
authorRelation()userThe BelongsTo relation naming each record's author
authorRelations([...])nonePer-model exceptions, such as a chat message's sender
recordUrlUsing()your resource for the modelWhere a record's "View" link goes, or null to hide it
overrideWarningRate()0.3Override rate above which a band is highlighted
minimumSample()10Reviews a band needs before it can be highlighted
navigationGroup(), navigationIcon(), navigationSort()"Moderation", each page's own icon, noneWhere the pages sit; a null group lists them ungrouped
JevModerationPlugin::make()
    ->authorize(fn (User $user): bool => $user->can('moderate'))
    ->models([ForumPost::class, ChatMessage::class])
    ->includeBlocked()
    ->modelLabels([ForumPost::class => 'Forum thread'])
    ->authorRelations([ChatMessage::class => 'sender'])
    ->recordUrlUsing(fn (ForumPost $record): string => route('forum.posts.show', $record))
    ->overrideWarningRate(0.25)
    ->minimumSample(20)
    ->navigationGroup('Trust and safety')

Labels and excerpts

Every string the plugin shows is translatable. Publish the English files and edit them in place, or copy them to another locale:

php artisan vendor:publish --tag=filament-jev-moderation-translations

Model types are named with modelLabels(), as above. Categories come from your policies' choice keys in sentence case, so personal_data reads "Personal data".

The queue excerpt is the content Jev checked: the model's moderatableContent() (by default its #[Moderate] fields), joined with a dot and cut at 140 characters. Put the field that identifies a record first, such as a title before its body. The history page shows every field in full.

Security and data

Reads: the core's moderation_decisions for the models it shows, including notes, the moderator or reporting user, and the stored copy of content when the core keeps one; each record's moderated fields, including content the site hides; authors by name, never email address; and policy thresholds through the core's API, never the API key.

Writes: only reviews, only through the core's approve() and reject(). An architecture test fails the build on any other database write.

Sends: nothing. No HTTP requests, no logging, both enforced by tests. The rescan commands it suggests are shown, never run.

Worth knowing: moderators see harmful content and any personal data in it, so keep authorize() to trained staff; a public server left on APP_ENV=local opens the pages to every panel user; and without a policy or authorizeActionsUsing(), everyone who can see the queue can review. The full list is in SECURITY.md.

Family

laravel-jev-moderation

The core this plugin builds on: confidence-aware moderation for any Eloquent model with Jev, per-category thresholds, and an append-only audit trail. Headless, with commands for install, ping, stats and rescan.