Laravel 12 Query Scopes: A Step-by-Step Guide with Practical Examples - Techvblogs

Laravel 12 Query Scopes: A Step-by-Step Guide with Practical Examples

Learn Laravel 12 query scopes with step-by-step instructions and real-world coding examples.


Suresh Ramani - Author - Techvblogs
Suresh Ramani
 

2 days ago

TechvBlogs - Google News

Query scopes in Laravel 12 represent one of Eloquent’s most powerful yet underutilized features for writing clean, maintainable database queries. Whether you’re filtering active users, organizing published content, or implementing complex business logic, mastering query scopes will transform how you structure your Laravel applications and eliminate repetitive query code across your codebase.

This comprehensive guide walks you through everything from basic local scopes to advanced global scope implementations, complete with real-world examples and best practices that will make your Laravel 12 applications more efficient and maintainable.

Why Query Scopes Matter in Laravel 12

Query scopes solve a fundamental problem in web application development: the tendency to repeat similar database queries throughout your application. Without scopes, you’ll find yourself writing the same filtering logic in multiple controllers, services, and models, leading to code duplication and maintenance headaches.

Laravel 12’s enhanced query scope functionality provides significant improvements over previous versions, including better performance optimization, cleaner syntax, and enhanced support for complex query conditions.

Benefits of Using Query Scopes for Clean, Reusable Code

Query scopes deliver several critical advantages for Laravel developers:

Code Reusability: Define complex filtering logic once and use it across your entire application. A scope like published() can be applied to blog posts, products, or any publishable content model.

Improved Readability: Transform complex SQL conditions into descriptive method names. Instead of where('status', 'active')->where('verified_at', '!=', null), you can simply write active()->verified().

Centralized Logic: Business rules and filtering conditions live directly in your models, making them easier to maintain and update as requirements change.

Enhanced Testing: Scopes can be tested independently, improving your application’s test coverage and reliability.

Query Optimization: Laravel 12’s scope implementation includes automatic query optimization, reducing database load and improving response times.

Understanding Query Scopes in Laravel

What Are Query Scopes and How They Work

Query scopes are methods defined in Eloquent models that encapsulate common query constraints. They act as shortcuts for complex WHERE clauses, joins, and other SQL operations, allowing you to build readable and maintainable database queries.

When you call a scope method on an Eloquent model, Laravel automatically passes the query builder instance to your scope method, enabling you to modify the query before execution.

// Without scopes - repetitive and hard to maintain
$activeUsers = User::where('status', 'active')
                  ->where('email_verified_at', '!=', null)
                  ->where('created_at', '>=', now()->subDays(30))
                  ->get();

// With scopes - clean and expressive
$activeUsers = User::active()->verified()->recent()->get();

Global vs Local Scopes: What’s the Difference?

Laravel 12 supports two distinct types of query scopes, each serving different purposes in your application architecture.

Local Scopes are defined directly in your model and called explicitly when building queries. They provide flexible, on-demand filtering that you control completely.

Global Scopes automatically apply to all queries for a specific model unless explicitly removed. They’re perfect for implementing application-wide business rules like soft deletes or multi-tenancy filtering.

The key difference lies in their application: local scopes are opt-in (you choose when to use them), while global scopes are opt-out (they apply automatically unless removed).

Setting Up the Laravel 12 Environment

Installing Laravel 12 and Preparing the Database

Before diving into query scopes, ensure you have Laravel 12 properly installed and configured. If you’re starting fresh, create a new Laravel 12 project:

composer create-project laravel/laravel laravel-scopes-demo
cd laravel-scopes-demo
php artisan serve

Configure your database connection in the .env file:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_scopes
DB_USERNAME=your_username
DB_PASSWORD=your_password

Creating a Sample Model for Scope Demonstration

For this guide, we’ll create a comprehensive Post model that demonstrates various scope scenarios. Generate the model, migration, and factory:

php artisan make:model Post -mf

Update the migration file to include fields that will showcase different scope types:

<?php
// database/migrations/create_posts_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('content');
            $table->enum('status', ['draft', 'published', 'archived'])->default('draft');
            $table->foreignId('user_id')->constrained();
            $table->timestamp('published_at')->nullable();
            $table->integer('view_count')->default(0);
            $table->boolean('is_featured')->default(false);
            $table->softDeletes();
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('posts');
    }
};

Run the migration to create your database structure:

php artisan migrate

Creating Local Scopes

Defining Your First Local Scope in a Model

Local scopes in Laravel 12 follow a simple naming convention: they must be prefixed with scope and use camelCase. The scope method receives the query builder instance as its first parameter.

Let’s create several practical local scopes in our Post model:

<?php
// app/Models/Post.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;

class Post extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'title', 'content', 'status', 'user_id', 
        'published_at', 'view_count', 'is_featured'
    ];

    protected $casts = [
        'published_at' => 'datetime',
        'is_featured' => 'boolean',
    ];

    // Basic scope for published posts
    public function scopePublished(Builder $query): Builder
    {
        return $query->where('status', 'published')
                    ->whereNotNull('published_at');
    }

    // Scope for draft posts
    public function scopeDraft(Builder $query): Builder
    {
        return $query->where('status', 'draft');
    }

    // Scope for featured posts
    public function scopeFeatured(Builder $query): Builder
    {
        return $query->where('is_featured', true);
    }

    // Scope for popular posts based on view count
    public function scopePopular(Builder $query, int $minViews = 100): Builder
    {
        return $query->where('view_count', '>=', $minViews);
    }
}

Using Local Scopes in Eloquent Queries

Once defined, local scopes can be used in your queries by calling them as methods on the Eloquent builder. Laravel automatically removes the scope prefix and converts the method name to snake_case when calling.

<?php
// In a controller or service

// Get all published posts
$publishedPosts = Post::published()->get();

// Get featured posts that are also published
$featuredPublished = Post::published()->featured()->get();

// Get popular posts with custom view threshold
$popularPosts = Post::popular(500)->get();

// Combine with standard Eloquent methods
$recentPopular = Post::popular()
                    ->where('created_at', '>=', now()->subDays(7))
                    ->orderBy('view_count', 'desc')
                    ->take(10)
                    ->get();

Chaining Multiple Scopes for Advanced Filtering

One of the most powerful features of Laravel scopes is their ability to chain together, creating complex queries that remain readable and maintainable.

<?php
// Complex query using multiple scopes
$complexQuery = Post::published()
                   ->featured()
                   ->popular(200)
                   ->with('user')
                   ->orderBy('published_at', 'desc')
                   ->paginate(15);

// Conditional scope application
$query = Post::query();

if ($request->has('featured')) {
    $query->featured();
}

if ($request->has('popular')) {
    $query->popular($request->input('min_views', 100));
}

$results = $query->published()->paginate(10);

Real-World Examples of Local Scopes

Scope for Active Users or Published Posts

In real applications, you’ll frequently need to filter records based on their active status. Here are comprehensive examples that handle various scenarios:

<?php
// Enhanced Post model with comprehensive status scopes

class Post extends Model
{
    // Scope for published posts with additional validation
    public function scopePublished(Builder $query): Builder
    {
        return $query->where('status', 'published')
                    ->whereNotNull('published_at')
                    ->where('published_at', '<=', now());
    }

    // Scope for archived posts
    public function scopeArchived(Builder $query): Builder
    {
        return $query->where('status', 'archived');
    }

    // Scope for posts visible to public
    public function scopeVisible(Builder $query): Builder
    {
        return $query->whereIn('status', ['published'])
                    ->whereNull('deleted_at');
    }

    // Scope for posts by specific status
    public function scopeByStatus(Builder $query, string $status): Builder
    {
        return $query->where('status', $status);
    }
}

// Usage examples
$visiblePosts = Post::visible()->get();
$draftPosts = Post::byStatus('draft')->get();

Filtering Records Based on Date Ranges

Date-based filtering is extremely common in web applications. Laravel 12’s enhanced date handling makes creating date scopes more intuitive:

<?php
// Date-based scopes in Post model

public function scopePublishedAfter(Builder $query, $date): Builder
{
    return $query->where('published_at', '>=', $date);
}

public function scopePublishedBefore(Builder $query, $date): Builder
{
    return $query->where('published_at', '<=', $date);
}

public function scopePublishedBetween(Builder $query, $startDate, $endDate): Builder
{
    return $query->whereBetween('published_at', [$startDate, $endDate]);
}

public function scopeRecent(Builder $query, int $days = 7): Builder
{
    return $query->where('created_at', '>=', now()->subDays($days));
}

public function scopeThisMonth(Builder $query): Builder
{
    return $query->whereMonth('created_at', now()->month)
                ->whereYear('created_at', now()->year);
}

public function scopeThisYear(Builder $query): Builder
{
    return $query->whereYear('created_at', now()->year);
}

// Usage examples
$recentPosts = Post::recent(14)->published()->get();
$monthlyPosts = Post::thisMonth()->get();
$yearRange = Post::publishedBetween('2024-01-01', '2024-12-31')->get();

Applying User Roles or Permissions with Scopes

When building applications with user roles and permissions, scopes help maintain clean separation of concerns:

<?php
// User model with role-based scopes

class User extends Model
{
    public function scopeAdmins(Builder $query): Builder
    {
        return $query->where('role', 'admin');
    }

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('status', 'active')
                    ->whereNotNull('email_verified_at');
    }

    public function scopeWithRole(Builder $query, string $role): Builder
    {
        return $query->where('role', $role);
    }

    public function scopeVerified(Builder $query): Builder
    {
        return $query->whereNotNull('email_verified_at');
    }
}

// Post model with user-permission-aware scopes
class Post extends Model
{
    public function scopeVisibleToUser(Builder $query, User $user): Builder
    {
        if ($user->role === 'admin') {
            return $query; // Admins see everything
        }

        return $query->where(function ($q) use ($user) {
            $q->where('user_id', $user->id)
              ->orWhere('status', 'published');
        });
    }

    public function scopeOwnedBy(Builder $query, User $user): Builder
    {
        return $query->where('user_id', $user->id);
    }
}

// Usage examples
$adminUsers = User::admins()->active()->get();
$userPosts = Post::visibleToUser(auth()->user())->get();
$myDrafts = Post::ownedBy(auth()->user())->draft()->get();

Creating Global Scopes

How to Register a Global Scope

Global scopes automatically apply to all queries for a model unless explicitly removed. Laravel 12 provides two ways to implement global scopes: using closure-based scopes or dedicated scope classes.

Closure-Based Global Scopes

Register closure-based global scopes in your model’s booted method:

<?php
// Simple global scope using closure

class Post extends Model
{
    protected static function booted()
    {
        // Automatically exclude soft-deleted posts
        static::addGlobalScope('notDeleted', function (Builder $builder) {
            $builder->whereNull('deleted_at');
        });

        // Automatically exclude draft posts for non-admin users
        static::addGlobalScope('publishedOnly', function (Builder $builder) {
            if (!auth()->check() || !auth()->user()->isAdmin()) {
                $builder->where('status', 'published');
            }
        });
    }
}

Class-Based Global Scopes

For more complex logic, create dedicated scope classes:

php artisan make:scope PublishedScope
<?php
// app/Scopes/PublishedScope.php

namespace App\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class PublishedScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('status', 'published')
                ->whereNotNull('published_at')
                ->where('published_at', '<=', now());
    }

    public function extend(Builder $builder): void
    {
        $builder->macro('withDrafts', function (Builder $builder) {
            return $builder->withoutGlobalScope($this);
        });

        $builder->macro('onlyDrafts', function (Builder $builder) {
            return $builder->withoutGlobalScope($this)
                          ->where('status', 'draft');
        });
    }
}

Register the class-based scope in your model:

<?php
// In your Post model

use App\Scopes\PublishedScope;

protected static function booted()
{
    static::addGlobalScope(new PublishedScope);
}

When and Why to Use Global Scopes

Global scopes are ideal for implementing business rules that should apply consistently across your application:

  • Multi-tenancy: Automatically filter records by tenant ID
  • Soft deletes: Exclude deleted records by default
  • Access control: Hide private or restricted content
  • Data integrity: Ensure only valid records are retrieved

However, use global scopes judiciously. They can make debugging more difficult and may cause unexpected behavior if not properly documented.

Example: Automatically Filtering Soft Deleted Records

While Laravel includes built-in soft delete functionality, here’s how you might implement custom deletion logic with global scopes:

<?php
// Custom soft delete implementation

namespace App\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class CustomSoftDeleteScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->whereNull($model->getQualifiedDeletedAtColumn());
    }

    public function extend(Builder $builder): void
    {
        $builder->macro('withTrashed', function (Builder $builder) {
            return $builder->withoutGlobalScope($this);
        });

        $builder->macro('onlyTrashed', function (Builder $builder) {
            return $builder->withoutGlobalScope($this)
                          ->whereNotNull($this->getDeletedAtColumn());
        });

        $builder->macro('restore', function (Builder $builder) {
            return $builder->update([$this->getDeletedAtColumn() => null]);
        });
    }

    protected function getDeletedAtColumn(): string
    {
        return 'deleted_at';
    }
}

Dynamic and Conditional Scopes

Building Scopes with Parameters

Dynamic scopes accept parameters to create flexible filtering conditions. Laravel 12 enhances parameter handling with better type hinting and validation:

<?php
// Advanced parameterized scopes

class Post extends Model
{
    // Scope with multiple optional parameters
    public function scopeFilterByDateRange(
        Builder $query, 
        ?string $startDate = null, 
        ?string $endDate = null,
        string $column = 'created_at'
    ): Builder {
        if ($startDate) {
            $query->where($column, '>=', $startDate);
        }

        if ($endDate) {
            $query->where($column, '<=', $endDate);
        }

        return $query;
    }

    // Scope with array parameters
    public function scopeWithStatuses(Builder $query, array $statuses): Builder
    {
        return $query->whereIn('status', $statuses);
    }

    // Scope with complex parameter validation
    public function scopeByViewCountRange(
        Builder $query, 
        int $min = 0, 
        ?int $max = null
    ): Builder {
        $query->where('view_count', '>=', max(0, $min));

        if ($max !== null && $max > $min) {
            $query->where('view_count', '<=', $max);
        }

        return $query;
    }

    // Scope with callback parameter for advanced filtering
    public function scopeWhereCallback(Builder $query, callable $callback): Builder
    {
        return $query->where($callback);
    }
}

// Usage examples
$posts = Post::filterByDateRange('2024-01-01', '2024-12-31', 'published_at')->get();
$multiStatus = Post::withStatuses(['published', 'featured'])->get();
$viewRange = Post::byViewCountRange(100, 1000)->get();

// Advanced callback usage
$complexFilter = Post::whereCallback(function ($query) {
    $query->where('view_count', '>', 100)
          ->orWhere(function ($subQuery) {
              $subQuery->where('is_featured', true)
                       ->where('created_at', '>=', now()->subDays(7));
          });
})->get();

Applying Scopes Conditionally in Controllers or Services

Build flexible query builders that apply scopes based on request parameters or business logic:

<?php
// Controller with conditional scope application

class PostController extends Controller
{
    public function index(Request $request)
    {
        $query = Post::query();

        // Apply scopes based on request parameters
        if ($request->filled('status')) {
            $query->byStatus($request->status);
        } else {
            // Default to published posts for public users
            if (!auth()->check() || !auth()->user()->isAdmin()) {
                $query->published();
            }
        }

        if ($request->boolean('featured')) {
            $query->featured();
        }

        if ($request->filled('min_views')) {
            $query->popular($request->integer('min_views'));
        }

        if ($request->filled('date_from') || $request->filled('date_to')) {
            $query->filterByDateRange(
                $request->date_from, 
                $request->date_to,
                'published_at'
            );
        }

        // Apply sorting
        $sortBy = $request->input('sort', 'published_at');
        $sortDirection = $request->input('direction', 'desc');
        $query->orderBy($sortBy, $sortDirection);

        return $query->with('user')->paginate(15);
    }
}

// Service class with conditional scope logic
class PostService
{
    public function getPostsForUser(User $user, array $filters = []): Collection
    {
        $query = Post::query();

        // Apply user-specific visibility rules
        if ($user->isAdmin()) {
            // Admins see all posts
        } elseif ($user->isEditor()) {
            $query->where(function ($q) use ($user) {
                $q->published()->orWhere('user_id', $user->id);
            });
        } else {
            $query->published();
        }

        // Apply additional filters
        foreach ($filters as $filter => $value) {
            match ($filter) {
                'featured' => $query->when($value, fn($q) => $q->featured()),
                'popular' => $query->when($value, fn($q) => $q->popular()),
                'recent' => $query->when($value, fn($q) => $q->recent()),
                'category' => $query->when($value, fn($q) => $q->where('category_id', $value)),
                default => null
            };
        }

        return $query->get();
    }
}

Organizing and Reusing Scopes

Extracting Scopes to Traits for Reusability

When multiple models share similar scoping logic, extract common scopes into reusable traits:

<?php
// app/Traits/HasStatusScopes.php

namespace App\Traits;

use Illuminate\Database\Eloquent\Builder;

trait HasStatusScopes
{
    public function scopeActive(Builder $query): Builder
    {
        return $query->where('status', 'active');
    }

    public function scopeInactive(Builder $query): Builder
    {
        return $query->where('status', 'inactive');
    }

    public function scopeByStatus(Builder $query, string $status): Builder
    {
        return $query->where('status', $status);
    }

    public function scopeWithStatuses(Builder $query, array $statuses): Builder
    {
        return $query->whereIn('status', $statuses);
    }
}

// app/Traits/HasDateScopes.php
namespace App\Traits;

use Illuminate\Database\Eloquent\Builder;

trait HasDateScopes
{
    public function scopeCreatedAfter(Builder $query, $date): Builder
    {
        return $query->where('created_at', '>=', $date);
    }

    public function scopeCreatedBefore(Builder $query, $date): Builder
    {
        return $query->where('created_at', '<=', $date);
    }

    public function scopeCreatedBetween(Builder $query, $startDate, $endDate): Builder
    {
        return $query->whereBetween('created_at', [$startDate, $endDate]);
    }

    public function scopeRecent(Builder $query, int $days = 7): Builder
    {
        return $query->where('created_at', '>=', now()->subDays($days));
    }
}

// Using traits in models
class Post extends Model
{
    use HasStatusScopes, HasDateScopes;
    
    // Model-specific scopes
    public function scopePublished(Builder $query): Builder
    {
        return $query->active()->whereNotNull('published_at');
    }
}

class Product extends Model
{
    use HasStatusScopes, HasDateScopes;
    
    // Product-specific scopes
    public function scopeInStock(Builder $query): Builder
    {
        return $query->active()->where('quantity', '>', 0);
    }
}

Best Practices for Scope Naming and Structure

Follow these conventions for maintainable and intuitive scope design:

Naming Conventions:

  • Use descriptive, action-oriented names: published()featured()recent()
  • Prefix filter scopes with “by” or “with”: byStatus()withCategory()
  • Use boolean adjectives for state-based scopes: active()verified()archived()

Structure Guidelines:

  • Keep scopes focused on a single responsibility
  • Use parameters for flexibility, defaults for convenience
  • Return the Builder instance for method chaining
  • Document complex scopes with DocBlocks
<?php
// Well-structured scope examples

class Post extends Model
{
    /**
     * Scope for posts published within a specific date range.
     *
     * @param Builder $query
     * @param string|null $startDate
     * @param string|null $endDate
     * @return Builder
     */
    public function scopePublishedBetween(
        Builder $query, 
        ?string $startDate = null, 
        ?string $endDate = null
    ): Builder {
        $query->published();

        if ($startDate) {
            $query->where('published_at', '>=', $startDate);
        }

        if ($endDate) {
            $query->where('published_at', '<=', $endDate);
        }

        return $query;
    }

    /**
     * Scope for posts with minimum view count.
     * Includes validation to prevent negative values.
     */
    public function scopePopular(Builder $query, int $minViews = 100): Builder
    {
        return $query->where('view_count', '>=', max(0, $minViews));
    }
}

Testing Query Scopes

Writing Unit Tests for Scopes

Comprehensive testing ensures your scopes work correctly and handle edge cases appropriately:

<?php
// tests/Unit/PostScopeTest.php

namespace Tests\Unit;

use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PostScopeTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        
        // Create test data
        $user = User::factory()->create();
        
        // Create posts with different statuses
        Post::factory()->create([
            'status' => 'published',
            'published_at' => now()->subDays(1),
            'user_id' => $user->id
        ]);
        
        Post::factory()->create([
            'status' => 'draft',
            'published_at' => null,
            'user_id' => $user->id
        ]);
        
        Post::factory()->create([
            'status' => 'published',
            'published_at' => now()->subDays(3),
            'is_featured' => true,
            'view_count' => 500,
            'user_id' => $user->id
        ]);
    }

    public function test_published_scope_returns_only_published_posts()
    {
        $publishedPosts = Post::published()->get();
        
        $this->assertCount(2, $publishedPosts);
        $publishedPosts->each(function ($post) {
            $this->assertEquals('published', $post->status);
            $this->assertNotNull($post->published_at);
        });
    }

    public function test_draft_scope_returns_only_draft_posts()
    {
        $draftPosts = Post::draft()->get();
        
        $this->assertCount(1, $draftPosts);
        $this->assertEquals('draft', $draftPosts->first()->status);
    }

    public function test_featured_scope_returns_only_featured_posts()
    {
        $featuredPosts = Post::featured()->get();
        
        $this->assertCount(1, $featuredPosts);
        $this->assertTrue($featuredPosts->first()->is_featured);
    }

    public function test_popular_scope_with_default_threshold()
    {
        $popularPosts = Post::popular()->get();
        
        $popularPosts->each(function ($post) {
            $this->assertGreaterThanOrEqual(100, $post->view_count);
        });
    }

    public function test_popular_scope_with_custom_threshold()
    {
        $popularPosts = Post::popular(400)->get();
        
        $this->assertCount(1, $popularPosts);
        $this->assertGreaterThanOrEqual(400, $popularPosts->first()->view_count);
    }

    public function test_chaining_multiple_scopes()
    {
        $complexQuery = Post::published()->featured()->popular(400)->get();
        
        $this->assertCount(1, $complexQuery);
        
        $post = $complexQuery->first();
        $this->assertEquals('published', $post->status);
        $this->assertTrue($post->is_featured);
        $this->assertGreaterThanOrEqual(400, $post->view_count);
    }

    public function test_scope_with_date_parameters()
    {
        $recentPosts = Post::publishedBetween(
            now()->subDays(2)->toDateString(), 
            now()->toDateString()
        )->get();
        
        $this->assertCount(1, $recentPosts);
    }
}

Mocking Scopes in Feature or API Tests

For integration tests, you might need to mock or stub scope behavior:

<?php
// tests/Feature/PostApiTest.php

namespace Tests\Feature;

use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PostApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_api_returns_published_posts_for_guests()
    {
        // Create test posts
        Post::factory()->published()->count(3)->create();
        Post::factory()->draft()->count(2)->create();

        $response = $this->getJson('/api/posts');

        $response->assertStatus(200)
                ->assertJsonCount(3, 'data');
    }

    public function test_api_filters_posts_by_status()
    {
        Post::factory()->published()->count(2)->create();
        Post::factory()->draft()->count(1)->create();
        Post::factory()->archived()->count(1)->create();

        $response = $this->getJson('/api/posts?status=published');

        $response->assertStatus(200)
                ->assertJsonCount(2, 'data');
    }

    public function test_api_filters_posts_by_popularity()
    {
        Post::factory()->published()->create(['view_count' => 50]);
        Post::factory()->published()->create(['view_count' => 150]);
        Post::factory()->published()->create(['view_count' => 300]);

        $response = $this->getJson('/api/posts?min_views=100');

        $response->assertStatus(200)
                ->assertJsonCount(2, 'data');
    }
}

Common Pitfalls and How to Avoid Them

Scope Conflicts and Overlapping Filters

When multiple scopes modify the same query conditions, conflicts can arise. Here’s how to handle them effectively:

<?php
// Problematic scope overlap

class Post extends Model
{
    // These scopes conflict when chained together
    public function scopePublished(Builder $query): Builder
    {
        return $query->where('status', 'published');
    }

    public function scopeByStatus(Builder $query, string $status): Builder
    {
        return $query->where('status', $status);
    }
}

// This creates conflicting WHERE clauses
$posts = Post::published()->byStatus('draft')->get(); // Returns no results
<?php
// Better approach - mutually exclusive scopes
class Post extends Model
{
    public function scopePublished(Builder $query): Builder
    {
        return $query->where('status', 'published')
                    ->whereNotNull('published_at');
    }

    public function scopeByStatus(Builder $query, string $status): Builder
    {
        // Remove any existing status constraints before applying new ones
        $query->getQuery()->wheres = collect($query->getQuery()->wheres)
            ->reject(function ($where) {
                return isset($where['column']) && $where['column'] === 'status';
            })
            ->values()
            ->toArray();

        return $query->where('status', $status);
    }

    // Alternative: Use conditional logic within scopes
    public function scopeWithStatus(Builder $query, array $statuses): Builder
    {
        if (count($statuses) === 1) {
            return $query->where('status', $statuses[0]);
        }
        
        return $query->whereIn('status', $statuses);
    }
}

// Usage that avoids conflicts
$publishedPosts = Post::withStatus(['published'])->get();
$multipleStatuses = Post::withStatus(['published', 'featured'])->get();

Debugging Complex Queries Involving Multiple Scopes

Laravel 12 provides enhanced debugging tools for complex scope chains:

<?php
// Debugging scope queries

class PostController extends Controller
{
    public function debugComplexQuery()
    {
        $query = Post::published()
                    ->featured()
                    ->popular(100)
                    ->recent(30);

        // Laravel 12 enhanced query debugging
        if (app()->environment('local')) {
            // Log the SQL query with bindings
            logger()->info('Complex query SQL', [
                'sql' => $query->toSql(),
                'bindings' => $query->getBindings(),
                'raw_sql' => $query->toRawSql() // New in Laravel 12
            ]);

            // Debug query performance
            $start = microtime(true);
            $results = $query->get();
            $duration = microtime(true) - $start;
            
            logger()->info('Query performance', [
                'duration' => $duration,
                'result_count' => $results->count(),
                'memory_usage' => memory_get_peak_usage(true)
            ]);
        }

        return $query->get();
    }

    // Method to test individual scopes
    public function testScopes()
    {
        $baseQuery = Post::query();
        
        $tests = [
            'published' => $baseQuery->published(),
            'featured' => $baseQuery->featured(),
            'popular' => $baseQuery->popular(100),
            'combined' => $baseQuery->published()->featured()->popular(100)
        ];

        foreach ($tests as $name => $query) {
            logger()->info("Scope test: {$name}", [
                'sql' => $query->toSql(),
                'count' => $query->count()
            ]);
        }
    }
}

// Custom scope debugging trait
trait DebuggableScopes
{
    public function scopeDebug(Builder $query, string $label = 'Query'): Builder
    {
        if (app()->environment('local')) {
            logger()->info("{$label} Debug", [
                'sql' => $query->toSql(),
                'bindings' => $query->getBindings()
            ]);
        }
        
        return $query;
    }
}

// Usage in models
class Post extends Model
{
    use DebuggableScopes;
    
    // Your scopes here...
}

// Debug usage
$posts = Post::published()
            ->debug('After published scope')
            ->featured()
            ->debug('After featured scope')
            ->get();

Performance Considerations

Impact of Query Scopes on Database Performance

While query scopes improve code organization, they can impact performance if not used carefully. Here are optimization strategies:

<?php
// Performance-optimized scopes

class Post extends Model
{
    // Inefficient scope - causes N+1 queries
    public function scopeWithAuthorBadExample(Builder $query): Builder
    {
        return $query->get()->load('user'); // Don't do this!
    }

    // Efficient scope - uses eager loading
    public function scopeWithAuthor(Builder $query): Builder
    {
        return $query->with('user');
    }

    // Scope that adds database indexes hint
    public function scopeOptimizedPopular(Builder $query, int $minViews = 100): Builder
    {
        // Ensure there's an index on view_count column
        return $query->where('view_count', '>=', $minViews)
                    ->orderBy('view_count', 'desc');
    }

    // Scope with query optimization for large datasets
    public function scopeEfficientPagination(Builder $query, int $page = 1, int $perPage = 15): Builder
    {
        // Use cursor pagination for better performance on large datasets
        $offset = ($page - 1) * $perPage;
        
        return $query->orderBy('id')
                    ->skip($offset)
                    ->take($perPage);
    }

    // Scope that uses database-specific optimizations
    public function scopeFullTextSearch(Builder $query, string $term): Builder
    {
        // Use database-specific full-text search when available
        if (config('database.default') === 'mysql') {
            return $query->whereRaw(
                "MATCH(title, content) AGAINST(? IN BOOLEAN MODE)",
                [$term]
            );
        }

        // Fallback to LIKE queries
        return $query->where(function ($q) use ($term) {
            $q->where('title', 'LIKE', "%{$term}%")
              ->orWhere('content', 'LIKE', "%{$term}%");
        });
    }
}

Optimizing Scopes for Large Datasets

When working with large datasets, consider these optimization techniques:

<?php
// Large dataset optimization strategies

class Post extends Model
{
    // Use chunking for large result sets
    public function scopeProcessInChunks(Builder $query, callable $callback, int $chunkSize = 1000): void
    {
        $query->chunkById($chunkSize, $callback);
    }

    // Scope with selective column loading
    public function scopeMinimalData(Builder $query): Builder
    {
        return $query->select(['id', 'title', 'status', 'created_at']);
    }

    // Scope that uses raw queries for complex aggregations
    public function scopeWithViewStats(Builder $query): Builder
    {
        return $query->selectRaw(
            'posts.*, 
             (SELECT AVG(view_count) FROM posts p2 WHERE p2.user_id = posts.user_id) as avg_user_views,
             (SELECT COUNT(*) FROM posts p3 WHERE p3.user_id = posts.user_id) as user_post_count'
        );
    }

    // Scope with conditional eager loading
    public function scopeWithConditionalRelations(Builder $query, array $relations = []): Builder
    {
        if (!empty($relations)) {
            $query->with($relations);
        }
        
        return $query;
    }

    // Memory-efficient scope for exports
    public function scopeForExport(Builder $query): Builder
    {
        return $query->select(['id', 'title', 'status', 'created_at', 'view_count'])
                    ->orderBy('id')
                    ->cursor(); // Returns a lazy collection
    }
}

// Usage examples for large datasets
class PostService
{
    public function generateReport(): void
    {
        // Process large datasets in chunks to avoid memory issues
        Post::published()
            ->processInChunks(function ($posts) {
                foreach ($posts as $post) {
                    // Process each post
                    $this->processPostForReport($post);
                }
            }, 500);
    }

    public function exportPosts(): Collection
    {
        // Use cursor for memory-efficient iteration
        return Post::published()
                  ->forExport()
                  ->lazy(); // Laravel 12 enhanced lazy collections
    }
}

Add appropriate database indexes to support your scopes:

<?php
// Migration with performance indexes

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::table('posts', function (Blueprint $table) {
            // Indexes for common scope queries
            $table->index(['status', 'published_at']); // For published() scope
            $table->index(['is_featured', 'status']); // For featured() + status
            $table->index(['view_count']); // For popular() scope
            $table->index(['user_id', 'status']); // For user-specific queries
            $table->index(['created_at']); // For date-based scopes
            
            // Composite index for complex scope combinations
            $table->index(['status', 'is_featured', 'view_count'], 'posts_complex_filter_index');
            
            // Full-text index for search scope (MySQL specific)
            if (config('database.default') === 'mysql') {
                $table->fullText(['title', 'content'], 'posts_fulltext_index');
            }
        });
    }

    public function down()
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropIndex(['status', 'published_at']);
            $table->dropIndex(['is_featured', 'status']);
            $table->dropIndex(['view_count']);
            $table->dropIndex(['user_id', 'status']);
            $table->dropIndex(['created_at']);
            $table->dropIndex('posts_complex_filter_index');
            
            if (config('database.default') === 'mysql') {
                $table->dropFullText('posts_fulltext_index');
            }
        });
    }
};

Conclusion

Key Takeaways for Mastering Query Scopes in Laravel 12

Query scopes represent one of Laravel’s most powerful features for creating maintainable, readable, and reusable database query logic. Throughout this guide, we’ve explored how scopes can transform complex SQL operations into intuitive, chainable methods that make your codebase more expressive and easier to maintain.

The enhanced scope functionality in Laravel 12 provides developers with unprecedented flexibility and performance optimizations. From simple local scopes that encapsulate common filtering logic to sophisticated global scopes that enforce business rules across your entire application, mastering these concepts will significantly improve your Laravel development workflow.

Key benefits you’ll gain from implementing query scopes include:

  • Reduced code duplication through reusable query logic
  • Improved readability with descriptive, chainable method names
  • Enhanced maintainability by centralizing business rules in models
  • Better testing capabilities with isolated, testable query components
  • Performance optimizations through Laravel 12’s enhanced query building

When to Use Scopes and When to Avoid Them for Cleaner Code Architecture

Understanding when to implement query scopes versus alternative approaches is crucial for maintaining clean architecture:

Use Query Scopes When:

  • You find yourself repeating the same query conditions across multiple parts of your application
  • Business logic needs to be consistently applied to model queries
  • You want to create more expressive and readable database queries
  • Complex filtering logic would benefit from being broken into smaller, composable parts
  • You need to maintain backward compatibility while refactoring query logic

Consider Alternatives When:

  • Query logic is highly specific to a single use case
  • Performance requirements demand raw SQL optimization
  • The scope would add unnecessary complexity to simple queries
  • Global scopes might interfere with debugging or testing

Avoid Query Scopes When:

  • The query logic changes frequently and would require constant scope modifications
  • You’re dealing with one-off reports or data migrations
  • The performance overhead of additional method calls outweighs the benefits

By following the patterns, best practices, and optimization techniques covered in this guide, you’ll be equipped to leverage Laravel 12’s query scopes effectively in your applications. Remember that scopes should serve your application’s architecture, not complicate it—use them judiciously to create more maintainable and expressive code that stands the test of time.

For further exploration, consider diving into Laravel’s official Eloquent documentation and exploring advanced topics like custom collection methods, query builder macros, and database-specific optimizations that complement your scope implementation strategy.

Comments (0)

Comment


Note: All Input Fields are required.