Skip to content

Performance & Caching ​

Optimize your specifications for maximum performance. Learn caching strategies, query optimization techniques, and how to build specifications that scale from hundreds to millions of records.

Performance Fundamentals ​

Specifications operate in two contexts with different performance characteristics:

  • Memory Operations: Fast for small datasets, memory-bound for large ones
  • Database Queries: Scalable for large datasets, network/disk I/O bound

Understanding when to use each approach is crucial for optimal performance.

Query Optimization ​

Index-Aware Specification Design ​

Design specifications that leverage database indexes:

php
// Good: Uses indexed columns efficiently
class OptimizedUserSpecification extends AbstractSpecification
{
    public function toQuery($query): Builder
    {
        return $query->where('status', 'active')      // Indexed column first
                    ->where('created_at', '>', now()->subYear()) // Compound index
                    ->orderBy('updated_at', 'desc'); // Additional indexed column
    }
}

// Bad: Prevents index usage
class UnoptimizedUserSpecification extends AbstractSpecification
{
    public function toQuery($query): Builder
    {
        return $query->whereRaw('UPPER(email) LIKE ?', ['%@EXAMPLE.COM'])
                    ->orWhere('status', 'active'); // OR conditions can prevent index usage
    }
}

// Better: Index-friendly alternative
class ImprovedUserSpecification extends AbstractSpecification
{
    public function toQuery($query): Builder
    {
        return $query->where('email_domain', 'example.com') // Pre-computed indexed column
                    ->where('status', 'active'); // Compound index with email_domain
    }
}

Selective Query Building ​

Put most selective conditions first:

php
class SelectiveSpecification extends AbstractSpecification
{
    public function toQuery($query): Builder
    {
        return $query
            // Most selective first (eliminates 95% of records)
            ->where('subscription_type', 'premium_enterprise')
            
            // Moderately selective (filters remaining records)
            ->where('status', 'active')
            
            // Least selective last (fine-tunes final results)
            ->where('created_at', '>', now()->subMonth());
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        // Same order for consistency and short-circuiting
        return $candidate->subscription_type === 'premium_enterprise'
            && $candidate->status === 'active'
            && $candidate->created_at > now()->subMonth();
    }
}

Efficient Relationship Queries ​

Optimize specifications that work with relationships:

php
class EfficientRelationshipSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly int $minimumOrders = 1
    ) {}
    
    public function toQuery($query): Builder
    {
        if ($this->minimumOrders === 1) {
            // Use exists for simple "has relationship" checks
            return $query->whereHas('orders');
        }
        
        // Use withCount for counting relationships
        return $query->withCount('orders')
                    ->having('orders_count', '>=', $this->minimumOrders);
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        // Leverage eager-loaded counts when available
        if (isset($candidate->orders_count)) {
            return $candidate->orders_count >= $this->minimumOrders;
        }
        
        // Fallback to relationship check
        if ($candidate->relationLoaded('orders')) {
            return $candidate->orders->count() >= $this->minimumOrders;
        }
        
        // Last resort: database query
        return $candidate->orders()->count() >= $this->minimumOrders;
    }
}

Caching Strategies ​

Basic Specification Caching ​

Cache expensive specification evaluations:

php
class CachedSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly SpecificationInterface $innerSpec,
        private readonly int $ttlSeconds = 3600,
        private readonly ?string $cacheKey = null
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        $key = $this->cacheKey ?? $this->generateCacheKey($candidate);
        
        return Cache::remember($key, $this->ttlSeconds, function () use ($candidate) {
            return $this->innerSpec->isSatisfiedBy($candidate);
        });
    }
    
    public function toQuery($query): Builder
    {
        // Query operations typically don't need caching at specification level
        return $this->innerSpec->toQuery($query);
    }
    
    private function generateCacheKey(mixed $candidate): string
    {
        $specClass = get_class($this->innerSpec);
        $candidateKey = $this->getCandidateKey($candidate);
        
        return "spec.{$specClass}.{$candidateKey}";
    }
    
    private function getCandidateKey(mixed $candidate): string
    {
        if (method_exists($candidate, 'getCacheKey')) {
            return $candidate->getCacheKey();
        }
        
        if (isset($candidate->id)) {
            return get_class($candidate) . '.' . $candidate->id;
        }
        
        return md5(serialize($candidate));
    }
}

// Usage
$cachedSpec = new CachedSpecification(
    new ComplexBusinessRuleSpecification(),
    ttlSeconds: 1800 // 30 minutes
);

if ($cachedSpec->isSatisfiedBy($user)) {
    // Result cached for 30 minutes
}

Smart Cache Invalidation ​

Implement intelligent cache clearing:

php
class SmartCachedSpecification extends AbstractSpecification
{
    use InteractsWithCache;
    
    public function __construct(
        private readonly SpecificationInterface $innerSpec,
        private readonly array $invalidationEvents = []
    ) {}
    
    public function boot(): void
    {
        // Register cache invalidation listeners
        foreach ($this->invalidationEvents as $event) {
            Event::listen($event, function ($eventInstance) {
                $this->invalidateCache($eventInstance);
            });
        }
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        $key = $this->getCacheKey($candidate);
        
        return Cache::tags($this->getCacheTags($candidate))
            ->remember($key, 3600, function () use ($candidate) {
                return $this->innerSpec->isSatisfiedBy($candidate);
            });
    }
    
    private function invalidateCache($event): void
    {
        $tags = $this->getInvalidationTags($event);
        Cache::tags($tags)->flush();
    }
    
    private function getCacheTags(mixed $candidate): array
    {
        return [
            'specifications',
            get_class($this->innerSpec),
            get_class($candidate),
            'entity.' . ($candidate->id ?? 'unknown')
        ];
    }
    
    private function getInvalidationTags($event): array
    {
        if (method_exists($event, 'getCacheInvalidationTags')) {
            return $event->getCacheInvalidationTags();
        }
        
        return ['specifications'];
    }
}

// Usage with automatic cache invalidation
$smartCached = new SmartCachedSpecification(
    new UserEligibilitySpecification(),
    invalidationEvents: [
        UserUpdated::class,
        UserStatusChanged::class,
        UserSubscriptionChanged::class,
    ]
);

Query Result Caching ​

Cache expensive database queries:

php
class QueryCachedSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly SpecificationInterface $innerSpec,
        private readonly int $cacheTtl = 600 // 10 minutes
    ) {}
    
    public function toQuery($query): Builder
    {
        $innerQuery = $this->innerSpec->toQuery($query);
        
        // Add query caching
        return $innerQuery->remember($this->cacheTtl);
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $this->innerSpec->isSatisfiedBy($candidate);
    }
}

// Advanced query caching with custom keys
class AdvancedQueryCachedSpecification extends AbstractSpecification
{
    public function toQuery($query): Builder
    {
        $baseQuery = $this->innerSpec->toQuery($query);
        
        // Generate cache key from query SQL and bindings
        $cacheKey = $this->generateQueryCacheKey($baseQuery);
        
        return $baseQuery->remember($this->cacheTtl, $cacheKey);
    }
    
    private function generateQueryCacheKey(Builder $query): string
    {
        $sql = $query->toSql();
        $bindings = $query->getBindings();
        
        return 'query.' . md5($sql . serialize($bindings));
    }
}

Memory Optimization ​

Lazy Evaluation ​

Implement lazy evaluation for expensive operations:

php
class LazySpecification extends AbstractSpecification
{
    private ?bool $cachedResult = null;
    private mixed $lastCandidate = null;
    
    public function __construct(
        private readonly SpecificationInterface $innerSpec
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        // Only evaluate if candidate changed
        if ($this->lastCandidate !== $candidate) {
            $this->cachedResult = $this->innerSpec->isSatisfiedBy($candidate);
            $this->lastCandidate = $candidate;
        }
        
        return $this->cachedResult;
    }
    
    public function toQuery($query): Builder
    {
        return $this->innerSpec->toQuery($query);
    }
    
    public function resetCache(): void
    {
        $this->cachedResult = null;
        $this->lastCandidate = null;
    }
}

Batch Processing ​

Process large datasets efficiently:

php
class BatchProcessingSpecification
{
    public function __construct(
        private readonly SpecificationInterface $spec,
        private readonly int $batchSize = 1000
    ) {}
    
    public function filterLargeCollection(Collection $collection): Collection
    {
        return $collection->lazy()
            ->filter(fn($item) => $this->spec->isSatisfiedBy($item))
            ->collect();
    }
    
    public function processLargeQuery(Builder $query): void
    {
        $query->whereSpecification($this->spec)
            ->chunk($this->batchSize, function ($batch) {
                // Process each batch
                foreach ($batch as $item) {
                    $this->processItem($item);
                }
            });
    }
    
    private function processItem($item): void
    {
        // Your processing logic here
    }
}

Profiling and Monitoring ​

Performance Profiling ​

Track specification performance:

php
class ProfiledSpecification extends AbstractSpecification
{
    private array $metrics = [];
    
    public function __construct(
        private readonly SpecificationInterface $innerSpec,
        private readonly bool $enabled = true
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$this->enabled) {
            return $this->innerSpec->isSatisfiedBy($candidate);
        }
        
        $start = microtime(true);
        $result = $this->innerSpec->isSatisfiedBy($candidate);
        $duration = microtime(true) - $start;
        
        $this->recordMetric('isSatisfiedBy', $duration);
        
        return $result;
    }
    
    public function toQuery($query): Builder
    {
        if (!$this->enabled) {
            return $this->innerSpec->toQuery($query);
        }
        
        $start = microtime(true);
        $result = $this->innerSpec->toQuery($query);
        $duration = microtime(true) - $start;
        
        $this->recordMetric('toQuery', $duration);
        
        return $result;
    }
    
    private function recordMetric(string $method, float $duration): void
    {
        $this->metrics[] = [
            'method' => $method,
            'duration' => $duration,
            'timestamp' => now(),
            'specification' => get_class($this->innerSpec),
        ];
        
        // Log slow operations
        if ($duration > 0.1) { // 100ms threshold
            Log::warning('Slow specification operation', [
                'specification' => get_class($this->innerSpec),
                'method' => $method,
                'duration' => $duration,
            ]);
        }
    }
    
    public function getMetrics(): array
    {
        return $this->metrics;
    }
    
    public function getAverageExecutionTime(string $method = null): float
    {
        $filteredMetrics = $method
            ? array_filter($this->metrics, fn($m) => $m['method'] === $method)
            : $this->metrics;
        
        if (empty($filteredMetrics)) {
            return 0.0;
        }
        
        $totalDuration = array_sum(array_column($filteredMetrics, 'duration'));
        return $totalDuration / count($filteredMetrics);
    }
}

Query Analysis ​

Analyze specification-generated queries:

php
class QueryAnalyzer
{
    public function analyzeSpecification(SpecificationInterface $spec): array
    {
        DB::listen(function ($query) use (&$queryLog) {
            $queryLog[] = [
                'sql' => $query->sql,
                'bindings' => $query->bindings,
                'time' => $query->time,
            ];
        });
        
        $queryLog = [];
        
        // Execute specification query
        $query = User::whereSpecification($spec);
        $results = $query->get();
        
        return [
            'queries' => $queryLog,
            'total_time' => array_sum(array_column($queryLog, 'time')),
            'query_count' => count($queryLog),
            'result_count' => $results->count(),
            'memory_usage' => memory_get_peak_usage(true),
        ];
    }
    
    public function explainQuery(SpecificationInterface $spec): array
    {
        $query = User::whereSpecification($spec);
        
        $explainSql = 'EXPLAIN ' . $query->toSql();
        $explanation = DB::select($explainSql, $query->getBindings());
        
        return [
            'sql' => $query->toSql(),
            'bindings' => $query->getBindings(),
            'explanation' => $explanation,
            'recommendations' => $this->analyzeExplanation($explanation),
        ];
    }
    
    private function analyzeExplanation(array $explanation): array
    {
        $recommendations = [];
        
        foreach ($explanation as $row) {
            // Check for full table scans
            if (str_contains($row->Extra ?? '', 'Using filesort')) {
                $recommendations[] = 'Consider adding index for ORDER BY clause';
            }
            
            if (str_contains($row->Extra ?? '', 'Using temporary')) {
                $recommendations[] = 'Query uses temporary table - consider optimization';
            }
            
            if ($row->type === 'ALL') {
                $recommendations[] = 'Full table scan detected - add appropriate indexes';
            }
        }
        
        return $recommendations;
    }
}

Real-World Optimization Examples ​

E-commerce Product Filtering ​

php
class OptimizedProductFilterSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly array $filters,
        private readonly bool $useCache = true
    ) {}
    
    public function toQuery($query): Builder
    {
        $optimizedQuery = $query;
        
        // Apply most selective filters first
        if (!empty($this->filters['category'])) {
            // Category is usually very selective
            $optimizedQuery = $optimizedQuery->where('category_id', $this->filters['category']);
        }
        
        if (!empty($this->filters['in_stock']) && $this->filters['in_stock']) {
            // Stock check is moderately selective
            $optimizedQuery = $optimizedQuery->where('stock_quantity', '>', 0);
        }
        
        if (!empty($this->filters['price_range'])) {
            // Price range is less selective but uses index
            [$min, $max] = $this->filters['price_range'];
            $optimizedQuery = $optimizedQuery->whereBetween('price', [$min * 100, $max * 100]);
        }
        
        if (!empty($this->filters['brand'])) {
            // Brand filter with index
            $optimizedQuery = $optimizedQuery->where('brand_id', $this->filters['brand']);
        }
        
        // Add caching if enabled
        if ($this->useCache) {
            $cacheKey = 'products.' . md5(serialize($this->filters));
            $optimizedQuery = $optimizedQuery->remember(600, $cacheKey);
        }
        
        return $optimizedQuery;
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        // Use caching for memory operations too
        if ($this->useCache) {
            $cacheKey = "product_filter.{$candidate->id}." . md5(serialize($this->filters));
            
            return Cache::remember($cacheKey, 300, function () use ($candidate) {
                return $this->evaluateProduct($candidate);
            });
        }
        
        return $this->evaluateProduct($candidate);
    }
    
    private function evaluateProduct($product): bool
    {
        // Same order as query for consistency
        if (!empty($this->filters['category'])) {
            if ($product->category_id !== $this->filters['category']) {
                return false;
            }
        }
        
        if (!empty($this->filters['in_stock']) && $this->filters['in_stock']) {
            if ($product->stock_quantity <= 0) {
                return false;
            }
        }
        
        if (!empty($this->filters['price_range'])) {
            [$min, $max] = $this->filters['price_range'];
            $price = $product->price / 100; // Convert from cents
            if ($price < $min || $price > $max) {
                return false;
            }
        }
        
        if (!empty($this->filters['brand'])) {
            if ($product->brand_id !== $this->filters['brand']) {
                return false;
            }
        }
        
        return true;
    }
}

User Analytics Specification ​

php
class HighPerformanceUserAnalyticsSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly Carbon $startDate,
        private readonly Carbon $endDate
    ) {}
    
    public function toQuery($query): Builder
    {
        return $query
            // Use partitioned table if available
            ->whereDate('created_at', '>=', $this->startDate)
            ->whereDate('created_at', '<=', $this->endDate)
            
            // Eager load for analytics to prevent N+1
            ->with([
                'orders' => fn($q) => $q->whereBetween('created_at', [
                    $this->startDate, 
                    $this->endDate
                ]),
                'orders.items'
            ])
            
            // Use raw SQL for performance-critical calculations
            ->selectRaw('
                users.*,
                (
                    SELECT COUNT(*) 
                    FROM orders 
                    WHERE orders.user_id = users.id 
                    AND orders.created_at BETWEEN ? AND ?
                ) as orders_count,
                (
                    SELECT COALESCE(SUM(total_amount), 0)
                    FROM orders 
                    WHERE orders.user_id = users.id 
                    AND orders.created_at BETWEEN ? AND ?
                ) as total_spent
            ', [
                $this->startDate, $this->endDate,
                $this->startDate, $this->endDate
            ])
            
            // Cache for analytics queries
            ->remember(1800); // 30 minutes
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        // Use computed values if available
        if (isset($candidate->orders_count, $candidate->total_spent)) {
            return $this->evaluateFromComputedValues($candidate);
        }
        
        // Fallback to relationship evaluation
        return $this->evaluateFromRelationships($candidate);
    }
    
    private function evaluateFromComputedValues($user): bool
    {
        return $user->orders_count > 0 && $user->total_spent > 0;
    }
    
    private function evaluateFromRelationships($user): bool
    {
        if (!$user->relationLoaded('orders')) {
            // Load orders efficiently
            $user->load([
                'orders' => fn($q) => $q->whereBetween('created_at', [
                    $this->startDate, 
                    $this->endDate
                ])
            ]);
        }
        
        return $user->orders->isNotEmpty() && 
               $user->orders->sum('total_amount') > 0;
    }
}

Performance Testing ​

Benchmark Specifications ​

Create performance benchmarks:

php
class SpecificationBenchmark
{
    public function benchmarkSpecification(
        SpecificationInterface $spec,
        array $testData,
        int $iterations = 1000
    ): array {
        // Memory benchmark
        $memoryStart = memory_get_usage(true);
        $start = microtime(true);
        
        for ($i = 0; $i < $iterations; $i++) {
            foreach ($testData as $candidate) {
                $spec->isSatisfiedBy($candidate);
            }
        }
        
        $memoryEnd = memory_get_usage(true);
        $end = microtime(true);
        
        return [
            'total_time' => $end - $start,
            'average_time' => ($end - $start) / ($iterations * count($testData)),
            'memory_used' => $memoryEnd - $memoryStart,
            'operations_per_second' => ($iterations * count($testData)) / ($end - $start),
        ];
    }
    
    public function benchmarkQuery(
        SpecificationInterface $spec,
        int $iterations = 100
    ): array {
        $times = [];
        
        for ($i = 0; $i < $iterations; $i++) {
            $start = microtime(true);
            User::whereSpecification($spec)->get();
            $times[] = microtime(true) - $start;
        }
        
        return [
            'min_time' => min($times),
            'max_time' => max($times),
            'average_time' => array_sum($times) / count($times),
            'median_time' => $this->median($times),
        ];
    }
    
    private function median(array $values): float
    {
        sort($values);
        $count = count($values);
        $middle = floor($count / 2);
        
        if ($count % 2) {
            return $values[$middle];
        }
        
        return ($values[$middle - 1] + $values[$middle]) / 2;
    }
}

// Usage
$benchmark = new SpecificationBenchmark();
$testUsers = User::factory()->count(1000)->make();

$results = $benchmark->benchmarkSpecification(
    new ActiveUserSpecification(),
    $testUsers,
    1000
);

dump($results);

Performance Guidelines ​

Best Practices Summary ​

  1. Database Indexes: Ensure specifications use indexed columns
  2. Query Order: Put most selective conditions first
  3. Caching Strategy: Cache expensive operations appropriately
  4. Relationship Loading: Eager load when needed, avoid N+1 queries
  5. Memory Management: Use lazy evaluation for large datasets
  6. Profiling: Monitor and profile specification performance

When to Cache ​

  • ✅ Cache When: Complex calculations, external API calls, expensive database operations
  • ❌ Don't Cache When: Simple comparisons, rapidly changing data, memory-constrained environments

Performance Red Flags ​

Watch out for these performance issues:

php
// ❌ Bad: N+1 query problem
foreach ($users as $user) {
    if ((new UserWithOrdersSpecification())->isSatisfiedBy($user)) {
        // Each iteration triggers a database query
    }
}

// ✅ Good: Single query solution
$usersWithOrders = User::whereSpecification(
    new UserWithOrdersSpecification()
)->get();

// ❌ Bad: Inefficient OR conditions
$query->where('status', 'active')
      ->orWhere('status', 'premium')
      ->orWhere('status', 'trial');

// ✅ Good: Use whereIn for multiple values
$query->whereIn('status', ['active', 'premium', 'trial']);

// ❌ Bad: Function in WHERE clause prevents index usage
$query->whereRaw('DATE(created_at) = ?', [today()]);

// ✅ Good: Range query uses index
$query->whereBetween('created_at', [today(), today()->addDay()]);

Next Steps ​

With performance optimization mastered, learn how to integrate specifications throughout your Laravel application:


Performance Mastery

Great performance comes from understanding both your data access patterns and your application's constraints. Profile first, optimize second, and always measure the impact of your changes.

Next: Laravel Integration →

Released under the MIT License.