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

Moderate Laravel Package

torann/moderate

Torann Moderate is a Laravel package that adds lightweight moderation to Eloquent models. Mark records as pending, approved, or rejected and filter moderated content with simple scopes, ideal for posts, comments, and other user-generated content.

View on GitHub
Deep Wiki
Context7

Getting Started

Treat the first hour as a compatibility probe, not an install. Before anything else, open composer.json on the repo and compare its laravel/framework and php constraints to your runtime. A package of this vintage (last visible release note: a license tweak for Packagist) very often pins Laravel 5.x–8.x. Two outcomes, both useful:

  • composer require torann/moderate fails loudly on a constraint conflict — good, you've saved a day.
  • It installs on Laravel 10/11 — treat that as a yellow flag, not a green one. Installing is not the same as behaving correctly against newer Eloquent internals.

Read the source before you trust it. The package is small — one service provider, one trait, one migration, a config file, and a log model. Budget thirty minutes to read vendor/torann/moderate/src end to end. You need to know the exact method and scope names, because the README is thin and guessing is how you get the "moderated means pending" bug. Expect something along the lines of a ModerateTrait with approve() / reject() helpers, scopes like approved() / pending() / rejected(), and a configurable status column.

Publish and inspect, don't just run.

php artisan vendor:publish --provider="Torann\Moderate\ModerateServiceProvider"

Open the published migration and config and answer three questions before migrate:

  1. What is the column called, and is it nullable with a default?
  2. Is it tri-state, or a boolean wearing a costume?
  3. Does the migration add a defaulted column (potentially a table rebuild + write lock on old MySQL) or a plain nullable add?

Backfill before you ever filter. This is the single highest-probability incident in the whole integration:

// In the same PR as the migration, before any read path uses a scope
DB::table('posts')->whereNull('status')->update(['status' => 'approved']);

Do this on a staging copy of production data first, so you see both the lock behaviour on your real table sizes and what the app looks like when nothing has a status yet.

First use case. Pick the smallest gated surface you have — product reviews, comments, or user-submitted listings. Add the trait to that one model, default new rows to pending, and filter public reads through the approved() scope:

class Review extends Model
{
    use ModerateTrait;

    protected $fillable = ['body', 'rating', 'product_id']; // note: no status
}

// Public read path
Review::approved()->where('product_id', $id)->latest()->paginate();

Success criterion, and it's narrow and testable: a newly created review is invisible to an anonymous user, and an admin action makes it visible. Ship that before adding a second model.

Implementation Patterns

Guard the status column from mass assignment. Leave status out of $fillable and let the trait/helpers be the only mutation path. If status is fillable, a request payload with status=approved self-approves content, and no amount of scoping saves you.

Force the initial state in a model event, not in the controller. Controllers get duplicated; the creating hook doesn't:

protected static function booted()
{
    static::creating(fn ($model) => $model->status ??= 'pending');
}

That way a seeder, an import, a queue job, or a Nova resource all land in the same state.

Compose the scopes with the ones you already have. The realistic production read path is never just approved():

Post::approved()
    ->published()            // your own scope
    ->where('tenant_id', $tenant->id)
    ->latest()
    ->paginate();

Check explicitly that the package's scopes coexist with your global scopes — soft deletes, tenancy/marketplace, locale. A global scope quietly re-filtering or re-ordering is the kind of thing you find in staging a week later.

Wrap approvals in a transaction with a policy. The package gives you state; it does not give you authorization. Put the decision behind a Gate/Policy and a single action class:

public function approve(Review $review, User $moderator): void
{
    Gate::authorize('moderate', $review);

    DB::transaction(function () use ($review, $moderator) {
        $review->approve();                          // trait helper; verify the name in src/
        ModerationEvent::create([                    // your own table — see Gotchas
            'moderatable_type' => $review::class,
            'moderatable_id'   => $review->id,
            'actor_id'         => $moderator->id,
            'from'             => 'pending',
            'to'               => 'approved',
            'reason'           => request('reason'),
        ]);
    });
}

Controllers stay thin, and the audit record exists from day one instead of being retrofitted after launch.

Use the per-model moderation log for reviewer context, not for compliance. The package advertises per-model logs with reasons — that's genuinely useful for the admin queue ("why was this rejected?"). It is not the same as an org-wide audit trail, and it usually isn't queryable across model types. Keep both if you're in a marketplace, anything with minors, or anything with financial harm.

Build the queue screen in whatever admin panel you already have. Filament example — status as a badge column and a pending tab:

Tables\Columns\BadgeColumn::make('status')
    ->colors(['warning' => 'pending', 'success' => 'approved', 'danger' => 'rejected']);

// tab filter
->tabs(['pending' => Tables\Tab::make()->modifyQueryUsing(fn ($q) => $q->pending())])

Do the same in Nova or Backpack. The package is a storage convention; the queue is yours either way, and it's a couple of hours once the trait is on the model.

Hook notifications to state changes. Approve/reject should trigger mail or in-app notices to the author. If the package fires events (check src/), listen to them; if not, dispatch your own from the action class above — don't scatter Notification::send across controllers.

Roll out behind a feature flag. Column and backfill in PR #1; scopes on reads in PR #2, gated so you can flip visibility off without a rollback migration. When something looks empty, the flag tells you instantly whether it's the moderation filter or something else.

Test the invisibility, not just the happy path. A factory state or two and one assertion carry most of the value:

public function test_pending_review_is_hidden_from_public_listing(): void
{
    $review = Review::factory()->pending()->create();
    $this->getJson("/api/products/{$review->product_id}/reviews")
         ->assertJsonMissing(['id' => $review->id]);
}

For an existing table too large to take a lock on, do nullable add → backfill in batches → add the default/constraint, and keep the read-path change behind the flag until the backfill is verified.

Gotchas and Tips

Zero adoption means you own it. No stars, no listed dependents, one visible release that only touched the license. Plan as if upstream is gone: pin the exact version in composer.json, and if you're adopting past a spike, vendor the source into app/Support/Moderation with a named owner and a note in the repo. An unowned fork is code nobody dares to change — worse than code you wrote.

Null status silently deletes your content. Adding a nullable column and leaving existing rows null excludes every one of them from approved(). To users this reads as "the site lost its content." Backfill before the read path changes, in the same PR or an earlier one.

"Moderated" is ambiguous until you read the code. Depending on implementation it may mean "has been acted on" or "is pending action." Filtering a public list with the wrong one either leaks unapproved content or hides everything. Read the scope bodies; don't infer from the name.

One flag does not express a workflow. Two-stage review, escalation, "approved but flagged for re-review," appeals — all of these break the single-column model and force a migration away from the package's assumptions. If any of those are on the roadmap within two quarters, decide now whether you extend the enum yourself or skip the dependency.

No audit trail in the box. Who approved what, when, and why is the first post-launch moderation request and regulatory table stakes in a lot of domains. Add a moderation_events table (or a structured log) in the first PR, even if you also use the package's reason field.

Mass assignment and config caching bite in the usual ways. status out of $fillable, always. After editing config/moderate.php — model bindings, status column name — run php artisan config:clear; a stale cached config is a 20-minute debugging detour.

Check lock behaviour before production migration. A defaulted column add is cheap on MySQL 8 and Postgres, and can mean a full table rebuild under older MySQL on a large table. Run the migration against a production-sized copy, not an empty local schema.

Debug scopes with toSql(), not vibes. When a listing is unexpectedly empty, dd(Post::approved()->toSql(), Post::query()->toSql()) immediately shows whether the moderation scope, a global tenancy scope, or your own where is the culprit. This is the fastest triage step for the most common symptom.

Extension points to prefer. Publish and own the config, override the status column name if it clashes with your domain, and extend the trait into your own base model (app/Models/Concerns/Moderatable.php) rather than sprinkling use ModerateTrait — that gives you one place to add the creating hook, the events, and later a swap to a hand-rolled implementation.

Don't let anyone call moderation "done" when the spike lands. The package is the visibility switch. Queues, assignment, SLA, appeals, reporter-side flagging, and notifications remain roadmap work; say so in the PR description so the line survives contact with a status meeting.

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.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings