<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
class Comment extends Model
{
const STATUS_PENDING = 'pending';
const STATUS_APPROVED = 'approved';
const STATUS_REJECTED = 'rejected';
protected $fillable = ['post_id', 'user_id', 'body'];
protected $casts = ['moderated_at' => 'datetime'];
public function author()
{
return $this->belongsTo(User::class, 'user_id');
}
public function scopePending(Builder $query): Builder
{
return $query->where('status', self::STATUS_PENDING);
}
public function scopeApproved(Builder $query): Builder
{
return $query->where('status', self::STATUS_APPROVED);
}
public function approve(User $moderator): void
{
$this->forceFill([
'status' => self::STATUS_APPROVED,
'moderated_at' => now(),
'moderated_by' => $moderator->id,
])->save();
}
public function reject(User $moderator): void
{
$this->forceFill([
'status' => self::STATUS_REJECTED,
'moderated_at' => now(),
'moderated_by' => $moderator->id,
])->save();
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('body');
$table->string('status')->default('pending');
$table->timestamp('moderated_at')->nullable();
$table->foreignId('moderated_by')->nullable()->constrained('users');
$table->timestamps();
$table->index(['status', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('comments');
}
};
<?php
namespace App\Http\Controllers;
use App\Events\CommentApproved;
use App\Models\Comment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class CommentModerationController extends Controller
{
public function queue(Request $request)
{
$this->authorize('moderate', Comment::class);
$comments = Comment::pending()
->with('author')
->oldest()
->paginate(25);
return view('moderation.queue', compact('comments'));
}
public function approve(Request $request, Comment $comment)
{
$this->authorize('moderate', $comment);
DB::transaction(function () use ($comment, $request) {
$comment->approve($request->user());
event(new CommentApproved($comment));
});
return redirect()
->route('moderation.queue')
->with('status', "Comment #{$comment->id} approved.");
}
public function reject(Request $request, Comment $comment)
{
$this->authorize('moderate', $comment);
$comment->reject($request->user());
return redirect()
->route('moderation.queue')
->with('status', "Comment #{$comment->id} rejected.");
}
}
This snippet shows how a comment moderation workflow is modeled in Laravel using a status column, a query scope, and a controller action that transitions a comment from pending to approved. The pattern separates untrusted user-generated content from published content by defaulting every new comment to a pending state, so nothing appears publicly until a moderator acts on it.
In Comment model, the moderation state is stored in a single status string column with constants (STATUS_PENDING, STATUS_APPROVED, STATUS_REJECTED) rather than magic strings scattered across the code. The scopePending and scopeApproved query scopes wrap the common where('status', ...) filters so callers read as Comment::pending() and Comment::approved(), which keeps the intent obvious and centralizes the filter if the schema ever changes. The approve and reject domain methods encapsulate the transition and stamp moderated_at and moderated_by, giving an audit trail of who acted and when. Keeping this logic on the model means the controller stays thin and the same transition can be reused by console commands or batch jobs.
The create_comments_table migration defines the backing schema: status defaults to pending at the database level so a bug that forgets to set it still fails safe, and a composite index on (status, created_at) makes the moderation queue query — pending comments, oldest first — cheap even with many rows.
In CommentModerationController, the queue action returns the pending comments ordered oldest-first so moderators clear the backlog fairly, eager-loading author to avoid N+1 queries. The approve action is guarded by authorize, calls the model's approve method, and fires CommentApproved so downstream concerns — notifications, cache busting, search indexing — react without coupling the controller to them. Wrapping the state change and event in a DB::transaction ensures the row and any side effects committed inside stay consistent. This structure trades a little extra ceremony for clear boundaries: the model owns transitions, the controller owns HTTP and authorization, and events own the ripple effects. A common pitfall to avoid is filtering pending content in views rather than at the query layer, which is exactly what the scopes here prevent.
Related snips
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
<%# private stream: turbo signs the serialized record name %>
<%= turbo_stream_from current_user %>
<section class="notifications">
<h1>Notifications</h1>
Turbo Streams + authorization: signed per-user stream name
Share this code
Here's the card — post it anywhere.