Skip to content

Query Integration

Master how specifications integrate seamlessly with Laravel's Eloquent ORM and Query Builder. Learn optimization techniques, relationship handling, and advanced query patterns.

The Two Worlds of Specifications

Specifications bridge two different contexts:

  1. Memory Context: Working with loaded objects and collections
  2. Database Context: Building efficient SQL queries

Understanding this duality is key to writing effective specifications.

Basic Query Integration

The whereSpecification Method

Laravel Specifications adds a whereSpecification method to Eloquent:

php
// Basic usage
$activeUsers = User::whereSpecification(new ActiveUserSpecification())->get();

// With additional constraints
$recentActiveUsers = User::whereSpecification(new ActiveUserSpecification())
    ->where('created_at', '>', now()->subMonth())
    ->orderBy('created_at', 'desc')
    ->get();

// With pagination
$paginatedUsers = User::whereSpecification(new ActiveUserSpecification())
    ->paginate(15);

How toQuery() Works

When you call whereSpecification(), Laravel internally calls your specification's toQuery() method:

php
class ActiveUserSpecification extends AbstractSpecification
{
    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $candidate->status === 'active';
    }
    
    // This method is called by whereSpecification()
    public function toQuery($query): Builder
    {
        return $query->where('status', 'active');
    }
}

// Internally, Laravel does:
$spec = new ActiveUserSpecification();
$query = User::query();
$result = $spec->toQuery($query); // Your toQuery method is called

Working with Relationships

Simple Relationship Queries

php
class UserWithOrdersSpecification extends AbstractSpecification
{
    public function isSatisfiedBy(mixed $candidate): bool
    {
        // For loaded objects, check the relationship
        if ($candidate->relationLoaded('orders')) {
            return $candidate->orders->isNotEmpty();
        }
        
        // Fallback to database query
        return $candidate->orders()->exists();
    }
    
    public function toQuery($query): Builder
    {
        return $query->whereHas('orders');
    }
}

// Usage
$usersWithOrders = User::whereSpecification(
    new UserWithOrdersSpecification()
)->get();

// Generated SQL: SELECT * FROM users WHERE EXISTS (SELECT * FROM orders WHERE orders.user_id = users.id)

Complex Relationship Constraints

php
class UserWithRecentOrdersSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly int $days = 30
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if ($candidate->relationLoaded('orders')) {
            return $candidate->orders
                ->where('created_at', '>', now()->subDays($this->days))
                ->isNotEmpty();
        }
        
        return $candidate->orders()
            ->where('created_at', '>', now()->subDays($this->days))
            ->exists();
    }
    
    public function toQuery($query): Builder
    {
        return $query->whereHas('orders', function ($orderQuery) {
            $orderQuery->where('created_at', '>', now()->subDays($this->days));
        });
    }
}

// Usage
$query = User::whereSpecification(
    new UserWithRecentOrdersSpecification(7)
);

// Generated SQL: 
// SELECT * FROM users 
// WHERE EXISTS (
//   SELECT * FROM orders 
//   WHERE orders.user_id = users.id 
//   AND orders.created_at > '2025-01-22 10:00:00'
// )

Counting Relationships

php
class UserWithMinimumOrdersSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly int $minimumOrders
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if ($candidate->relationLoaded('orders')) {
            return $candidate->orders->count() >= $this->minimumOrders;
        }
        
        return $candidate->orders()->count() >= $this->minimumOrders;
    }
    
    public function toQuery($query): Builder
    {
        return $query->whereHas('orders', null, '>=', $this->minimumOrders);
    }
}

// Advanced version with better performance
class OptimizedUserWithMinimumOrdersSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly int $minimumOrders
    ) {}
    
    public function toQuery($query): Builder
    {
        return $query->withCount('orders')
            ->having('orders_count', '>=', $this->minimumOrders);
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        // Check if orders_count is available (from withCount)
        if (isset($candidate->orders_count)) {
            return $candidate->orders_count >= $this->minimumOrders;
        }
        
        // Fallback to relationship count
        return $candidate->orders()->count() >= $this->minimumOrders;
    }
}

Advanced Query Patterns

Subquery Specifications

php
class UserWithHighValueOrdersSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly float $minimumOrderValue
    ) {}
    
    public function toQuery($query): Builder
    {
        return $query->whereIn('id', function ($subquery) {
            $subquery->select('user_id')
                ->from('orders')
                ->where('total_amount', '>', $this->minimumOrderValue * 100)
                ->distinct();
        });
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $candidate->orders()
            ->where('total_amount', '>', $this->minimumOrderValue * 100)
            ->exists();
    }
}

Join-Based Specifications

php
class UserInRegionSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly string $regionName
    ) {}
    
    public function toQuery($query): Builder
    {
        return $query->join('addresses', 'users.id', '=', 'addresses.user_id')
            ->join('regions', 'addresses.region_id', '=', 'regions.id')
            ->where('regions.name', $this->regionName)
            ->select('users.*'); // Important: avoid selecting joined columns
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if ($candidate->relationLoaded('address.region')) {
            return $candidate->address->region->name === $this->regionName;
        }
        
        return $candidate->address()->whereHas('region', function ($query) {
            $query->where('name', $this->regionName);
        })->exists();
    }
}

Raw Query Specifications

For complex database-specific operations:

php
class UserWithComplexScoreSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly float $minimumScore
    ) {}
    
    public function toQuery($query): Builder
    {
        return $query->whereRaw('
            (
                COALESCE(loyalty_points, 0) * 0.3 + 
                COALESCE(purchase_frequency, 0) * 0.4 + 
                COALESCE(referral_count, 0) * 0.3
            ) >= ?
        ', [$this->minimumScore]);
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        $score = ($candidate->loyalty_points ?? 0) * 0.3 +
                ($candidate->purchase_frequency ?? 0) * 0.4 +
                ($candidate->referral_count ?? 0) * 0.3;
                
        return $score >= $this->minimumScore;
    }
}

Optimizing Query Performance

Index-Aware Specifications

Design specifications with database indexes in mind:

php
class OptimizedUserSpecification extends AbstractSpecification
{
    public function toQuery($query): Builder
    {
        // Good: Uses compound index on (status, created_at)
        return $query->where('status', 'active')
            ->where('created_at', '>', now()->subYear());
    }
}

class SuboptimalUserSpecification extends AbstractSpecification
{
    public function toQuery($query): Builder
    {
        // Bad: Function on indexed column prevents index usage
        return $query->whereRaw('UPPER(email) LIKE ?', ['%@EXAMPLE.COM']);
    }
}

class BetterUserSpecification extends AbstractSpecification
{
    public function toQuery($query): Builder
    {
        // Better: Uses index on email column
        return $query->where('email', 'like', '%@example.com');
    }
}

Eager Loading with Specifications

php
class UserWithOrderDetailsSpecification extends AbstractSpecification
{
    public function toQuery($query): Builder
    {
        // Eager load related data to avoid N+1 queries
        return $query->with(['orders.items.product'])
            ->whereHas('orders');
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        // Take advantage of eager loaded data
        if ($candidate->relationLoaded('orders')) {
            return $candidate->orders->isNotEmpty();
        }
        
        return $candidate->orders()->exists();
    }
}

// Usage
$usersWithOrders = User::whereSpecification(
    new UserWithOrderDetailsSpecification()
)->get();

// All orders and related data are loaded in the initial query
foreach ($usersWithOrders as $user) {
    foreach ($user->orders as $order) {
        // No additional queries - data is already loaded
        echo $order->items->first()->product->name;
    }
}

Query Caching Specifications

php
class CachedUserSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly string $cacheKey,
        private readonly int $cacheTtl = 3600
    ) {}
    
    public function toQuery($query): Builder
    {
        // Cache the query results
        return $query->remember($this->cacheTtl);
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $candidate->status === 'active';
    }
    
    // Helper method for cache key generation
    public static function forActiveUsers(): self
    {
        return new self(
            cacheKey: 'specifications.active_users',
            cacheTtl: 3600
        );
    }
}

Collection Integration

Filtering Collections

php
$users = User::all(); // Loaded from database or cache
$spec = new ActiveVerifiedUserSpecification();

// Filter the collection
$activeUsers = $users->filter(
    fn($user) => $spec->isSatisfiedBy($user)
);

// More complex filtering
$eligibleUsers = $users->filter(function ($user) use ($spec) {
    return $spec->isSatisfiedBy($user) && $user->orders_count > 5;
});

Collection Partitioning

php
$users = User::with('orders')->get();
$spec = new PremiumUserSpecification();

// Partition into premium and regular users
[$premiumUsers, $regularUsers] = $users->partition(
    fn($user) => $spec->isSatisfiedBy($user)
);

echo "Premium users: " . $premiumUsers->count();
echo "Regular users: " . $regularUsers->count();

Collection Aggregation

php
class RevenueCalculator
{
    public function calculateBySpecification(
        Collection $orders, 
        SpecificationInterface $spec
    ): float {
        return $orders
            ->filter(fn($order) => $spec->isSatisfiedBy($order))
            ->sum('total_amount');
    }
}

// Usage
$orders = Order::with('user')->get();
$calculator = new RevenueCalculator();

$premiumRevenue = $calculator->calculateBySpecification(
    $orders,
    new PremiumUserOrderSpecification()
);

$newCustomerRevenue = $calculator->calculateBySpecification(
    $orders,
    new NewCustomerOrderSpecification()
);

Advanced Integration Patterns

Specification-Based Scopes

Create reusable query scopes with specifications:

php
// In your Eloquent model
class User extends Model
{
    public function scopeWhereSpecification($query, SpecificationInterface $spec)
    {
        return $spec->toQuery($query);
    }
    
    public function scopeActive($query)
    {
        return $query->whereSpecification(new ActiveUserSpecification());
    }
    
    public function scopeEligibleForDiscount($query)
    {
        return $query->whereSpecification(
            (new ActiveUserSpecification())
                ->and(new VerifiedUserSpecification())
                ->and(new MinimumPurchaseSpecification(100))
        );
    }
}

// Usage
$users = User::active()->eligibleForDiscount()->get();

Dynamic Query Building

php
class DynamicUserQueryBuilder
{
    private Builder $query;
    private array $appliedSpecs = [];
    
    public function __construct()
    {
        $this->query = User::query();
    }
    
    public function addSpecification(SpecificationInterface $spec, string $name = null): self
    {
        $this->query = $spec->toQuery($this->query);
        $this->appliedSpecs[] = $name ?? get_class($spec);
        return $this;
    }
    
    public function when(bool $condition, SpecificationInterface $spec): self
    {
        if ($condition) {
            $this->addSpecification($spec);
        }
        return $this;
    }
    
    public function getQuery(): Builder
    {
        return $this->query;
    }
    
    public function getAppliedSpecifications(): array
    {
        return $this->appliedSpecs;
    }
    
    public function get(): Collection
    {
        return $this->query->get();
    }
}

// Usage
$builder = new DynamicUserQueryBuilder();

$users = $builder
    ->addSpecification(new ActiveUserSpecification(), 'active')
    ->when($request->boolean('verified'), new VerifiedUserSpecification())
    ->when($request->filled('role'), new UserRoleSpecification($request->role))
    ->get();

// Debug applied specifications
logger('Applied specifications', [
    'specs' => $builder->getAppliedSpecifications(),
    'sql' => $builder->getQuery()->toSql()
]);

Query Result Analysis

php
class QueryAnalyzer
{
    public function analyzeSpecification(SpecificationInterface $spec): array
    {
        $query = User::whereSpecification($spec);
        
        return [
            'sql' => $query->toSql(),
            'bindings' => $query->getBindings(),
            'count' => $query->count(),
            'execution_time' => $this->measureExecutionTime($query),
            'explain' => $this->explainQuery($query),
        ];
    }
    
    private function measureExecutionTime(Builder $query): float
    {
        $start = microtime(true);
        $query->get();
        return microtime(true) - $start;
    }
    
    private function explainQuery(Builder $query): array
    {
        $connection = $query->getConnection();
        $sql = 'EXPLAIN ' . $query->toSql();
        
        return $connection->select($sql, $query->getBindings());
    }
}

// Usage
$analyzer = new QueryAnalyzer();
$analysis = $analyzer->analyzeSpecification(
    (new ActiveUserSpecification())
        ->and(new PremiumUserSpecification())
);

dump($analysis);

Testing Query Integration

Database Query Tests

php
class SpecificationQueryTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_specification_generates_correct_sql()
    {
        $spec = new ActiveUserSpecification();
        $query = User::whereSpecification($spec);
        
        $this->assertEquals(
            'select * from "users" where "status" = ?',
            $query->toSql()
        );
        
        $this->assertEquals(['active'], $query->getBindings());
    }
    
    public function test_specification_returns_correct_results()
    {
        // Create test data
        $activeUser = User::factory()->create(['status' => 'active']);
        $inactiveUser = User::factory()->create(['status' => 'inactive']);
        
        $spec = new ActiveUserSpecification();
        $results = User::whereSpecification($spec)->get();
        
        $this->assertCount(1, $results);
        $this->assertTrue($results->contains($activeUser));
        $this->assertFalse($results->contains($inactiveUser));
    }
    
    public function test_composed_specification_query()
    {
        $spec = (new ActiveUserSpecification())
            ->and(new VerifiedUserSpecification());
            
        $query = User::whereSpecification($spec);
        
        $expectedSql = 'select * from "users" where "status" = ? and "email_verified_at" is not null';
        $this->assertEquals($expectedSql, $query->toSql());
    }
    
    public function test_relationship_specification_query()
    {
        $spec = new UserWithOrdersSpecification();
        $query = User::whereSpecification($spec);
        
        $this->assertStringContainsString('exists', strtolower($query->toSql()));
        $this->assertStringContainsString('orders', $query->toSql());
    }
}

Performance Testing

php
class SpecificationPerformanceTest extends TestCase
{
    public function test_specification_query_performance()
    {
        // Create test data
        User::factory()->count(1000)->create(['status' => 'active']);
        User::factory()->count(1000)->create(['status' => 'inactive']);
        
        $spec = new ActiveUserSpecification();
        
        // Measure query execution time
        $start = microtime(true);
        $results = User::whereSpecification($spec)->get();
        $executionTime = microtime(true) - $start;
        
        // Assert performance is acceptable (adjust threshold as needed)
        $this->assertLessThan(0.1, $executionTime, 'Query took too long');
        $this->assertCount(1000, $results);
    }
    
    public function test_eager_loading_prevents_n_plus_one()
    {
        // Create users with orders
        User::factory()->count(10)
            ->has(Order::factory()->count(3))
            ->create(['status' => 'active']);
        
        DB::enableQueryLog();
        
        $spec = new UserWithOrderDetailsSpecification();
        $users = User::whereSpecification($spec)->get();
        
        // Access related data
        foreach ($users as $user) {
            $user->orders->count(); // Should not trigger additional queries
        }
        
        $queries = DB::getQueryLog();
        
        // Should only execute 1 query due to eager loading
        $this->assertLessThanOrEqual(1, count($queries));
    }
}

Troubleshooting Common Issues

Memory vs Query Inconsistency

php
// Problem: Inconsistent behavior between memory and query modes
class ProblematicSpecification extends AbstractSpecification
{
    public function isSatisfiedBy(mixed $candidate): bool
    {
        // Uses PHP date formatting
        return date('Y', strtotime($candidate->created_at)) === '2025';
    }
    
    public function toQuery($query): Builder
    {
        // Uses SQL date formatting - might be different!
        return $query->whereYear('created_at', 2025);
    }
}

// Solution: Keep logic consistent
class ConsistentDateSpecification extends AbstractSpecification
{
    private readonly Carbon $targetYear;
    
    public function __construct(int $year = null)
    {
        $this->targetYear = Carbon::createFromFormat('Y', $year ?? now()->year);
    }
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        return Carbon::parse($candidate->created_at)->year === $this->targetYear->year;
    }
    
    public function toQuery($query): Builder
    {
        return $query->whereYear('created_at', $this->targetYear->year);
    }
}

Relationship Loading Issues

php
// Problem: Assuming relationships are loaded
class BadRelationshipSpecification extends AbstractSpecification
{
    public function isSatisfiedBy(mixed $candidate): bool
    {
        // This will cause N+1 queries if orders aren't loaded!
        return $candidate->orders->count() > 5;
    }
}

// Solution: Check if relationship is loaded
class SafeRelationshipSpecification extends AbstractSpecification
{
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if ($candidate->relationLoaded('orders')) {
            return $candidate->orders->count() > 5;
        }
        
        // Fallback to efficient database query
        return $candidate->orders()->count() > 5;
    }
}

Next Steps

Now that you understand query integration, learn how to test your specifications thoroughly:


Query Mastery

Effective specifications seamlessly work in both memory and database contexts. Always consider performance implications and keep your memory and query logic consistent.

Next: Testing Strategies →

Released under the MIT License.