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

Table of contents
- What Are Eloquent Scopes and Why They Matter
- Understanding the Basics of Local Scopes
- Creating Your First Local Scope
- Using Local Scopes in Your Queries
- Real-World Examples of Local Scopes
- Passing Parameters to Local Scopes
- Combining Multiple Local Scopes
- Reusing Scopes Across Multiple Models
- Local Scope vs Global Scope
- Testing Your Local Scopes
- Performance Considerations
- Using Local Scopes with Relationships
- Debugging Scope-Related Issues
- Advanced Scope Usage Tips
- Organizing Scopes for Readability
- Best Practices for Clean, Maintainable Scopes
- Conclusion

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()
becomesemailVerified()
scopeCreatedThisWeek()
becomescreatedThisWeek()
Be Consistent: Establish patterns across your application
Use
active/inactive
consistently instead of mixing withenabled/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
Applying Scopes on Related Models
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();
Debugging Scope-Related Issues
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.
Subscribe to my newsletter
Read articles from Suresh Ramani directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
