Laravel: Simplifying Query Scopes for Cleaner Models

By Maulik Paghdal

15 Dec, 2024

•  8 minutes to Read

Laravel: Simplifying Query Scopes for Cleaner Models

Introduction

When you're working with Laravel applications that have grown beyond a few simple CRUD operations, you'll quickly notice that your controllers start getting cluttered with repetitive query logic. You might find yourself writing where('status', 'active') or where('created_at', '>=', Carbon::now()->subDays(30)) across multiple controllers, and before you know it, you're copy-pasting the same database constraints everywhere.

Query scopes solve this problem elegantly by letting you encapsulate commonly used query logic directly within your Eloquent models. Think of them as reusable query building blocks that make your code more readable, maintainable, and consistent across your application.

This guide covers both local and global scopes, showing you when to use each type and how to avoid the common pitfalls that can make your models harder to work with instead of easier.

What Are Query Scopes?

Query scopes in Laravel are methods that encapsulate query logic within your Eloquent models. Instead of scattering database constraints throughout your application, you define them once in the model and reuse them wherever needed.

Laravel offers two types of scopes:

  1. Local Scopes: Optional query methods you can call when needed
  2. Global Scopes: Automatic query constraints applied to every database query for a model

The key difference is control. Local scopes give you flexibility to apply filters when you want them, while global scopes enforce rules across your entire application.

Local Scopes in Laravel

Local scopes are custom methods that return query builder instances, allowing you to chain them with other query methods. They're perfect for encapsulating business logic that you use frequently but don't want applied to every query.

Defining Local Scopes

Creating a local scope is straightforward. Define a method in your model that starts with scope, and Laravel handles the rest:

namespace App\Models;

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

class Post extends Model
{
    public function scopePublished($query)
    {
        return $query->where('status', 'published');
    }

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

    public function scopeByAuthor($query, $authorId)
    {
        return $query->where('author_id', $authorId);
    }

    public function scopeWithMinimumViews($query, $minViews = 100)
    {
        return $query->where('view_count', '>=', $minViews);
    }
}

💡 Tip: Always return the $query object from your scope methods. This ensures they can be chained with other query methods and scopes.

Using Local Scopes

When calling scopes, you drop the scope prefix and call them as if they were regular query builder methods:

use App\Models\Post;

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

// Get recent posts from the last 30 days
$recentPosts = Post::recent(30)->get();

// Chain multiple scopes together
$popularRecentPosts = Post::published()
    ->recent(14)
    ->withMinimumViews(500)
    ->orderBy('view_count', 'desc')
    ->get();

// Combine with regular query methods
$authorPosts = Post::byAuthor(auth()->id())
    ->published()
    ->paginate(10);

📌 Note: Scopes work seamlessly with Laravel's query builder methods like orderBy(), paginate(), and with(). You can chain them in any order that makes sense for your query.

Advanced Local Scope Patterns

Here are some more sophisticated scope examples that handle real-world scenarios:

class Post extends Model
{
    public function scopeSearch($query, $term)
    {
        return $query->where(function ($q) use ($term) {
            $q->where('title', 'like', "%{$term}%")
              ->orWhere('content', 'like', "%{$term}%")
              ->orWhereHas('tags', function ($tagQuery) use ($term) {
                  $tagQuery->where('name', 'like', "%{$term}%");
              });
        });
    }

    public function scopeInCategory($query, $categorySlug)
    {
        return $query->whereHas('category', function ($q) use ($categorySlug) {
            $q->where('slug', $categorySlug);
        });
    }

    public function scopeScheduledFor($query, $date = null)
    {
        $targetDate = $date ?: Carbon::today();
        
        return $query->where('published_at', '<=', $targetDate)
                    ->where('status', 'scheduled');
    }
}

⚠️ Warning: Be careful with complex scopes that include multiple orWhere conditions. Always wrap them in closures to avoid unexpected query behavior when chaining with other scopes.

Global Scopes in Laravel

Global scopes automatically apply constraints to all queries for a specific model. They're powerful but should be used carefully since they affect every database operation for that model.

Defining a Global Scope Class

The recommended approach is creating a dedicated scope class:

namespace App\Scopes;

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

class PublishedScope implements Scope
{
    public function apply(Builder $builder, Model $model)
    {
        $builder->where('status', 'published');
    }
}

For more complex scenarios, you can add additional logic:

namespace App\Scopes;

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

class TenantScope implements Scope
{
    protected $tenantId;

    public function __construct($tenantId)
    {
        $this->tenantId = $tenantId;
    }

    public function apply(Builder $builder, Model $model)
    {
        if ($this->tenantId) {
            $builder->where('tenant_id', $this->tenantId);
        }
    }
}

Applying Global Scopes to Models

Register your global scope in the model's booted method:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use App\Scopes\PublishedScope;
use App\Scopes\TenantScope;

class Post extends Model
{
    protected static function booted()
    {
        static::addGlobalScope(new PublishedScope);
        
        // You can also add conditional global scopes
        if (auth()->check()) {
            static::addGlobalScope(new TenantScope(auth()->user()->tenant_id));
        }
    }
}

Anonymous Global Scopes

For simple constraints, you can define anonymous global scopes directly in the model:

class Post extends Model
{
    protected static function booted()
    {
        static::addGlobalScope('published', function (Builder $builder) {
            $builder->where('status', 'published');
        });
    }
}

Working with Global Scopes

Once applied, global scopes work transparently:

// This automatically includes where('status', 'published')
$posts = Post::all();

// Still applies the global scope
$recentPosts = Post::where('created_at', '>=', now()->subWeek())->get();

Sometimes you need to bypass global scopes. Laravel provides methods for this:

// Remove all global scopes
$allPosts = Post::withoutGlobalScopes()->get();

// Remove specific global scopes
$allPosts = Post::withoutGlobalScope(PublishedScope::class)->get();

// Remove anonymous global scopes
$allPosts = Post::withoutGlobalScope('published')->get();

⚠️ Warning: Global scopes affect relationships too. If your User model has a relationship to Post with a global scope, only published posts will be loaded in that relationship unless you explicitly remove the scope.

Combining Scopes Effectively

The real power of scopes comes from combining them to build complex queries while keeping your code readable:

class Post extends Model
{
    public function scopeForDashboard($query, $userId)
    {
        return $query->where('author_id', $userId)
                    ->with(['category', 'tags'])
                    ->withCount(['comments', 'likes']);
    }

    public function scopeFeatured($query)
    {
        return $query->where('is_featured', true)
                    ->orderBy('featured_at', 'desc');
    }
}

// Usage
$dashboardPosts = Post::published()
    ->forDashboard(auth()->id())
    ->recent(30)
    ->paginate(15);

$featuredPosts = Post::published()
    ->featured()
    ->take(5)
    ->get();

You can also create scopes that build upon other scopes:

public function scopePopular($query)
{
    return $query->published()
                ->withMinimumViews(1000)
                ->where('created_at', '>=', now()->subMonth());
}

Best Practices and Common Pitfalls

Do's and Don'ts

Do:

  • Keep scopes focused on single responsibilities
  • Use descriptive names that clearly indicate what the scope does
  • Return the $query object to enable method chaining
  • Add default parameters to make scopes flexible
  • Use global scopes sparingly and document their behavior

Don't:

  • Create overly complex scopes that are hard to understand
  • Use global scopes for business logic that might need to be bypassed
  • Forget that global scopes affect relationships and counts
  • Chain multiple orWhere conditions without proper grouping

Common Mistakes to Avoid

// ❌ Bad: Not returning the query
public function scopePublished($query)
{
    $query->where('status', 'published');
    // Missing return statement
}

// ❌ Bad: Complex logic without grouping
public function scopeSearchBadly($query, $term)
{
    return $query->where('title', 'like', "%{$term}%")
                ->where('status', 'published')
                ->orWhere('content', 'like', "%{$term}%");
    // This will not work as expected due to operator precedence
}

// ✅ Good: Properly grouped conditions
public function scopeSearch($query, $term)
{
    return $query->where('status', 'published')
                ->where(function ($q) use ($term) {
                    $q->where('title', 'like', "%{$term}%")
                      ->orWhere('content', 'like', "%{$term}%");
                });
}

Performance Considerations

Scopes don't automatically improve or hurt performance, but they can help you write more efficient queries:

// Include commonly needed relationships in scopes
public function scopeWithDetails($query)
{
    return $query->with(['author', 'category', 'tags']);
}

// Optimize for specific use cases
public function scopeForApi($query)
{
    return $query->select(['id', 'title', 'slug', 'published_at'])
                ->with(['author:id,name']);
}

📌 Note: Global scopes can sometimes create N+1 query problems if they include relationships. Use with() statements carefully and monitor your query counts during development.

Real-World Example: Building a Content Management System

Here's how you might structure scopes for a blog or CMS application:

class Article extends Model
{
    // Local scopes for flexible querying
    public function scopePublished($query)
    {
        return $query->where('status', 'published')
                    ->where('published_at', '<=', now());
    }

    public function scopeDraft($query)
    {
        return $query->where('status', 'draft');
    }

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

    public function scopeByCategory($query, $categoryId)
    {
        return $query->where('category_id', $categoryId);
    }

    public function scopePopularThisMonth($query)
    {
        return $query->published()
                    ->where('created_at', '>=', now()->startOfMonth())
                    ->where('views', '>=', 100)
                    ->orderBy('views', 'desc');
    }

    public function scopeForSitemap($query)
    {
        return $query->published()
                    ->select(['slug', 'updated_at'])
                    ->orderBy('updated_at', 'desc');
    }
}

// Usage in controllers
class BlogController extends Controller
{
    public function index()
    {
        $articles = Article::published()
            ->with('author', 'category')
            ->latest('published_at')
            ->paginate(10);

        return view('blog.index', compact('articles'));
    }

    public function category($slug)
    {
        $category = Category::where('slug', $slug)->firstOrFail();
        
        $articles = Article::published()
            ->byCategory($category->id)
            ->latest('published_at')
            ->paginate(10);

        return view('blog.category', compact('articles', 'category'));
    }
}

Benefits of Using Query Scopes

Query scopes provide several key advantages that become more apparent as your application grows:

Code Reusability: Instead of writing the same query constraints repeatedly, you define them once and use them everywhere. This reduces bugs and makes updates easier.

Improved Readability: Compare Post::where('status', 'published')->where('created_at', '>=', now()->subDays(7))->get() with Post::published()->recent()->get(). The scoped version immediately tells you what you're trying to accomplish.

Consistency: When business rules change, you update the scope definition rather than hunting down every instance of that query logic across your codebase.

Testing Benefits: Scopes make it easier to test query logic in isolation and ensure consistent behavior across your application.

Conclusion

Query scopes are one of Laravel's most underutilized features, yet they can dramatically improve your code quality and development experience. Local scopes give you the flexibility to build reusable query components, while global scopes ensure consistent data access patterns across your application.

The key is finding the right balance. Start with local scopes for commonly used filters and constraints. Only introduce global scopes when you have universal business rules that should apply to every query for a model. Remember that with global scopes, you can always opt out when needed, but the default behavior should make sense for 90% of your use cases.

As your Laravel application grows, you'll find that well-designed scopes not only make your code more maintainable but also make it easier for other developers to understand your business logic and data access patterns. Start small, be consistent, and let your scopes evolve naturally with your application's needs.

Topics Covered

About Author

Maulik Paghdal

I'm Maulik Paghdal, the founder of Script Binary and a passionate full-stack web developer. I have a strong foundation in both frontend and backend development, specializing in building dynamic, responsive web applications using Laravel, Vue.js, and React.js. With expertise in Tailwind CSS and Bootstrap, I focus on creating clean, efficient, and scalable solutions that enhance user experiences and optimize performance.