Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Jev Moderation Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

  • 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.

Implementation Patterns

  • Attribute + trait is the whole integration. #[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.
  • Dispatch happens after commit. 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.
  • Override the hooks rather than reimplementing:
    • 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.
  • Policies are configuration, not code. Each policy is a question plus described choices with the safe choice last, 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.
  • User reports and moderator actions are the same append-only stream:
    $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.
  • Query scopes are the read path. 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', ...).
  • Add the denormalised column once traffic grows. 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.
  • Moderation dashboard loop: 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.
  • Testing with 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.
  • React to events, don't poll. 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.
  • Custom drivers go through 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.

Gotchas and Tips

  • The global scope is a real global scope. Pre-moderation hides 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.
  • Append-only means append-only. Saving, deleting, or mass-updating/deleting an existing decision throws 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.
  • Threshold semantics are precise and surprising at the edges: block beats flag, a probability equal to a threshold counts as above it, low-confidence answers (below 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.
  • Retry classification matters: 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.
  • The 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.
  • Extension points to prefer, in order: 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.
  • Version pinning: this is 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource