hootlex/laravel-moderation
A Laravel package for moderating Eloquent models. Add moderation to any model with a trait, then approve, reject, or mark records pending using simple methods, query scopes, and events. Includes customizable status columns and support for filtering moderated content.
Install and scaffold in three steps:
composer require hootlex/laravel-moderation
php artisan vendor:publish --provider="Hootlex\Moderation\ModerationServiceProvider" --tag="migrations"
php artisan migrate
As of v1.1.0 the service provider is registered via package auto-discovery, so nothing needs to go into config/app.php on Laravel 5.5+. On Laravel < 5.5 (anything before auto-discovery) add Hootlex\Moderation\ModerationServiceProvider::class to the providers array manually — and only there.
Then attach the trait to every model you want to gate:
use Hootlex\Moderation\Moderatable;
class Comment extends Model
{
use Moderatable;
}
That single use statement is the entire public surface: it persists a moderation status on the model and gives you approve(), reject(), moderate(), isApproved(), isPending(), isRejected(), plus the query scopes approved(), pending(), rejected() and approvedOrPending(). Statuses are constants on Hootlex\Moderation\Status (PENDING, APPROVED, REJECTED) — always compare against those rather than raw integers.
Where to look first: open vendor/hootlex/laravel-moderation/src/Moderatable.php. It is a small file and it is the API; there is no separate service layer or facade to learn. Then glance at Status.php for the constants and the published migration for the column definition.
First use case to wire up end-to-end: a blog comment form. New comments land as pending, an admin list paginates Comment::pending(), and each row gets an Approve / Reject button that calls $comment->approve() or $comment->reject(). The public comment list reads Comment::approved()->latest()->get(). That loop — create → queue → decide → filter — is 90% of what this package is used for.
Pending-by-default creation. Let the DB default do the work; don't ask the user or the form for a status.
$comment = Comment::create($request->validated()); // status = pending
For trusted paths (seeded content, admin-authored posts, imports) set it explicitly before saving:
$post = new Post($data);
$post->approve(); // persists immediately
Public read path via scopes. Never write where('status', 1) by hand — it drifts the moment someone reorders the constants.
// Controller
$comments = Comment::approved()->latest()->paginate(20);
Admin moderation queue with status tabs. One route, one view, a status filter:
$filter = $request->query('status', 'pending');
$comments = match ($filter) {
'approved' => Comment::approved(),
'rejected' => Comment::rejected(),
default => Comment::pending(),
};
return view('admin.comments.index', [
'comments' => $comments->latest()->paginate(25),
'filter' => $filter,
]);
Single-action endpoints. Keep approve/reject as their own POST routes so you can authorize and log them individually:
Route::post('/admin/comments/{comment}/approve', [CommentModerationController::class, 'approve']);
Route::post('/admin/comments/{comment}/reject', [CommentModerationController::class, 'reject']);
public function approve(Comment $comment)
{
$this->authorize('moderate', $comment);
$comment->approve();
return back()->with('status', 'Comment approved.');
}
Guard public visibility with a global scope if "pending must never leak" is a hard requirement. The package does not ship one, so add it yourself and opt out in admin controllers:
protected static function booted(): void
{
static::addGlobalScope('approved', fn ($q) => $q->where('status', Status::APPROVED));
}
// Admin queue
Comment::withoutGlobalScope('approved')->pending()->paginate(25);
This is stronger than relying on every developer remembering ->approved(), but it is a footgun for the admin UI — document it in the model docblock.
Bulk moderation. For a "select all → approve" toolbar, a batch update is far cheaper than a loop of approve() calls:
Comment::pending()->whereIn('id', $ids)->update(['status' => Status::APPROVED]);
Accept the trade-off: this skips model events, updated_at is touched but observers won't fire. Use ->each->approve() when per-row side effects (notifications, search indexing) matter.
Reactive side effects. Because the package doesn't emit domain events, wrap the trait methods in your model or observe the status column:
public function approve()
{
parent::approve(); // trait method
CommentApproved::dispatch($this);
return $this;
}
A class method always wins over a trait method, so this is the sanctioned extension point — no vendor patching required.
Blade. The predicates read cleanly in views and save you from magic numbers:
@if ($comment->isPending())
<span class="badge badge-warning">Awaiting review</span>
@elseif ($comment->isApproved())
<span class="badge badge-success">Live</span>
@endif
Testing. Add a factory state so tests express intent, not integers:
public function approved(): static
{
return $this->state(['status' => Status::APPROVED]);
}
v1.1.0 is a housekeeping release, not a modernisation one. The only changes are auto-discovery and declared PHP 7.1 / 7.2 support. There is no Laravel version bump — the illuminate/* constraints still target 5.x, and there is no PHP 8 declaration. Treat the compatibility picture as unchanged: on Laravel 9/10/11 composer require will be blocked by the constraints, and on PHP 8.1+ hosts you may hit runtime deprecations/fatals even if you force the install. Options, in order of preference: fork it (the trait is ~100 lines and MIT-licensed — vendoring it as an app-local trait is often less work than maintaining a fork), or pin "hootlex/laravel-moderation": "dev-master" with a repositories VCS entry and relaxed constraints. Do not assume it works on modern Laravel out of the box because the release notes mention new PHP versions.
Don't register the provider twice. Now that v1.1.0 ships auto-discovery, a leftover ModerationServiceProvider::class entry in config/app.php (copied from an older project or a blog post) registers the package twice. The migration file gets loaded twice and php artisan migrate fails with a "cannot declare class / table already exists" style error. Remove the manual line on 5.5+.
Keep status out of $fillable. The single most common security bug with this package is a model that mass-assigns status, letting a crafted form field self-approve content. Leave it guarded, or strip it in FormRequest validation. Set it via approve() / reject() only.
Default state on existing rows. New records pick up the column default (PENDING), but any rows that predate the migration will be NULL — and isPending() may return false for them since NULL !== 0. Backfill before shipping:
DB::table('comments')->whereNull('status')->update(['status' => Status::PENDING]);
Watch approvedOrPending() in compound queries. Scopes of that shape typically expand to an orWhere, which silently escapes your other constraints (Comment::where('post_id', $id)->approvedOrPending() can return other posts' comments). Wrap it in a closure when combining:
Comment::where('post_id', $id)
->where(fn ($q) => $q->approvedOrPending())
->get();
Index the status column. Almost every query in a moderated app filters on it, and the admin queue usually sorts by created_at too. A composite index on (status, created_at) keeps the pending queue fast as the table grows past a few hundred thousand rows.
approve() saves immediately. It is not a setter — it writes to the database and touches updated_at. If you need "approve and also change the title in one write," mutate attributes first, then call approve(), or set status directly and save() once.
Name collisions. If your model already has a status column with different semantics (order fulfilment, account state), this package will fight it. Either rename via a migration, use a separate moderation model, or don't put the trait on that model.
No who-moderated audit trail. The package records what the status is, not who changed it or when it was decided (only updated_at). If your product needs an audit log — and most moderation flows eventually do — add a moderation_logs table and write to it from the wrapped approve()/reject() methods described above. Don't wait until after launch.
Re-moderation is manual. There is no "content edited after approval → back to pending" behaviour. If you want that (a common requirement on community sites), hook updating on the model and call moderate() when a moderated field changes — otherwise edited-and-approved content stays live.
Scopes don't compose with soft deletes automatically. Rejected or pending records are still returned by default queries once trashed, depending on your setup — combine explicitly (->approved()->withTrashed()) and decide per endpoint whether rejected content should be visible to its author.
Debugging tip. Status bugs are almost always one of three things: the trait wasn't added to a subclass that overrides $table, a mass-assignment guard silently dropped status, or a global scope from another package is interfering. $comment->getAttributes() plus Comment::toSql() after applying the scope resolves it in under a minute.
How can I help you explore Laravel packages today?