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:
// 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
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
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:
// 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
$eligibleUsers = User::whereSpecification($spec)->get();
In Collections
$allUsers = User::all();
$eligibleUsers = $allUsers->filter(fn($user) => $spec->isSatisfiedBy($user));
In Policies
class UserPolicy
{
public function receiveDiscount(User $user): bool
{
return (new EligibleForDiscountSpecification())->isSatisfiedBy($user);
}
}
In Commands
class SendDiscountEmails extends Command
{
public function handle()
{
$eligibleSpec = new EligibleForDiscountSpecification();
User::whereSpecification($eligibleSpec)
->chunk(100, function ($users) {
// Send discount emails...
});
}
}
In Jobs
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:
// 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:
// 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:
// 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
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
// 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
// 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:
- ✅ First Specification - You're here!
- ➡️ Artisan Generator - Master the powerful code generator
- 🍽️ 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?