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.