Skip to content

Your First Specification

Let's create your first specification and see the immediate benefits. You'll transform messy conditional logic into clean, testable, and reusable code in just 5 minutes.

The Problem: Messy Controller Logic

Here's a typical Laravel controller method that's crying out for the Specification Pattern:

php
// UserController.php
class UserController extends Controller
{
    public function eligibleForDiscount()
    {
        // 😱 This logic is hard to read, test, and reuse
        $users = User::where('status', 'active')
            ->whereNotNull('email_verified_at')
            ->where('created_at', '<=', now()->subDays(30))
            ->where('total_orders', '>=', 5)
            ->whereHas('orders', function ($query) {
                $query->where('total', '>=', 100)
                      ->where('created_at', '>=', now()->subDays(90));
            })
            ->where('country', '!=', 'US') // International customers only
            ->get();

        return response()->json($users);
    }
}

Problems with This Approach

  • Hard to read: Complex conditions mixed together
  • Not testable: Can't test business logic in isolation
  • Not reusable: Logic is trapped in the controller
  • Hard to maintain: Changes require modifying the controller

The Solution: Create a Specification

Let's transform this into a clean, maintainable specification.

Step 1: Generate the Specification

bash
php artisan make:specification User/EligibleForDiscountSpecification --model=User

Step 2: Implement the Business Logic

Open app/Specifications/User/EligibleForDiscountSpecification.php and implement your logic:

php
<?php

namespace App\Specifications\User;

use App\Models\User;
use DangerWayne\Specification\Specifications\AbstractSpecification;
use DangerWayne\Specification\Specifications\Common\WhereSpecification;
use DangerWayne\Specification\Specifications\Common\WhereNullSpecification;
use DangerWayne\Specification\Specifications\Common\WhereHasSpecification;
use Illuminate\Database\Eloquent\Builder;

class EligibleForDiscountSpecification extends AbstractSpecification
{
    public function __construct(
        private int $minimumDaysAsMember = 30,
        private int $minimumOrders = 5,
        private float $minimumOrderValue = 100,
        private int $recentOrdersDays = 90
    ) {
        //
    }

    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof User) {
            return false;
        }

        return $this->buildCompositeSpecification()->isSatisfiedBy($candidate);
    }

    public function toQuery(Builder $query): Builder
    {
        return $this->buildCompositeSpecification()->toQuery($query);
    }

    private function buildCompositeSpecification(): AbstractSpecification
    {
        $activeUserSpec = new WhereSpecification('status', 'active');
        $verifiedEmailSpec = new WhereNullSpecification('email_verified_at', false);
        $membershipDurationSpec = new WhereSpecification('created_at', '<=', now()->subDays($this->minimumDaysAsMember));
        $minimumOrdersSpec = new WhereSpecification('total_orders', '>=', $this->minimumOrders);
        $internationalSpec = new WhereSpecification('country', '!=', 'US');
        
        // Recent high-value orders
        $recentHighValueOrderSpec = new WhereHasSpecification(
            'orders',
            (new WhereSpecification('total', '>=', $this->minimumOrderValue))
                ->and(new WhereSpecification('created_at', '>=', now()->subDays($this->recentOrdersDays)))
        );

        return $activeUserSpec
            ->and($verifiedEmailSpec)
            ->and($membershipDurationSpec)
            ->and($minimumOrdersSpec)
            ->and($recentHighValueOrderSpec)
            ->and($internationalSpec);
    }

}

Step 3: Transform Your Controller

Now your controller becomes beautifully simple:

php
// UserController.php
class UserController extends Controller
{
    public function eligibleForDiscount()
    {
        // ✨ Clean, readable, and maintainable!
        $eligibleUsersSpec = new EligibleForDiscountSpecification();
        $users = User::whereSpecification($eligibleUsersSpec)->get();

        return response()->json($users);
    }
}

The Transformation Results

Benefits Achieved

  • Readable: Business logic is clearly expressed
  • Testable: Can test the specification in isolation
  • Reusable: Use anywhere in your application
  • Flexible: Easily configurable with constructor parameters
  • Maintainable: Business rule changes happen in one place

Bonus: Multiple Usage Patterns

Now that you have a specification, you can use it everywhere:

In Controllers

php
$eligibleUsers = User::whereSpecification($spec)->get();

In Collections

php
$allUsers = User::all();
$eligibleUsers = $allUsers->filter(fn($user) => $spec->isSatisfiedBy($user));

In Policies

php
class UserPolicy
{
    public function receiveDiscount(User $user): bool
    {
        return (new EligibleForDiscountSpecification())->isSatisfiedBy($user);
    }
}

In Commands

php
class SendDiscountEmails extends Command
{
    public function handle()
    {
        $eligibleSpec = new EligibleForDiscountSpecification();
        
        User::whereSpecification($eligibleSpec)
            ->chunk(100, function ($users) {
                // Send discount emails...
            });
    }
}

In Jobs

php
class ProcessDiscountQueue implements ShouldQueue
{
    public function handle()
    {
        $spec = new EligibleForDiscountSpecification();
        
        foreach (User::whereSpecification($spec)->cursor() as $user) {
            // Process individual user...
        }
    }
}

Testing Your Specification

Create a test to verify your business logic:

php
// tests/Unit/Specifications/User/EligibleForDiscountSpecificationTest.php
class EligibleForDiscountSpecificationTest extends TestCase
{
    public function test_eligible_user_satisfies_specification()
    {
        $user = User::factory()
            ->has(Order::factory()->count(6)->state(['total' => 150]))
            ->create([
                'status' => 'active',
                'email_verified_at' => now(),
                'created_at' => now()->subDays(45),
                'total_orders' => 6,
                'country' => 'CA'
            ]);

        $spec = new EligibleForDiscountSpecification();

        $this->assertTrue($spec->isSatisfiedBy($user));
    }

    public function test_new_user_does_not_satisfy_specification()
    {
        $newUser = User::factory()->create([
            'created_at' => now()->subDays(10), // Too new
            'total_orders' => 2, // Not enough orders
        ]);

        $spec = new EligibleForDiscountSpecification();

        $this->assertFalse($spec->isSatisfiedBy($newUser));
    }

    public function test_us_customer_does_not_satisfy_specification()
    {
        $usUser = User::factory()->create(['country' => 'US']);
        $spec = new EligibleForDiscountSpecification();

        $this->assertFalse($spec->isSatisfiedBy($usUser));
    }
}

Customizing Behavior

Make your specification flexible with constructor parameters:

php
// Default behavior
$standardDiscount = new EligibleForDiscountSpecification();

// Black Friday special (more generous criteria)
$blackFridayDiscount = new EligibleForDiscountSpecification(
    minimumDaysAsMember: 7,    // Only 1 week membership required
    minimumOrders: 2,          // Only 2 orders required
    minimumOrderValue: 50,     // Lower order value threshold
    recentOrdersDays: 180      // Extended recent order window
);

// VIP customer discount (stricter criteria)
$vipDiscount = new EligibleForDiscountSpecification(
    minimumDaysAsMember: 365,  // 1 year membership
    minimumOrders: 20,         // High order count
    minimumOrderValue: 500,    // High value orders
    recentOrdersDays: 30       // Very recent orders
);

Performance Benefits

Specifications work seamlessly with Laravel's query optimization:

php
// Efficient database queries
$users = User::whereSpecification($spec)
    ->with(['orders', 'profile'])
    ->paginate(20);

// Chunked processing for large datasets
User::whereSpecification($spec)
    ->chunk(1000, function ($users) {
        // Process users in batches
    });

// Lazy collections for memory efficiency
User::whereSpecification($spec)
    ->lazy()
    ->each(function ($user) {
        // Process one user at a time
    });

Bonus: Caching for Performance

For expensive specifications that you'll run frequently, add caching to dramatically improve performance:

Adding the CacheableSpecification Trait

php
<?php

namespace App\Specifications\User;

use App\Models\User;
use DangerWayne\Specification\Specifications\AbstractSpecification;
use DangerWayne\Specification\Traits\CacheableSpecification;
use Illuminate\Database\Eloquent\Builder;

class EligibleForDiscountSpecification extends AbstractSpecification
{
    use CacheableSpecification; // Add caching capability
    
    protected int $cacheTtl = 3600; // Cache for 1 hour

    public function __construct(
        private int $minimumDaysAsMember = 30,
        private int $minimumOrders = 5,
        private float $minimumOrderValue = 100,
        private int $recentOrdersDays = 90
    ) {
        //
    }

    // ... rest of implementation stays the same

    public function getCacheKey(): string
    {
        // Create unique cache key based on parameters
        return 'eligible_discount_' . md5(serialize($this->getParameters()));
    }
    
    protected function getParameters(): array
    {
        // Now this method is actually used for cache key generation!
        return [
            'minimum_days_as_member' => $this->minimumDaysAsMember,
            'minimum_orders' => $this->minimumOrders,
            'minimum_order_value' => $this->minimumOrderValue,
            'recent_orders_days' => $this->recentOrdersDays,
        ];
    }
}

Cache Benefits

php
// First call: Executes database query and caches result
$eligibleUsers = User::whereSpecification($cachedSpec)->get();

// Subsequent calls within 1 hour: Returns cached result instantly
$sameUsers = User::whereSpecification($cachedSpec)->get(); // ⚡ Cached!

// Different parameters create different cache keys
$blackFridaySpec = new EligibleForDiscountSpecification(
    minimumDaysAsMember: 7,    // Different parameters
    minimumOrders: 2
);
$blackFridayUsers = User::whereSpecification($blackFridaySpec)->get(); // New cache entry

Cache Management

php
// Clear cache for specific specification
$spec = new EligibleForDiscountSpecification();
$spec->clearCache();

// Check if result is cached
if ($spec->isCached()) {
    // Result will be served from cache
}

// Force fresh query (bypass cache)
$freshUsers = User::whereSpecification($spec, bypassCache: true)->get();

What You've Accomplished

Congratulations! In just 5 minutes, you've:

  • ✅ Transformed messy controller logic into clean specifications
  • ✅ Made your business rules testable and reusable
  • ✅ Learned how to compose complex logic from simple parts
  • ✅ Discovered multiple usage patterns for specifications
  • ✅ Created flexible, configurable business logic

Next Steps

Now that you've created your first specification:

  1. First Specification - You're here!
  2. ➡️ Artisan Generator - Master the powerful code generator
  3. 🍽️ Appetizers - See more transformation examples

Feeling the Power?

You've just experienced the core benefit of the Specification Pattern. Ready to see what the Artisan generator can do?

Master the Artisan Generator →

Released under the MIT License.