phoenix1331/laravel-jev-moderation
A Laravel moderation package that lets you flag, review, and approve or reject user-generated content with configurable rules, queues, and status tracking. Integrates cleanly with Eloquent models so you can add moderation workflows to posts, comments, and more with minimal setup.
Install the package and run the installer. composer require then php artisan jev-moderation:install. The command is the fastest orientation: it publishes config/moderation.php (config key jev-moderation), publishes the migration via the jev-moderation-migrations tag, prompts for mode (post/pre), default policy, and whether to migrate, and prints the .env keys you need. In CI use --no-interaction and note it defaults to not migrating — pass --migrate explicitly.
Set the two or three env keys that matter first: JEV_API_KEY (driver credentials), JEV_MODERATION_MODE (post is the safe default), JEV_MODERATION_FAIL (open by default). Everything else can wait.
Verify connectivity before touching models. php artisan jev-moderation:ping shows driver settings (never the key), lists models the key can use, classifies one harmless sample, prints the outcome and answering Jev version, and exits 1 with the request id on failure. This is your first debugging step for every "why isn't it flagging anything" question.
First use case: one model, one policy, post-moderation. Add the trait and attribute:
use Moderatable;
#[Moderate(fields: ['title', 'body'], policy: 'community')]
class Post extends Model
{
use Moderatable;
}
Save a row, wait for the queue worker, then inspect latestModeration / moderationStatus(). Post-moderation means content is visible immediately and the global scope hides it once a decision lands as blocked (per policy thresholds).
Read in this order: the published config (policies block, drivers.jev, store_content, queue settings) → ModerationStatus/ModerationSource enums → the Verdict/VerdictReason threshold resolution → docs/accuracy.md for what the preset policies actually do on real text.
Run the accuracy suite once locally (composer test:accuracy) if you're going to tune thresholds — it's the documented signal for whether community/chat/strict wording behaves as you expect before you write your own preset.
#[Moderate(fields: [...], policy: ...)] records a pending decision on create and on changes to those fields only; other column updates and no-op saves are skipped by design. You don't call anything in your controllers.ModerateContent is queued on the after_commit semantics of the saving transaction, so jobs won't see uncommitted rows and a rolled-back save doesn't burn a Jev check. Don't fight this by dispatching manually; use moderateNow() when you genuinely need a synchronous re-check.moderatableContent() — what text actually goes to Jev (strip markup, concatenate fields, exclude internal notes).moderationContext() — surrounding context; it's trimmed to max_context_tokens (default 24000) context-first, so the content you care about survives.moderationPolicy() — per-model policy override without touching the attribute.moderationAuthor() — defaults to the user relation; override for multi-guard or non-user ownership.review_above per category, optional block_above, and an optional uncertain_below confidence floor. Start from community, clone it for your domain, and pin JEV_MODERATION_POLICY per environment. Policy versions (sha256 of prompt + thresholds) are stored on every decision — use them to explain historical decisions after a tuning change.$post->flagForReview('spam', $user); // ContentReported
$post->approve($moderator, 'fine'); // ReviewResolved
$post->reject($moderator, 'off-topic'); // ReviewResolved
$post->moderateNow(); // fresh check
None of these mutate Jev's original decision — that's deliberate. Reports on already blocked/rejected content are recorded but don't resurrect visibility.withoutModeration(), onlyFlagged(), onlyBlocked(), whereModerationStatus() on the model; moderationStatus(), isFlagged(), isBlocked(), isVisible() on the instance. Use these in your public listings instead of hand-rolling whereHas('moderationDecisions', ...).php artisan jev-moderation:add-column posts generates a migration adding an indexed moderation_status backfilled from each row's latest decision; from then on it's kept in sync on every decision and the visibility scopes read the column instead of joining the decisions table. Do this before the decisions table gets large.jev-moderation:stats --days=30 for per-status counts, auto flag/block counts per category, and moderator uphold rate (this is your threshold-tuning feedback signal). jev-moderation:rescan "App\Models\Post" --status=flagged --since=... --chunk=500 --dry-run after a threshold change — note it asks for confirmation interactively because every check is billed.FakeDriver:
JevModeration::fake();
JevModeration::classifyAs('blocked');
// or per-call: JevModeration::push([...]), JevModeration::pushFailure(...)
JevModeration::assertClassified($post);
classifyUsing() for custom logic, failWith() for the failure path. ModerationDecisionFactory has pending, failed, passed, flagged, blocked, reported, approved, rejected states and needs no Faker — use it for admin-panel seeds and feature tests.ContentPending, ContentPassed, ContentFlagged, ContentBlocked, ModerationFailed (lifecycle) plus ContentReported, ReviewResolved (human). All carry the model and decision and are serialisable for queued listeners — good for Slack pings, reviewer queues, and analytics.JevModeration::extend() implementing ModerationDriver::classify() and models(), returning Classification for a ModerationRequest; throw DriverException with retryable set appropriately. Register in a service provider, not in config.JevModeration::classify() is your escape hatch for checking text that isn't a model yet (comment previews, form validation) — it records nothing.pending content from every query, including your own admin tooling if you forget withoutModeration(). fail=open (the default) means a failed check leaves content visible; fail=closed hides it. Decide this deliberately per environment — the safe-looking choice may silently take your whole site down when Jev has an outage.DecisionsAreAppendOnly. Don't write cleanup jobs that prune decisions; if you need retention, that's a schema-level concern, not an Eloquent one. Also don't updateOrCreate on moderationDecisions.store_content is off by default. Decisions store only a sha256 of the content, so you cannot retroactively inspect what was checked, and --dry-run/audit tooling can't show the text. Turn on JEV_MODERATION_STORE_CONTENT early if compliance or reviewer UX requires it — turning it on later doesn't backfill hashes into content.uncertain_below) are flagged not passed, and every risky category's probability is checked — not just Jev's top choice. If a post is flagged "for no reason", look at the second-place category before you blame the model.sync mode skips retries. JEV_MODERATION_SYNC=true classifies inline with no retry loop — great for demos and small jobs, but a transient 429 becomes a permanent failed decision. Keep the queue in production and tune tries/backoff instead.429, 529, 5xx and timeouts are retryable (honouring retry-after when Jev sends it); everything else fails immediately. If you see repeated failed decisions, check jev-moderation:ping's request id against your driver logs before assuming an outage.JEV_MODEL aliases trigger a ping warning — use the resolved model name. Silent quality differences otherwise.user relation assumption bites on anything not owned by the default guard; override moderationAuthor() (or accept null authors) or decisions will record the wrong reviewer/owner and your stats will lie.moderateNow() is a fresh, billable check. It appends a new decision; it doesn't replace the old one, and the scope resolves visibility from the latest. Use it for explicit re-checks, not as a "refresh" in a loop.rescan is billed per check and asks before running interactively. It includes content that was never checked, and skips moderator-approved/rejected rows unless --status names them — so a naive --status=flagged rescan won't re-litigate human decisions, which is usually what you want.moderatableContent()/moderationContext() → custom policy in config → JevModeration::extend() custom driver. Reaching for a custom driver before tuning the content hook usually means you're sending the wrong text.moderationPolicy()/moderationContext() run on save. Keep them cheap and side-effect free — they're in the write path of every moderated model.0.1.0. The append-only contract and the global scope are the two behaviours most likely to change shape pre-1.0; keep the decision table and your policy config under review, and lean on fake() in tests so a driver change doesn't break your suite.How can I help you explore Laravel packages today?