Skip to content

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
<?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:

  1. Trusted Author Rule: User has 5+ published posts, account age 3+ months, zero violations
  2. Premium User Rule: Active premium subscription
  3. Content Quality Rule: Minimum 100 words
  4. Content Safety Rule: No spam/fake/scam keywords
  5. 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 criteria
  • PremiumUserSpecification - Subscription status
  • ContentQualitySpecification - Word count requirements
  • ContentSafetySpecification - Keyword filtering
  • AutoApprovalSpecification - Composed final decision

Step 1: Generate Base Specifications

Let's use the Artisan command to generate our specifications:

bash
# 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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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

php
// 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.

View Next Example →

Released under the MIT License.