Laravel 12 Local Scope: Simplify Your Eloquent Queries Like a Pro

Suresh RamaniSuresh Ramani
25 min read

Table of contents

Laravel's Eloquent ORM continues to evolve, and with Laravel 12, local scopes remain one of the most powerful yet underutilized features for writing clean, maintainable database queries. If you've ever found yourself writing repetitive where clauses or struggling to keep your controllers slim, local scopes are about to become your new best friend.

Local scopes allow you to encapsulate common query logic directly within your Eloquent models, making your code more readable, reusable, and easier to test. Whether you're filtering active users, published posts, or implementing complex business logic, local scopes transform verbose query chains into elegant, expressive method calls.

In this comprehensive guide, we'll explore everything you need to know about Laravel 12 local scopes, from basic implementation to advanced techniques that will elevate your Eloquent game to professional levels.

What Are Eloquent Scopes and Why They Matter

Eloquent scopes are methods that allow you to define reusable query constraints for your models. Think of them as pre-built query filters that you can apply whenever needed, without repeating the same where conditions throughout your application.

Before local scopes, you might write queries like this across multiple controllers:

$activeUsers = User::where('status', 'active')->where('email_verified_at', '!=', null)->get();
$publishedPosts = Post::where('status', 'published')->where('published_at', '<=', now())->get();

With local scopes, these become:

$activeUsers = User::active()->verified()->get();
$publishedPosts = Post::published()->get();

The difference is immediately apparent—cleaner code, better readability, and centralized query logic.

Benefits of Using Local Scopes in Laravel 12

Local scopes offer several compelling advantages for modern Laravel applications:

Code Reusability: Define query logic once, use it everywhere. No more copy-pasting complex where clauses across controllers, jobs, or commands.

Improved Readability: Scope names act as self-documenting code, making your queries instantly understandable to other developers (and future you).

Centralized Logic: Business rules live in your models where they belong, not scattered across your application layer.

Easier Testing: Test query logic in isolation by testing individual scopes rather than complex controller methods.

Better Maintainability: Change business rules in one place, and the updates automatically apply everywhere the scope is used.

Enhanced Performance: Laravel 12's query optimization works seamlessly with scopes, ensuring your filtered queries remain fast and efficient.

Understanding the Basics of Local Scopes

What Is a Local Scope in Eloquent?

A local scope is a method defined in your Eloquent model that begins with the word "scope" followed by a capitalized method name. When called, Laravel automatically removes the "scope" prefix and makes the method available as a chainable query method.

// In your model
public function scopeActive($query)
{
    return $query->where('status', 'active');
}

// In your controller or service
$users = User::active()->get();

Local scopes receive the query builder instance as their first parameter, allowing you to modify the query before it executes.

When and Why to Use Local Scopes Over Inline Queries

Choose local scopes when you find yourself:

  • Repeating the same query conditions across multiple parts of your application

  • Implementing complex business logic that involves multiple conditions

  • Filtering data based on user permissions or application state

  • Creating readable, self-documenting queries that express business intent

  • Building API endpoints that need consistent filtering logic

Avoid local scopes for one-off queries or simple conditions that won't be reused. The overhead isn't worth it for basic where('id', $id) type queries.

Creating Your First Local Scope

Syntax for Defining a Local Scope in a Model

Creating a local scope follows a simple pattern. Here's the basic syntax:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    /**
     * Scope a query to only include active users.
     */
    public function scopeActive($query)
    {
        return $query->where('status', 'active');
    }

    /**
     * Scope a query to only include verified users.
     */
    public function scopeVerified($query)
    {
        return $query->whereNotNull('email_verified_at');
    }

    /**
     * Scope a query to only include users created in the last days.
     */
    public function scopeRecent($query, $days = 30)
    {
        return $query->where('created_at', '>=', now()->subDays($days));
    }
}

Naming Conventions and Scope Best Practices

Follow these naming conventions for professional, maintainable scopes:

Use Descriptive Names: Choose names that clearly describe what the scope does

  • scopePublished()scopeActive()scopeExpired()

  • scopeFilter()scopeCustom()scopeSpecial()

Use Camel Case: Laravel automatically converts camelCase to snake_case when calling scopes

  • scopeEmailVerified() becomes emailVerified()

  • scopeCreatedThisWeek() becomes createdThisWeek()

Be Consistent: Establish patterns across your application

  • Use active/inactive consistently instead of mixing with enabled/disabled

  • Stick to a naming pattern like byStatus(), byType(), byDate()

Document Complex Logic: Use PHPDoc comments to explain scope behavior

/**
 * Scope a query to include users with specific subscription status.
 * 
 * @param \Illuminate\Database\Eloquent\Builder $query
 * @param string $status The subscription status (active, cancelled, expired)
 * @return \Illuminate\Database\Eloquent\Builder
 */
public function scopeSubscriptionStatus($query, $status)
{
    return $query->where('subscription_status', $status)
                 ->where('subscription_expires_at', '>', now());
}

Using Local Scopes in Your Queries

How to Apply a Local Scope in Eloquent

Once defined, local scopes integrate seamlessly into your Eloquent queries. Here's how to use them:

// Basic scope usage
$activeUsers = User::active()->get();
$verifiedUsers = User::verified()->get();

// Scopes with parameters
$recentUsers = User::recent(7)->get(); // Users from last 7 days
$premiumUsers = User::subscriptionStatus('premium')->get();

// Scopes in complex queries
$user = User::active()
           ->verified()
           ->where('email', $email)
           ->first();

Chaining Local Scopes with Other Query Methods

Local scopes work perfectly with all Eloquent query methods:

// Combining scopes with standard query methods
$users = User::active()
            ->verified()
            ->where('city', 'New York')
            ->orderBy('created_at', 'desc')
            ->paginate(15);

// Using scopes with aggregate functions
$activeUserCount = User::active()->count();
$averageAge = User::active()->verified()->avg('age');

// Scopes with relationships
$usersWithPosts = User::active()
                     ->verified()
                     ->has('posts')
                     ->with('posts')
                     ->get();

Real-World Examples of Local Scopes

Filtering Active Users with a Local Scope

Here's a comprehensive example of user filtering scopes:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;

class User extends Model
{
    /**
     * Scope for active users only.
     */
    public function scopeActive($query)
    {
        return $query->where('status', 'active')
                    ->whereNull('deleted_at');
    }

    /**
     * Scope for verified users.
     */
    public function scopeVerified($query)
    {
        return $query->whereNotNull('email_verified_at');
    }

    /**
     * Scope for users with complete profiles.
     */
    public function scopeProfileComplete($query)
    {
        return $query->whereNotNull('first_name')
                    ->whereNotNull('last_name')
                    ->whereNotNull('phone');
    }

    /**
     * Scope for users by registration date range.
     */
    public function scopeRegisteredBetween($query, $startDate, $endDate)
    {
        return $query->whereBetween('created_at', [
            Carbon::parse($startDate)->startOfDay(),
            Carbon::parse($endDate)->endOfDay()
        ]);
    }
}

Usage examples:

// Get all active, verified users with complete profiles
$qualifiedUsers = User::active()
                     ->verified()
                     ->profileComplete()
                     ->get();

// Get users registered in the last month
$newUsers = User::active()
               ->registeredBetween(now()->subMonth(), now())
               ->count();

Creating a Scope for Published Blog Posts

Blog post scopes demonstrate more complex filtering logic:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;

class Post extends Model
{
    /**
     * Scope for published posts only.
     */
    public function scopePublished($query)
    {
        return $query->where('status', 'published')
                    ->where('published_at', '<=', now());
    }

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

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

    /**
     * Scope for posts by category.
     */
    public function scopeInCategory($query, $categoryId)
    {
        return $query->where('category_id', $categoryId);
    }

    /**
     * Scope for posts with minimum view count.
     */
    public function scopePopular($query, $minViews = 1000)
    {
        return $query->where('view_count', '>=', $minViews);
    }

    /**
     * Scope for posts tagged with specific tags.
     */
    public function scopeWithTags($query, array $tagIds)
    {
        return $query->whereHas('tags', function ($q) use ($tagIds) {
            $q->whereIn('tags.id', $tagIds);
        });
    }
}

Practical usage:

// Homepage: Get featured, published posts
$featuredPosts = Post::published()->featured()->take(5)->get();

// Category page: Get published posts in specific category
$categoryPosts = Post::published()
                    ->inCategory($categoryId)
                    ->orderBy('published_at', 'desc')
                    ->paginate(10);

// Popular posts with specific tags
$popularTaggedPosts = Post::published()
                         ->popular(500)
                         ->withTags([1, 2, 3])
                         ->get();

Defining Scopes for Date-Based Filtering

Date filtering is common across many applications:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;

class Order extends Model
{
    /**
     * Scope for orders from today.
     */
    public function scopeToday($query)
    {
        return $query->whereDate('created_at', today());
    }

    /**
     * Scope for orders from this week.
     */
    public function scopeThisWeek($query)
    {
        return $query->whereBetween('created_at', [
            now()->startOfWeek(),
            now()->endOfWeek()
        ]);
    }

    /**
     * Scope for orders from this month.
     */
    public function scopeThisMonth($query)
    {
        return $query->whereMonth('created_at', now()->month)
                    ->whereYear('created_at', now()->year);
    }

    /**
     * Scope for orders from last N days.
     */
    public function scopeLastDays($query, $days)
    {
        return $query->where('created_at', '>=', now()->subDays($days));
    }

    /**
     * Scope for orders between specific dates.
     */
    public function scopeBetweenDates($query, $startDate, $endDate)
    {
        return $query->whereBetween('created_at', [
            Carbon::parse($startDate)->startOfDay(),
            Carbon::parse($endDate)->endOfDay()
        ]);
    }
}

Passing Parameters to Local Scopes

How to Make Your Scopes More Dynamic

Parameters make scopes flexible and reusable across different scenarios:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    /**
     * Scope for products within price range.
     */
    public function scopePriceRange($query, $minPrice, $maxPrice = null)
    {
        $query->where('price', '>=', $minPrice);

        if ($maxPrice !== null) {
            $query->where('price', '<=', $maxPrice);
        }

        return $query;
    }

    /**
     * Scope for products by availability status.
     */
    public function scopeAvailability($query, $status = 'in_stock')
    {
        return $query->where('stock_status', $status);
    }

    /**
     * Scope for products with minimum rating.
     */
    public function scopeRatedAbove($query, $rating = 3.0)
    {
        return $query->where('average_rating', '>=', $rating);
    }

    /**
     * Scope for products by multiple categories.
     */
    public function scopeInCategories($query, array $categoryIds = [])
    {
        if (empty($categoryIds)) {
            return $query;
        }

        return $query->whereIn('category_id', $categoryIds);
    }
}

Example: Filtering Users by Role or Status

Here's a practical example showing parameter flexibility:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    /**
     * Scope for users with specific roles.
     */
    public function scopeWithRole($query, $roles)
    {
        // Handle single role or array of roles
        $roles = is_array($roles) ? $roles : [$roles];

        return $query->whereHas('roles', function ($q) use ($roles) {
            $q->whereIn('name', $roles);
        });
    }

    /**
     * Scope for users by status with optional date constraint.
     */
    public function scopeByStatus($query, $status, $since = null)
    {
        $query->where('status', $status);

        if ($since) {
            $query->where('status_updated_at', '>=', $since);
        }

        return $query;
    }

    /**
     * Scope for users with activity in specified period.
     */
    public function scopeActiveIn($query, $period = '30 days', $activity = 'login')
    {
        $date = now()->sub($period);

        return $query->where('last_' . $activity . '_at', '>=', $date);
    }
}

Usage with parameters:

// Single role
$admins = User::withRole('admin')->get();

// Multiple roles
$staff = User::withRole(['admin', 'moderator', 'editor'])->get();

// Status with date constraint
$recentlyActive = User::byStatus('active', now()->subWeek())->get();

// Flexible activity filtering
$recentLogins = User::activeIn('7 days', 'login')->get();
$recentPosts = User::activeIn('1 month', 'post')->get();

Combining Multiple Local Scopes

Using Multiple Scopes for Cleaner Logic

One of the most powerful aspects of local scopes is their ability to chain together, creating complex queries that remain readable:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    public function scopeActive($query)
    {
        return $query->where('status', 'active');
    }

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

    public function scopePremium($query)
    {
        return $query->where('subscription_type', 'premium')
                    ->where('subscription_expires_at', '>', now());
    }

    public function scopeFromCity($query, $city)
    {
        return $query->where('city', $city);
    }

    public function scopeWithMinAge($query, $age)
    {
        return $query->where('age', '>=', $age);
    }
}

Complex query made simple:

// Find premium users from NYC who are active, verified, and over 21
$targetUsers = User::active()
                  ->verified()
                  ->premium()
                  ->fromCity('New York')
                  ->withMinAge(21)
                  ->get();

// The equivalent without scopes would be much longer:
$targetUsers = User::where('status', 'active')
                  ->whereNotNull('email_verified_at')
                  ->where('subscription_type', 'premium')
                  ->where('subscription_expires_at', '>', now())
                  ->where('city', 'New York')
                  ->where('age', '>=', 21)
                  ->get();

Scope Composition for Complex Query Requirements

For advanced scenarios, you can compose scopes to handle complex business logic:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    /**
     * Base scope for completed orders.
     */
    public function scopeCompleted($query)
    {
        return $query->where('status', 'completed');
    }

    /**
     * Scope for high-value orders.
     */
    public function scopeHighValue($query, $threshold = 500)
    {
        return $query->where('total_amount', '>=', $threshold);
    }

    /**
     * Scope for recent orders.
     */
    public function scopeRecent($query, $days = 30)
    {
        return $query->where('created_at', '>=', now()->subDays($days));
    }

    /**
     * Composite scope for VIP customer analysis.
     */
    public function scopeVipCustomerOrders($query, $days = 90, $minAmount = 1000)
    {
        return $query->completed()
                    ->highValue($minAmount)
                    ->recent($days)
                    ->with('customer');
    }

    /**
     * Scope for refund-eligible orders.
     */
    public function scopeRefundEligible($query)
    {
        return $query->completed()
                    ->where('created_at', '>=', now()->subDays(30))
                    ->whereNull('refunded_at');
    }
}

Usage examples:

// Get VIP customer orders for analysis
$vipOrders = Order::vipCustomerOrders(60, 750)->get();

// Find orders eligible for refund processing
$refundableOrders = Order::refundEligible()->get();

// Complex reporting query
$salesReport = Order::completed()
                   ->recent(7)
                   ->highValue(100)
                   ->with(['customer', 'items'])
                   ->orderBy('total_amount', 'desc')
                   ->get();

Reusing Scopes Across Multiple Models

Abstracting Common Logic with Traits

When multiple models share similar filtering logic, traits provide an elegant solution:

<?php

namespace App\Traits;

trait HasStatusScope
{
    /**
     * Scope for active records.
     */
    public function scopeActive($query)
    {
        return $query->where('status', 'active');
    }

    /**
     * Scope for inactive records.
     */
    public function scopeInactive($query)
    {
        return $query->where('status', 'inactive');
    }

    /**
     * Scope by specific status.
     */
    public function scopeByStatus($query, $status)
    {
        return $query->where('status', $status);
    }
}
<?php

namespace App\Traits;

trait HasTimestampScopes
{
    /**
     * Scope for records created today.
     */
    public function scopeCreatedToday($query)
    {
        return $query->whereDate('created_at', today());
    }

    /**
     * Scope for records updated recently.
     */
    public function scopeUpdatedRecently($query, $hours = 24)
    {
        return $query->where('updated_at', '>=', now()->subHours($hours));
    }

    /**
     * Scope for records created in date range.
     */
    public function scopeCreatedBetween($query, $startDate, $endDate)
    {
        return $query->whereBetween('created_at', [$startDate, $endDate]);
    }
}

Using traits in models:

<?php

namespace App\Models;

use App\Traits\HasStatusScope;
use App\Traits\HasTimestampScopes;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    use HasStatusScope, HasTimestampScopes;

    // Model-specific scopes
    public function scopeVerified($query)
    {
        return $query->whereNotNull('email_verified_at');
    }
}

class Product extends Model
{
    use HasStatusScope, HasTimestampScopes;

    // Model-specific scopes
    public function scopeInStock($query)
    {
        return $query->where('stock_quantity', '>', 0);
    }
}

Now both models can use the shared scopes:

$activeUsers = User::active()->createdToday()->get();
$activeProducts = Product::active()->updatedRecently(6)->get();

Creating a BaseModel for Shared Scopes

For even broader scope sharing, create a base model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

abstract class BaseModel extends Model
{
    /**
     * Scope for soft-deleted records (if using soft deletes).
     */
    public function scopeWithTrashed($query)
    {
        return $query->withTrashed();
    }

    /**
     * Scope for records created by specific user.
     */
    public function scopeCreatedBy($query, $userId)
    {
        return $query->where('created_by', $userId);
    }

    /**
     * Scope for records with specific attribute value.
     */
    public function scopeWhereAttribute($query, $attribute, $value)
    {
        return $query->where($attribute, $value);
    }

    /**
     * Scope for ordering by specific column.
     */
    public function scopeOrderByColumn($query, $column, $direction = 'asc')
    {
        return $query->orderBy($column, $direction);
    }
}

Extend your models from BaseModel:

<?php

namespace App\Models;

class User extends BaseModel
{
    // User-specific scopes and methods
}

class Post extends BaseModel
{
    // Post-specific scopes and methods
}

Local Scope vs Global Scope

Key Differences and Use Cases for Each

Understanding when to use local versus global scopes is crucial for effective Laravel development:

Aspect

Local Scope

Global Scope

Application

Applied manually when needed

Applied automatically to all queries

Flexibility

High - use when you want it

Low - always active unless removed

Use Case

Optional filtering, business logic

Mandatory constraints, tenant isolation

Performance

Only impacts queries that use it

Impacts every query on the model

Debugging

Easier to debug - explicit usage

Can be forgotten and cause confusion

When to Choose Local Scope Over Global Scope

Choose Local Scopes When:

  • You need optional filtering that applies in some contexts but not others

  • Building API endpoints with optional query parameters

  • Creating reusable business logic that may or may not apply

  • You want explicit control over when constraints are applied

  • Different parts of your application need different default filtering

Example scenarios for local scopes:

// Optional filtering - sometimes you want all posts, sometimes just published
$allPosts = Post::all(); // Gets everything
$publishedPosts = Post::published()->get(); // Gets only published

// API filtering - let users choose what to filter
$posts = Post::query();
if ($request->has('status')) {
    $posts->byStatus($request->status);
}
if ($request->has('category')) {
    $posts->inCategory($request->category);
}

Choose Global Scopes When:

  • You need constraints that should ALWAYS apply (like tenant isolation)

  • Working with soft deletes where you rarely want deleted records

  • Implementing row-level security or data isolation

  • You have mandatory business rules that should never be bypassed

Example scenarios for global scopes:

// Tenant isolation - users should NEVER see other tenants' data
class TenantScope implements Scope
{
    public function apply(Builder $query, Model $model)
    {
        $query->where('tenant_id', auth()->user()->tenant_id);
    }
}

// Soft deletes - you rarely want deleted records in normal queries
// Laravel's SoftDeletes trait uses a global scope internally

Testing Your Local Scopes

Writing Unit Tests for Scope Logic

Testing local scopes ensures your query logic works correctly and remains stable as your application evolves:

<?php

namespace Tests\Unit\Models;

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

class UserScopeTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function active_scope_filters_only_active_users()
    {
        // Create test data
        User::factory()->create(['status' => 'active']);
        User::factory()->create(['status' => 'inactive']);
        User::factory()->create(['status' => 'active']);

        // Test the scope
        $activeUsers = User::active()->get();

        $this->assertCount(2, $activeUsers);
        $this->assertTrue($activeUsers->every(fn($user) => $user->status === 'active'));
    }

    /** @test */
    public function verified_scope_filters_verified_users_only()
    {
        // Create verified and unverified users
        User::factory()->create(['email_verified_at' => now()]);
        User::factory()->create(['email_verified_at' => null]);
        User::factory()->create(['email_verified_at' => now()->subDays(5)]);

        $verifiedUsers = User::verified()->get();

        $this->assertCount(2, $verifiedUsers);
        $this->assertTrue($verifiedUsers->every(fn($user) => $user->email_verified_at !== null));
    }

    /** @test */
    public function recent_scope_respects_day_parameter()
    {
        // Create users with different creation dates
        User::factory()->create(['created_at' => now()->subDays(5)]);
        User::factory()->create(['created_at' => now()->subDays(15)]);
        User::factory()->create(['created_at' => now()->subDays(2)]);

        // Test with 7 days
        $recentUsers = User::recent(7)->get();
        $this->assertCount(2, $recentUsers);

        // Test with 20 days
        $moreRecentUsers = User::recent(20)->get();
        $this->assertCount(3, $moreRecentUsers);
    }

    /** @test */
    public function scopes_can_be_chained_together()
    {
        // Create various combinations of users
        User::factory()->create([
            'status' => 'active',
            'email_verified_at' => now(),
            'created_at' => now()->subDays(5)
        ]);

        User::factory()->create([
            'status' => 'inactive',
            'email_verified_at' => now(),
            'created_at' => now()->subDays(5)
        ]);

        User::factory()->create([
            'status' => 'active',
            'email_verified_at' => null,
            'created_at' => now()->subDays(5)
        ]);

        // Test chained scopes
        $qualifiedUsers = User::active()->verified()->recent(7)->get();

        $this->assertCount(1, $qualifiedUsers);
        $this->assertEquals('active', $qualifiedUsers->first()->status);
        $this->assertNotNull($qualifiedUsers->first()->email_verified_at);
    }
}

Setting Up Seeders and Test Data for Accurate Results

Create comprehensive test data using factories and seeders:

<?php

namespace Database\Factories;

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class UserFactory extends Factory
{
    protected $model = User::class;

    public function definition()
    {
        return [
            'name' => $this->faker->name(),
            'email' => $this->faker->unique()->safeEmail(),
            'status' => $this->faker->randomElement(['active', 'inactive', 'pending']),
            'email_verified_at' => $this->faker->optional(0.7)->dateTimeBetween('-1 year', 'now'),
            'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
        ];
    }

    /**
     * Factory state for active users.
     */
    public function active()
    {
        return $this->state(['status' => 'active']);
    }

    /**
     * Factory state for verified users.
     */
    public function verified()
    {
        return $this->state(['email_verified_at' => now()]);
    }

    /**
     * Factory state for recent users.
     */
    public function recent($days = 7)
    {
        return $this->state([
            'created_at' => $this->faker->dateTimeBetween("-{$days} days", 'now')
        ]);
    }
}

Use factory states in tests:

/** @test */
public function complex_scope_combinations_work_correctly()
{
    // Create specific test scenarios using factory states
    User::factory()->active()->verified()->recent(5)->count(3)->create();
    User::factory()->active()->verified()->count(2)->create(['created_at' => now()->subMonths(2)]);
    User::factory()->active()->count(2)->create(['email_verified_at' => null]);
    User::factory()->verified()->recent(5)->count(2)->create(['status' => 'inactive']);

    // Test the combination
    $result = User::active()->verified()->recent(7)->get();

    $this->assertCount(3, $result);
    // Additional assertions...
}

Performance Considerations

Do Scopes Impact Query Speed?

Local scopes themselves don't add significant overhead to your queries. They're essentially syntactic sugar that gets compiled into standard SQL WHERE clauses. However, the logic within your scopes can impact performance:

Minimal Performance Impact:

// Simple scopes compile to efficient SQL
public function scopeActive($query)
{
    return $query->where('status', 'active'); // Efficient
}

// SQL: SELECT * FROM users WHERE status = 'active'

Potential Performance Concerns:

// Complex subqueries can be slower
public function scopeWithRecentActivity($query)
{
    return $query->whereExists(function ($subquery) {
        $subquery->select('id')
                ->from('user_activities')
                ->whereColumn('user_activities.user_id', 'users.id')
                ->where('created_at', '>=', now()->subDays(30));
    });
}

// Consider using joins or eager loading instead for better performance

Optimizing Complex Scopes for Large Datasets

For applications with large datasets, optimize your scopes using these strategies:

1. Use Database Indexes Strategically

// Ensure columns used in scopes are indexed
Schema::table('users', function (Blueprint $table) {
    $table->index('status');
    $table->index('email_verified_at');
    $table->index(['status', 'created_at']); // Composite index for chained scopes
});

2. Optimize Relationship-Based Scopes

// Less efficient - uses subquery
public function scopeWithActivePosts($query)
{
    return $query->whereHas('posts', function ($q) {
        $q->where('status', 'published');
    });
}

// More efficient - uses join
public function scopeWithActivePostsOptimized($query)
{
    return $query->join('posts', 'users.id', '=', 'posts.user_id')
                ->where('posts.status', 'published')
                ->select('users.*')
                ->distinct();
}

3. Use Query Caching for Expensive Scopes

public function scopePopularThisMonth($query)
{
    return $query->where('view_count', '>=', function ($subquery) {
        $subquery->selectRaw('AVG(view_count) * 2')
                ->from('posts')
                ->whereMonth('created_at', now()->month);
    });
}

// Cache expensive calculations
public function scopePopularThisMonthCached($query)
{
    $threshold = Cache::remember('popular_threshold_' . now()->format('Y-m'), 3600, function () {
        return Post::whereMonth('created_at', now()->month)->avg('view_count') * 2;
    });

    return $query->where('view_count', '>=', $threshold);
}

4. Profile Your Queries

// Enable query logging in development
DB::enableQueryLog();

$users = User::active()->verified()->recent(30)->get();

// Review generated queries
dd(DB::getQueryLog());

Using Local Scopes with Relationships

Local scopes work seamlessly with Eloquent relationships, allowing you to filter related data efficiently:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    public function posts()
    {
        return $this->hasMany(Post::class);
    }

    public function publishedPosts()
    {
        return $this->hasMany(Post::class)->published();
    }

    public function scopeWithPublishedPosts($query)
    {
        return $query->whereHas('posts', function ($q) {
            $q->published();
        });
    }
}

class Post extends Model
{
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    public function scopePublished($query)
    {
        return $query->where('status', 'published')
                    ->where('published_at', '<=', now());
    }

    public function scopeByActiveUser($query)
    {
        return $query->whereHas('user', function ($q) {
            $q->active();
        });
    }
}

Usage examples:

// Get users who have published posts
$authorsWithContent = User::withPublishedPosts()->get();

// Get published posts by active users
$validPosts = Post::published()->byActiveUser()->get();

// Get a user's published posts directly
$userPosts = $user->publishedPosts;

// Complex relationship filtering
$featuredAuthors = User::active()
                      ->verified()
                      ->whereHas('posts', function ($query) {
                          $query->published()
                               ->where('view_count', '>', 1000);
                      })
                      ->withCount(['posts' => function ($query) {
                          $query->published();
                      }])
                      ->having('posts_count', '>=', 5)
                      ->get();

Eager Loading with Scoped Constraints

Combine scopes with eager loading for optimal performance:

// Load users with their published posts only
$users = User::active()
            ->with(['posts' => function ($query) {
                $query->published()->orderBy('published_at', 'desc');
            }])
            ->get();

// Load posts with active user data
$posts = Post::published()
            ->with(['user' => function ($query) {
                $query->active()->verified();
            }])
            ->get();

// Complex eager loading with multiple scoped relationships
$authors = User::active()
              ->with([
                  'posts' => function ($query) {
                      $query->published()->recent(30);
                  },
                  'comments' => function ($query) {
                      $query->approved()->recent(7);
                  },
                  'profile' => function ($query) {
                      $query->public();
                  }
              ])
              ->get();

Common Mistakes and How to Fix Them

1. Forgetting to Return the Query

// ❌ Wrong - doesn't return the query
public function scopeActive($query)
{
    $query->where('status', 'active');
}

// ✅ Correct - returns the modified query
public function scopeActive($query)
{
    return $query->where('status', 'active');
}

2. Incorrect Parameter Handling

// ❌ Wrong - doesn't handle empty arrays
public function scopeInCategories($query, $categoryIds)
{
    return $query->whereIn('category_id', $categoryIds);
}

// ✅ Correct - handles edge cases
public function scopeInCategories($query, $categoryIds = [])
{
    if (empty($categoryIds)) {
        return $query;
    }

    return $query->whereIn('category_id', $categoryIds);
}

3. Scope Naming Conflicts

// ❌ Wrong - conflicts with Eloquent method
public function scopeWhere($query, $column, $value)
{
    return $query->where($column, $value);
}

// ✅ Correct - use descriptive, unique names
public function scopeWhereAttribute($query, $column, $value)
{
    return $query->where($column, $value);
}

Debugging with Laravel's Query Logging Tools

Laravel provides excellent tools for debugging scope-generated queries:

1. Enable Query Logging

// In a controller or service
DB::enableQueryLog();

$users = User::active()->verified()->recent(30)->get();

// View the generated SQL
$queries = DB::getQueryLog();
dd($queries);

2. Use the toSql() Method

// See the SQL without executing
$sql = User::active()->verified()->recent(30)->toSql();
echo $sql;
// Output: select * from `users` where `status` = ? and `email_verified_at` is not null and `created_at` >= ?

3. Debug Individual Scopes

// Test scopes in isolation
$activeQuery = User::active()->toSql();
$verifiedQuery = User::verified()->toSql();
$combinedQuery = User::active()->verified()->toSql();

// Compare the queries to understand the combination

4. Use Laravel Debugbar

Install Laravel Debugbar for visual query debugging:

composer require barryvdh/laravel-debugbar --dev

5. Custom Debug Helper

// Create a helper trait for debugging scopes
trait DebuggableScopes
{
    public function debugScope($scopeName, ...$parameters)
    {
        $query = $this->newQuery();
        $query = $query->$scopeName(...$parameters);

        dump([
            'scope' => $scopeName,
            'parameters' => $parameters,
            'sql' => $query->toSql(),
            'bindings' => $query->getBindings()
        ]);

        return $query;
    }
}

// Use in your model
class User extends Model
{
    use DebuggableScopes;

    // Your scopes...
}

// Debug usage
User::debugScope('active');
User::debugScope('recent', 30);

Advanced Scope Usage Tips

Conditional Scopes with When() and Higher-Order Functions

Laravel's when() method works beautifully with scopes for conditional query building:

// Controller method with optional filtering
public function index(Request $request)
{
    $users = User::query()
        ->when($request->status, function ($query, $status) {
            return $query->byStatus($status);
        })
        ->when($request->verified, function ($query) {
            return $query->verified();
        })
        ->when($request->city, function ($query, $city) {
            return $query->fromCity($city);
        })
        ->when($request->min_age, function ($query, $age) {
            return $query->withMinAge($age);
        })
        ->paginate(15);

    return view('users.index', compact('users'));
}

Advanced Conditional Logic:

public function scopeConditionalFilter($query, $conditions = [])
{
    return $query
        ->when($conditions['active'] ?? false, function ($q) {
            return $q->active();
        })
        ->when($conditions['date_range'] ?? null, function ($q, $range) {
            return $q->createdBetween($range['start'], $range['end']);
        })
        ->when($conditions['has_posts'] ?? false, function ($q) {
            return $q->has('posts');
        })
        ->when($conditions['role'] ?? null, function ($q, $role) {
            return $q->withRole($role);
        });
}

// Usage
$users = User::conditionalFilter([
    'active' => true,
    'date_range' => ['start' => '2024-01-01', 'end' => '2024-12-31'],
    'role' => 'admin'
])->get();

Using Scopes in API Filtering and Custom Filters

Build flexible API endpoints using scopes:

<?php

namespace App\Http\Controllers\Api;

use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index(Request $request)
    {
        $posts = Post::query()
            // Apply scopes based on query parameters
            ->when($request->has('status'), function ($query) use ($request) {
                return $query->byStatus($request->status);
            })
            ->when($request->has('category'), function ($query) use ($request) {
                return $query->inCategory($request->category);
            })
            ->when($request->has('author'), function ($query) use ($request) {
                return $query->byAuthor($request->author);
            })
            ->when($request->has('published_after'), function ($query) use ($request) {
                return $query->publishedAfter($request->published_after);
            })
            ->when($request->has('popular'), function ($query) use ($request) {
                return $query->popular($request->input('popular', 1000));
            })
            ->when($request->has('featured'), function ($query) {
                return $query->featured();
            })
            // Default ordering
            ->orderBy('published_at', 'desc')
            ->paginate($request->input('per_page', 15));

        return response()->json($posts);
    }
}

Create a Reusable Filter Class:

<?php

namespace App\Http\Filters;

class PostFilter
{
    protected $query;
    protected $request;

    public function __construct($query, $request)
    {
        $this->query = $query;
        $this->request = $request;
    }

    public function apply()
    {
        foreach ($this->getFilters() as $filter => $value) {
            if (method_exists($this, $filter) && $value !== null) {
                $this->$filter($value);
            }
        }

        return $this->query;
    }

    protected function getFilters()
    {
        return $this->request->only([
            'status', 'category', 'author', 'published_after', 
            'popular', 'featured', 'tags'
        ]);
    }

    protected function status($status)
    {
        $this->query->byStatus($status);
    }

    protected function category($category)
    {
        $this->query->inCategory($category);
    }

    protected function author($author)
    {
        $this->query->byAuthor($author);
    }

    protected function publishedAfter($date)
    {
        $this->query->publishedAfter($date);
    }

    protected function popular($threshold)
    {
        $this->query->popular($threshold);
    }

    protected function featured($featured)
    {
        if ($featured) {
            $this->query->featured();
        }
    }

    protected function tags($tags)
    {
        $tagIds = is_array($tags) ? $tags : explode(',', $tags);
        $this->query->withTags($tagIds);
    }
}

Use the filter in your controller:

public function index(Request $request)
{
    $query = Post::query();
    $filter = new PostFilter($query, $request);

    $posts = $filter->apply()
                   ->orderBy('published_at', 'desc')
                   ->paginate($request->input('per_page', 15));

    return response()->json($posts);
}

Organizing Scopes for Readability

Grouping Scopes by Function in Models

Organize your scopes logically within your models for better maintainability:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    // ========================================
    // STATUS SCOPES
    // ========================================

    /**
     * Scope for active users.
     */
    public function scopeActive($query)
    {
        return $query->where('status', 'active');
    }

    /**
     * Scope for inactive users.
     */
    public function scopeInactive($query)
    {
        return $query->where('status', 'inactive');
    }

    /**
     * Scope by specific status.
     */
    public function scopeByStatus($query, $status)
    {
        return $query->where('status', $status);
    }

    // ========================================
    // VERIFICATION SCOPES
    // ========================================

    /**
     * Scope for email verified users.
     */
    public function scopeVerified($query)
    {
        return $query->whereNotNull('email_verified_at');
    }

    /**
     * Scope for unverified users.
     */
    public function scopeUnverified($query)
    {
        return $query->whereNull('email_verified_at');
    }

    // ========================================
    // DATE-BASED SCOPES
    // ========================================

    /**
     * Scope for recent users.
     */
    public function scopeRecent($query, $days = 30)
    {
        return $query->where('created_at', '>=', now()->subDays($days));
    }

    /**
     * Scope for users created between dates.
     */
    public function scopeCreatedBetween($query, $startDate, $endDate)
    {
        return $query->whereBetween('created_at', [$startDate, $endDate]);
    }

    // ========================================
    // ROLE & PERMISSION SCOPES
    // ========================================

    /**
     * Scope for users with specific role.
     */
    public function scopeWithRole($query, $role)
    {
        return $query->whereHas('roles', function ($q) use ($role) {
            $q->where('name', $role);
        });
    }

    /**
     * Scope for admin users.
     */
    public function scopeAdmins($query)
    {
        return $query->withRole('admin');
    }

    // ========================================
    // ACTIVITY SCOPES
    // ========================================

    /**
     * Scope for users with recent login.
     */
    public function scopeRecentlyActive($query, $hours = 24)
    {
        return $query->where('last_login_at', '>=', now()->subHours($hours));
    }
}

Using DocBlocks and Comments for Clarity

Comprehensive documentation makes your scopes more maintainable:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;

class Order extends Model
{
    /**
     * Scope a query to only include completed orders.
     * 
     * Completed orders are those with status 'completed' and have
     * a completion timestamp. This excludes cancelled, pending,
     * and processing orders.
     * 
     * @param \Illuminate\Database\Eloquent\Builder $query
     * @return \Illuminate\Database\Eloquent\Builder
     * 
     * @example Order::completed()->get()
     */
    public function scopeCompleted($query)
    {
        return $query->where('status', 'completed')
                    ->whereNotNull('completed_at');
    }

    /**
     * Scope a query to include orders within a specific total range.
     * 
     * @param \Illuminate\Database\Eloquent\Builder $query
     * @param float $minAmount Minimum order total (inclusive)
     * @param float|null $maxAmount Maximum order total (inclusive, optional)
     * @return \Illuminate\Database\Eloquent\Builder
     * 
     * @example Order::totalRange(100, 500)->get() // Orders between $100-$500
     * @example Order::totalRange(1000)->get() // Orders $1000 and above
     */
    public function scopeTotalRange($query, $minAmount, $maxAmount = null)
    {
        $query->where('total_amount', '>=', $minAmount);

        if ($maxAmount !== null) {
            $query->where('total_amount', '<=', $maxAmount);
        }

        return $query;
    }

    /**
     * Scope orders by customer type and status combination.
     * 
     * This is a complex scope that handles various customer types
     * and their associated order statuses. Used primarily in
     * reporting and customer service interfaces.
     * 
     * @param \Illuminate\Database\Eloquent\Builder $query
     * @param string $customerType Customer type (premium, regular, vip)
     * @param array $allowedStatuses Order statuses to include
     * @return \Illuminate\Database\Eloquent\Builder
     * 
     * @throws \InvalidArgumentException When customer type is invalid
     * 
     * @example Order::byCustomerType('premium', ['completed', 'processing'])->get()
     */
    public function scopeByCustomerType($query, $customerType, $allowedStatuses = ['completed'])
    {
        $validTypes = ['premium', 'regular', 'vip'];

        if (!in_array($customerType, $validTypes)) {
            throw new \InvalidArgumentException("Invalid customer type: {$customerType}");
        }

        return $query->whereHas('customer', function ($q) use ($customerType) {
                    $q->where('type', $customerType);
                })
                ->whereIn('status', $allowedStatuses);
    }
}

Best Practices for Clean, Maintainable Scopes

Keeping Scope Logic Simple and Focused

Single Responsibility Principle:

// ✅ Good - each scope has one clear purpose
public function scopeActive($query)
{
    return $query->where('status', 'active');
}

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

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

// ❌ Avoid - too many responsibilities in one scope
public function scopeActiveVerifiedRecent($query, $days = 30)
{
    return $query->where('status', 'active')
                ->whereNotNull('email_verified_at')
                ->where('created_at', '>=', now()->subDays($days));
}

Prefer Composition:

// ✅ Better - compose simple scopes for complex logic
$qualifiedUsers = User::active()->verified()->recent(7)->get();

// This is more flexible and testable than a single complex scope

Documenting Scope Intent and Behavior

Clear Naming and Documentation:

/**
 * Scope for users eligible for premium upgrade offers.
 * 
 * Includes users who are:
 * - Active for at least 30 days
 * - Have made at least 3 purchases
 * - Currently on basic or standard plan
 * - Haven't been offered an upgrade in the last 60 days
 */
public function scopeEligibleForPremiumUpgrade($query)
{
    return $query->where('status', 'active')
                ->where('created_at', '<=', now()->subDays(30))
                ->whereHas('orders', function ($q) {
                    $q->where('status', 'completed');
                }, '>=', 3)
                ->whereIn('subscription_plan', ['basic', 'standard'])
                ->where(function ($q) {
                    $q->whereNull('last_upgrade_offer_at')
                      ->orWhere('last_upgrade_offer_at', '<=', now()->subDays(60));
                });
}

Version and Change Documentation:

/**
 * Scope for published content.
 * 
 * @version 2.1
 * @since 1.0
 * 
 * @changelog
 * - v2.1: Added timezone-aware published_at comparison
 * - v2.0: Added support for scheduled publishing
 * - v1.0: Initial implementation
 */
public function scopePublished($query)
{
    return $query->where('status', 'published')
                ->where('published_at', '<=', now());
}

Conclusion

Laravel 12's local scopes represent one of the most elegant solutions for writing clean, maintainable, and reusable database queries. Throughout this comprehensive guide, we've explored how these powerful tools can transform your Eloquent queries from verbose, repetitive code into expressive, self-documenting method chains.

Recap of the Power and Simplicity of Local Scopes

Local scopes excel in several key areas that make them indispensable for professional Laravel development:

Code Organization: By encapsulating query logic within your models, you maintain the single responsibility principle while keeping your controllers and services focused on their primary concerns.

Reusability: Define query constraints once and use them throughout your application, eliminating code duplication and reducing maintenance overhead.

Readability: Transform complex query chains into readable, business-focused method calls that clearly communicate intent to other developers.

Testability: Test query logic in isolation, making your test suite more reliable and easier to maintain.

Performance: When properly implemented with appropriate database indexes, scopes provide efficient query execution without sacrificing code clarity.

The journey from basic scope implementation to advanced techniques like conditional filtering, relationship constraints, and API integration demonstrates the versatility of this feature. Whether you're building simple content management systems or complex enterprise applications, local scopes provide the foundation for maintainable, scalable query architecture.

What to Explore Next: Global Scopes and Query Macros

As you master local scopes, consider expanding your Eloquent expertise with these advanced topics:

Global Scopes offer automatic query constraints that apply to every query on a model. They're perfect for implementing tenant isolation, soft deletes, or mandatory business rules that should never be bypassed.

Query Macros allow you to extend Laravel's query builder with custom methods, providing even more flexibility for complex query scenarios that go beyond what individual model scopes can handle.

Custom Collection Methods complement scopes by allowing you to define reusable operations on query results, creating a complete toolkit for data manipulation and presentation.

The combination of local scopes, global scopes, and query macros creates a powerful ecosystem for database interaction that keeps your code clean, your queries efficient, and your applications maintainable as they grow in complexity.

Remember, the best developers don't just write code that works—they write code that communicates clearly, remains stable over time, and makes the next developer's job easier. Laravel 12's local scopes are one of your most valuable tools for achieving these goals.

Start implementing local scopes in your current projects today, and experience firsthand how they can transform your relationship with database queries. Your future self—and your team—will thank you for the investment in cleaner, more maintainable code.

0
Subscribe to my newsletter

Read articles from Suresh Ramani directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Suresh Ramani
Suresh Ramani