Blog Content Moderation System
Difficulty: Basic | Time: 45 minutes | Integration: Validation, Events, Testing
Transform a messy blog content approval system into clean, testable specifications. This complete walkthrough shows every step from recognizing business logic to implementing comprehensive tests.
The Problem: Scattered Approval Logic
You've inherited a blog application where content approval logic is scattered across multiple controllers, making it difficult to maintain and test.
🔍 Examining the Existing Code
Let's look at the problematic PostController
:
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function store(Request $request)
{
$post = Post::create($request->validated());
// Scattered approval logic - this is what we need to fix!
$shouldAutoApprove = false;
// Check if user is trusted
if ($request->user()->posts()->where('status', 'published')->count() >= 5
&& $request->user()->created_at <= now()->subMonths(3)
&& $request->user()->violations()->count() === 0) {
$shouldAutoApprove = true;
}
// Override for premium users
if ($request->user()->subscription_type === 'premium'
&& $request->user()->subscription_expires_at > now()) {
$shouldAutoApprove = true;
}
// Content checks
if (str_word_count($post->content) < 100) {
$shouldAutoApprove = false;
}
if (preg_match('/\b(spam|fake|scam)\b/i', $post->content)) {
$shouldAutoApprove = false;
}
// Final status
$post->status = $shouldAutoApprove ? 'published' : 'pending';
$post->save();
return redirect()->route('posts.show', $post);
}
public function update(Request $request, Post $post)
{
$post->update($request->validated());
// Same logic duplicated here! 😱
$shouldAutoApprove = false;
if ($request->user()->posts()->where('status', 'published')->count() >= 5
&& $request->user()->created_at <= now()->subMonths(3)
&& $request->user()->violations()->count() === 0) {
$shouldAutoApprove = true;
}
// ... more duplicated logic
$post->status = $shouldAutoApprove ? 'published' : 'pending';
$post->save();
return redirect()->route('posts.show', $post);
}
}
🎯 Business Logic Analysis
Let's identify the business rules hidden in this code:
- Trusted Author Rule: User has 5+ published posts, account age 3+ months, zero violations
- Premium User Rule: Active premium subscription
- Content Quality Rule: Minimum 100 words
- Content Safety Rule: No spam/fake/scam keywords
- Final Decision: ALL safety/quality rules must pass, AND at least one trust rule must pass
📋 Specification Strategy
We'll create focused specifications for each business rule:
TrustedAuthorSpecification
- User trust criteriaPremiumUserSpecification
- Subscription statusContentQualitySpecification
- Word count requirementsContentSafetySpecification
- Keyword filteringAutoApprovalSpecification
- Composed final decision
Step 1: Generate Base Specifications
Let's use the Artisan command to generate our specifications:
# Generate specifications for different concerns
php artisan make:specification Post/TrustedAuthorSpecification --model=User
php artisan make:specification User/PremiumUserSpecification --model=User
php artisan make:specification Post/ContentQualitySpecification --model=Post
php artisan make:specification Post/ContentSafetySpecification --model=Post
php artisan make:specification Post/AutoApprovalSpecification --composite
This creates the following files:
app/Specifications/Post/TrustedAuthorSpecification.php
app/Specifications/User/PremiumUserSpecification.php
app/Specifications/Post/ContentQualitySpecification.php
app/Specifications/Post/ContentSafetySpecification.php
app/Specifications/Post/AutoApprovalSpecification.php
Step 2: Implement Individual Specifications
TrustedAuthorSpecification
<?php
namespace App\Specifications\Post;
use App\Models\User;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class TrustedAuthorSpecification extends AbstractSpecification
{
public function __construct(
private readonly int $minimumPosts = 5,
private readonly int $minimumMonths = 3,
private readonly int $maxViolations = 0
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof User) {
return false;
}
// Check published post count
$publishedCount = $candidate->posts()->where('status', 'published')->count();
if ($publishedCount < $this->minimumPosts) {
return false;
}
// Check account age
$accountAge = $candidate->created_at;
if ($accountAge > now()->subMonths($this->minimumMonths)) {
return false;
}
// Check violations
$violationCount = $candidate->violations()->count();
if ($violationCount > $this->maxViolations) {
return false;
}
return true;
}
public function toQuery(Builder $query): Builder
{
return $query
->whereHas('posts', function ($postQuery) {
$postQuery->where('status', 'published');
}, '>=', $this->minimumPosts)
->where('created_at', '<=', now()->subMonths($this->minimumMonths))
->whereDoesntHave('violations')
->orWhereHas('violations', function ($violationQuery) {
// This ensures violations count is 0
}, '<=', $this->maxViolations);
}
}
PremiumUserSpecification
<?php
namespace App\Specifications\User;
use App\Models\User;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class PremiumUserSpecification extends AbstractSpecification
{
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof User) {
return false;
}
return $candidate->subscription_type === 'premium'
&& $candidate->subscription_expires_at > now();
}
public function toQuery(Builder $query): Builder
{
return $query
->where('subscription_type', 'premium')
->where('subscription_expires_at', '>', now());
}
}
ContentQualitySpecification
<?php
namespace App\Specifications\Post;
use App\Models\Post;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class ContentQualitySpecification extends AbstractSpecification
{
public function __construct(
private readonly int $minimumWords = 100
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof Post) {
return false;
}
$wordCount = str_word_count($candidate->content);
return $wordCount >= $this->minimumWords;
}
public function toQuery(Builder $query): Builder
{
// Note: This is a simplified example. In production, you might
// store word_count as a database column for better query performance
return $query->whereRaw('LENGTH(content) - LENGTH(REPLACE(content, " ", "")) + 1 >= ?',
[$this->minimumWords]);
}
}
ContentSafetySpecification
<?php
namespace App\Specifications\Post;
use App\Models\Post;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class ContentSafetySpecification extends AbstractSpecification
{
private readonly array $bannedKeywords;
public function __construct(?array $bannedKeywords = null)
{
$this->bannedKeywords = $bannedKeywords ?? ['spam', 'fake', 'scam', 'click here'];
}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof Post) {
return false;
}
$content = strtolower($candidate->content);
foreach ($this->bannedKeywords as $keyword) {
if (str_contains($content, strtolower($keyword))) {
return false;
}
}
return true;
}
public function toQuery(Builder $query): Builder
{
foreach ($this->bannedKeywords as $keyword) {
$query->where('content', 'NOT LIKE', "%{$keyword}%");
}
return $query;
}
public function getBannedKeywords(): array
{
return $this->bannedKeywords;
}
}
AutoApprovalSpecification (Composite)
<?php
namespace App\Specifications\Post;
use App\Models\Post;
use App\Specifications\User\PremiumUserSpecification;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class AutoApprovalSpecification extends AbstractSpecification
{
private readonly TrustedAuthorSpecification $trustedAuthorSpec;
private readonly PremiumUserSpecification $premiumUserSpec;
private readonly ContentQualitySpecification $qualitySpec;
private readonly ContentSafetySpecification $safetySpec;
public function __construct(
?TrustedAuthorSpecification $trustedAuthorSpec = null,
?PremiumUserSpecification $premiumUserSpec = null,
?ContentQualitySpecification $qualitySpec = null,
?ContentSafetySpecification $safetySpec = null
) {
$this->trustedAuthorSpec = $trustedAuthorSpec ?? new TrustedAuthorSpecification();
$this->premiumUserSpec = $premiumUserSpec ?? new PremiumUserSpecification();
$this->qualitySpec = $qualitySpec ?? new ContentQualitySpecification();
$this->safetySpec = $safetySpec ?? new ContentSafetySpecification();
}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof Post) {
return false;
}
// Content must pass quality and safety checks
if (!$this->qualitySpec->isSatisfiedBy($candidate)) {
return false;
}
if (!$this->safetySpec->isSatisfiedBy($candidate)) {
return false;
}
// User must be either trusted OR premium
$author = $candidate->author;
return $this->trustedAuthorSpec->isSatisfiedBy($author)
|| $this->premiumUserSpec->isSatisfiedBy($author);
}
public function toQuery(Builder $query): Builder
{
return $query
->where(function ($contentQuery) {
// Apply content specifications
$this->qualitySpec->toQuery($contentQuery);
$this->safetySpec->toQuery($contentQuery);
})
->whereHas('author', function ($authorQuery) {
// User must be trusted OR premium
$authorQuery->where(function ($trustQuery) {
$this->trustedAuthorSpec->toQuery($trustQuery);
})->orWhere(function ($premiumQuery) {
$this->premiumUserSpec->toQuery($premiumQuery);
});
});
}
}
Step 3: Refactor the Controller
Now we can clean up our controller dramatically:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Models\Post;
use App\Specifications\Post\AutoApprovalSpecification;
use Illuminate\Http\RedirectResponse;
class PostController extends Controller
{
public function __construct(
private readonly AutoApprovalSpecification $autoApprovalSpec
) {}
public function store(StorePostRequest $request): RedirectResponse
{
$post = Post::create(array_merge(
$request->validated(),
['author_id' => $request->user()->id]
));
// Clean, single line of business logic!
$post->status = $this->autoApprovalSpec->isSatisfiedBy($post)
? 'published'
: 'pending';
$post->save();
return redirect()->route('posts.show', $post);
}
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
{
$post->update($request->validated());
// Same clean logic for updates
$post->status = $this->autoApprovalSpec->isSatisfiedBy($post)
? 'published'
: 'pending';
$post->save();
return redirect()->route('posts.show', $post);
}
}
Step 4: Integration with Validation
Let's create a custom validation rule using our specification:
<?php
namespace App\Rules;
use App\Specifications\Post\ContentSafetySpecification;
use Illuminate\Contracts\Validation\Rule;
class SafeContentRule implements Rule
{
private readonly ContentSafetySpecification $safetySpec;
public function __construct()
{
$this->safetySpec = new ContentSafetySpecification();
}
public function passes($attribute, $value): bool
{
// Create a temporary object to test the content
$testPost = (object) ['content' => $value];
return $this->safetySpec->isSatisfiedBy($testPost);
}
public function message(): string
{
$keywords = implode(', ', $this->safetySpec->getBannedKeywords());
return "The content contains prohibited keywords: {$keywords}";
}
}
Update your form request:
<?php
namespace App\Http\Requests;
use App\Rules\SafeContentRule;
use Illuminate\Foundation\Http\FormRequest;
class StorePostRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string', 'min:100', new SafeContentRule()],
'category_id' => ['required', 'exists:categories,id'],
];
}
}
Step 5: Event Integration
Let's trigger events based on our specifications:
<?php
namespace App\Observers;
use App\Events\PostAutoApproved;
use App\Events\PostRequiresModeration;
use App\Models\Post;
use App\Specifications\Post\AutoApprovalSpecification;
class PostObserver
{
public function __construct(
private readonly AutoApprovalSpecification $autoApprovalSpec
) {}
public function created(Post $post): void
{
$this->checkApprovalStatus($post);
}
public function updated(Post $post): void
{
if ($post->wasChanged('content')) {
$this->checkApprovalStatus($post);
}
}
private function checkApprovalStatus(Post $post): void
{
if ($this->autoApprovalSpec->isSatisfiedBy($post)) {
event(new PostAutoApproved($post));
} else {
event(new PostRequiresModeration($post));
}
}
}
Step 6: Comprehensive Testing Setup
Let's set up both PHPUnit and Pest tests for our specifications:
Model Factory Updates
First, update your factories to support testing:
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class UserFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => bcrypt('password'),
'created_at' => fake()->dateTimeBetween('-2 years', 'now'),
'subscription_type' => 'free',
'subscription_expires_at' => null,
];
}
public function trusted(): static
{
return $this->state([
'created_at' => now()->subMonths(6), // Old enough account
])->afterCreating(function (User $user) {
// Create 5+ published posts
$user->posts()->createMany(
\App\Models\Post::factory()->published()->count(6)->make()->toArray()
);
});
}
public function premium(): static
{
return $this->state([
'subscription_type' => 'premium',
'subscription_expires_at' => now()->addYear(),
]);
}
public function withViolations(int $count = 1): static
{
return $this->afterCreating(function (User $user) use ($count) {
$user->violations()->createMany(
\App\Models\Violation::factory()->count($count)->make()->toArray()
);
});
}
}
PHPUnit Tests
<?php
namespace Tests\Unit\Specifications\Post;
use App\Models\Post;
use App\Models\User;
use App\Specifications\Post\AutoApprovalSpecification;
use App\Specifications\Post\ContentQualitySpecification;
use App\Specifications\Post\ContentSafetySpecification;
use App\Specifications\Post\TrustedAuthorSpecification;
use App\Specifications\User\PremiumUserSpecification;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class AutoApprovalSpecificationTest extends TestCase
{
use RefreshDatabase;
private AutoApprovalSpecification $specification;
protected function setUp(): void
{
parent::setUp();
$this->specification = new AutoApprovalSpecification();
}
public function test_approves_post_from_trusted_author_with_quality_content(): void
{
// Arrange
$trustedUser = User::factory()->trusted()->create();
$qualityPost = Post::factory()->make([
'content' => str_repeat('This is quality content. ', 20), // 100+ words
'author_id' => $trustedUser->id,
]);
$qualityPost->setRelation('author', $trustedUser);
// Act & Assert
$this->assertTrue($this->specification->isSatisfiedBy($qualityPost));
}
public function test_rejects_post_with_banned_keywords(): void
{
// Arrange
$trustedUser = User::factory()->trusted()->create();
$spamPost = Post::factory()->make([
'content' => str_repeat('This is spam content click here. ', 20),
'author_id' => $trustedUser->id,
]);
$spamPost->setRelation('author', $trustedUser);
// Act & Assert
$this->assertFalse($this->specification->isSatisfiedBy($spamPost));
}
public function test_approves_premium_user_post_with_quality_content(): void
{
// Arrange
$premiumUser = User::factory()->premium()->create();
$qualityPost = Post::factory()->make([
'content' => str_repeat('Premium user quality content. ', 20),
'author_id' => $premiumUser->id,
]);
$qualityPost->setRelation('author', $premiumUser);
// Act & Assert
$this->assertTrue($this->specification->isSatisfiedBy($qualityPost));
}
public function test_rejects_short_content_even_from_trusted_user(): void
{
// Arrange
$trustedUser = User::factory()->trusted()->create();
$shortPost = Post::factory()->make([
'content' => 'Too short.',
'author_id' => $trustedUser->id,
]);
$shortPost->setRelation('author', $trustedUser);
// Act & Assert
$this->assertFalse($this->specification->isSatisfiedBy($shortPost));
}
public function test_rejects_new_user_post(): void
{
// Arrange
$newUser = User::factory()->create(['created_at' => now()]);
$qualityPost = Post::factory()->make([
'content' => str_repeat('New user quality content. ', 20),
'author_id' => $newUser->id,
]);
$qualityPost->setRelation('author', $newUser);
// Act & Assert
$this->assertFalse($this->specification->isSatisfiedBy($qualityPost));
}
public function test_generates_correct_database_query(): void
{
// Arrange
$query = Post::query();
// Act
$result = $this->specification->toQuery($query);
// Assert - Check that the query includes our conditions
$sql = $result->toSql();
$this->assertStringContainsString('author', $sql);
$this->assertStringContainsString('posts', $sql);
}
}
Pest Tests
<?php
use App\Models\Post;
use App\Models\User;
use App\Specifications\Post\ContentSafetySpecification;
beforeEach(function () {
$this->specification = new ContentSafetySpecification();
});
it('allows safe content', function () {
$post = Post::factory()->make([
'content' => 'This is completely safe and appropriate content for our blog.'
]);
expect($this->specification->isSatisfiedBy($post))->toBeTrue();
});
it('rejects content with banned keywords', function ($keyword) {
$post = Post::factory()->make([
'content' => "This content contains {$keyword} which is not allowed."
]);
expect($this->specification->isSatisfiedBy($post))->toBeFalse();
})->with(['spam', 'fake', 'scam', 'SPAM', 'Fake']);
it('handles case insensitive keyword detection', function () {
$post = Post::factory()->make([
'content' => 'This is SPAM content that should be rejected.'
]);
expect($this->specification->isSatisfiedBy($post))->toBeFalse();
});
it('rejects invalid candidates', function () {
expect($this->specification->isSatisfiedBy('not a post'))->toBeFalse();
expect($this->specification->isSatisfiedBy(null))->toBeFalse();
expect($this->specification->isSatisfiedBy(new stdClass()))->toBeFalse();
});
it('can be configured with custom banned keywords', function () {
$customSpec = new ContentSafetySpecification(['badword', 'anotherbad']);
$badPost = Post::factory()->make(['content' => 'This contains badword']);
$goodPost = Post::factory()->make(['content' => 'This contains spam']); // Default keyword, but not in custom list
expect($customSpec->isSatisfiedBy($badPost))->toBeFalse();
expect($customSpec->isSatisfiedBy($goodPost))->toBeTrue();
});
Feature Test
<?php
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostModerationTest extends TestCase
{
use RefreshDatabase;
public function test_trusted_user_post_gets_auto_approved(): void
{
// Arrange
$trustedUser = User::factory()->trusted()->create();
// Act
$response = $this->actingAs($trustedUser)
->post('/posts', [
'title' => 'Test Post',
'content' => str_repeat('This is quality content. ', 25),
'category_id' => 1,
]);
// Assert
$response->assertRedirect();
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'status' => 'published', // Auto-approved
]);
}
public function test_new_user_post_requires_moderation(): void
{
// Arrange
$newUser = User::factory()->create(['created_at' => now()]);
// Act
$response = $this->actingAs($newUser)
->post('/posts', [
'title' => 'New User Post',
'content' => str_repeat('New user quality content. ', 25),
'category_id' => 1,
]);
// Assert
$response->assertRedirect();
$this->assertDatabaseHas('posts', [
'title' => 'New User Post',
'status' => 'pending', // Requires moderation
]);
}
public function test_post_with_spam_content_gets_rejected(): void
{
// Arrange
$user = User::factory()->create();
// Act
$response = $this->actingAs($user)
->post('/posts', [
'title' => 'Spam Post',
'content' => str_repeat('This is spam content click here now! ', 25),
'category_id' => 1,
]);
// Assert
$response->assertSessionHasErrors(['content']);
$this->assertDatabaseMissing('posts', [
'title' => 'Spam Post',
]);
}
}
Results: Clean, Maintainable Code
Before vs After Comparison
Before (Problems):
- ❌ 50+ lines of conditional logic in controller
- ❌ Duplicated business rules across methods
- ❌ Untestable business logic
- ❌ Hard to modify or extend rules
- ❌ No clear separation of concerns
After (Benefits):
- ✅ 2 lines of business logic in controller
- ✅ Single source of truth for each rule
- ✅ Fully testable with isolated unit tests
- ✅ Easy to modify individual rules
- ✅ Clear separation of concerns
- ✅ Reusable across the application
- ✅ Composable for complex scenarios
Maintainability Wins
// Need to change the trusted user criteria?
// Just modify TrustedAuthorSpecification - it's used everywhere automatically!
// Want to add a new approval rule?
// Create a new specification and compose it in AutoApprovalSpecification
// Need different rules for different post types?
// Extend the specifications or create new ones - no controller changes needed!
This example demonstrates how specifications transform scattered business logic into clean, maintainable, testable code that follows SOLID principles and Laravel best practices.
Next Example: Learn how to handle more complex scenarios with multiple integrations in our Intermediate Examples.