What are Query Scopes?
Query scopes are reusable query constraints that you can define in your models. They allow you to encapsulate common query logic and chain it with other query methods for clean, readable code.Real-world analogy: Query scopes are like preset filters on a camera. Instead of manually adjusting settings every time (ISO, aperture, shutter speed), you have presets like “Portrait,” “Landscape,” or “Night Mode” that apply multiple settings at once. Similarly, scopes apply multiple query conditions with a single method call.
Basic Scopes
Defining Scopes
// models/Post.js
class Post extends Model {
// Simple scope - no parameters
static scopePublished(query) {
return query.where('is_published', true);
}
// Scope with parameters
static scopeOfType(query, type) {
return query.where('type', type);
}
// Complex scope with multiple conditions
static scopePopular(query, threshold = 100) {
return query
.where('views', '>', threshold)
.where('likes', '>', 10)
.orderBy('views', 'desc');
}
// Date-based scope
static scopeRecent(query, days = 7) {
const date = new Date();
date.setDate(date.getDate() - days);
return query.where('created_at', '>', date);
}
}
Using Scopes
// Simple scope usage
const publishedPosts = await Post.query().published().get();
// Scope with parameters
const articles = await Post.query().ofType('article').get();
// Chaining multiple scopes
const popularRecentPosts = await Post.query()
.published()
.popular(500)
.recent(30)
.get();
// Combining scopes with other query methods
const results = await Post.query()
.published()
.ofType('tutorial')
.where('author_id', userId)
.orderBy('created_at', 'desc')
.limit(10)
.get();
Scope Naming: Scope methods are automatically available without the
scope prefix. scopePublished becomes published(), scopeOfType becomes ofType().Advanced Scope Patterns
Conditional Scopes
class User extends Model {
static scopeActive(query, isActive = true) {
return query.where('is_active', isActive);
}
static scopeByRole(query, roles) {
if (Array.isArray(roles)) {
return query.whereIn('role', roles);
}
return query.where('role', roles);
}
static scopeSearch(query, term) {
if (!term) return query;
return query.where((q) => {
q.where('name', 'like', `%${term}%`)
.orWhere('email', 'like', `%${term}%`)
.orWhere('username', 'like', `%${term}%`);
});
}
static scopeWithinDateRange(query, startDate, endDate) {
if (startDate) {
query = query.where('created_at', '>=', startDate);
}
if (endDate) {
query = query.where('created_at', '<=', endDate);
}
return query;
}
}
// Usage
const users = await User.query()
.active()
.byRole(['admin', 'editor'])
.search('john')
.withinDateRange(new Date('2023-01-01'), new Date('2023-12-31'))
.get();
Relationship Scopes
class Post extends Model {
static scopeByAuthor(query, authorId) {
return query.where('user_id', authorId);
}
static scopeWithAuthor(query, authorName) {
return query.whereHas('author', (authorQuery) => {
authorQuery.where('name', 'like', `%${authorName}%`);
});
}
static scopeWithComments(query, minComments = 1) {
return query.has('comments', '>=', minComments);
}
static scopeWithoutComments(query) {
return query.doesntHave('comments');
}
static scopeInCategory(query, categorySlug) {
return query.whereHas('category', (categoryQuery) => {
categoryQuery.where('slug', categorySlug);
});
}
static scopeWithTags(query, tags) {
return query.whereHas('tags', (tagQuery) => {
if (Array.isArray(tags)) {
tagQuery.whereIn('name', tags);
} else {
tagQuery.where('name', tags);
}
});
}
}
// Usage
const posts = await Post.query()
.published()
.withAuthor('John')
.withComments(5)
.inCategory('technology')
.withTags(['javascript', 'nodejs'])
.get();
Aggregation Scopes
class Order extends Model {
static scopeByStatus(query, status) {
return query.where('status', status);
}
static scopeByDateRange(query, startDate, endDate) {
return query.whereBetween('created_at', [startDate, endDate]);
}
static scopeWithMinTotal(query, minAmount) {
return query.where('total', '>=', minAmount);
}
static scopeGroupedByStatus(query) {
return query
.select('status')
.selectRaw('COUNT(*) as count')
.selectRaw('SUM(total) as total_amount')
.selectRaw('AVG(total) as average_amount')
.groupBy('status');
}
static scopeMonthlyStats(query, year) {
return query
.selectRaw('MONTH(created_at) as month')
.selectRaw('COUNT(*) as order_count')
.selectRaw('SUM(total) as revenue')
.whereYear('created_at', year)
.groupBy('month')
.orderBy('month');
}
}
// Usage
const stats = await Order.query()
.byStatus('completed')
.byDateRange(startDate, endDate)
.monthlyStats(2023)
.get();
Dynamic Scopes
Parameterized Dynamic Scopes
class Product extends Model {
static scopeInPriceRange(query, min, max) {
if (min !== undefined) {
query = query.where('price', '>=', min);
}
if (max !== undefined) {
query = query.where('price', '<=', max);
}
return query;
}
static scopeWithFilters(query, filters = {}) {
// Category filter
if (filters.category) {
query = query.where('category_id', filters.category);
}
// Brand filter
if (filters.brand) {
query = query.where('brand', filters.brand);
}
// Price range filter
if (filters.minPrice || filters.maxPrice) {
query = query.inPriceRange(filters.minPrice, filters.maxPrice);
}
// In stock filter
if (filters.inStock) {
query = query.where('stock_quantity', '>', 0);
}
// Rating filter
if (filters.minRating) {
query = query.where('average_rating', '>=', filters.minRating);
}
// Search filter
if (filters.search) {
query = query.where((q) => {
q.where('name', 'like', `%${filters.search}%`)
.orWhere('description', 'like', `%${filters.search}%`);
});
}
return query;
}
static scopeSortBy(query, sortBy, direction = 'asc') {
const allowedSorts = ['name', 'price', 'created_at', 'average_rating'];
if (allowedSorts.includes(sortBy)) {
return query.orderBy(sortBy, direction);
}
return query.orderBy('created_at', 'desc');
}
}
// Usage
const products = await Product.query()
.withFilters({
category: 1,
minPrice: 10,
maxPrice: 100,
inStock: true,
minRating: 4,
search: 'laptop',
})
.sortBy('price', 'asc')
.paginate(1, 20);
Builder Pattern Scopes
class User extends Model {
static scopeFilter(query, callback) {
return callback(new UserFilterBuilder(query));
}
}
class UserFilterBuilder {
constructor(query) {
this.query = query;
}
active(isActive = true) {
this.query = this.query.where('is_active', isActive);
return this;
}
role(roles) {
if (Array.isArray(roles)) {
this.query = this.query.whereIn('role', roles);
} else {
this.query = this.query.where('role', roles);
}
return this;
}
search(term) {
if (term) {
this.query = this.query.where((q) => {
q.where('name', 'like', `%${term}%`)
.orWhere('email', 'like', `%${term}%`);
});
}
return this;
}
createdAfter(date) {
this.query = this.query.where('created_at', '>', date);
return this;
}
createdBefore(date) {
this.query = this.query.where('created_at', '<', date);
return this;
}
withPosts(minPosts = 1) {
this.query = this.query.has('posts', '>=', minPosts);
return this;
}
// Return the final query
build() {
return this.query;
}
}
// Usage
const users = await User.query()
.filter((filter) => {
return filter
.active()
.role(['admin', 'editor'])
.search('john')
.createdAfter(new Date('2023-01-01'))
.withPosts(5)
.build();
})
.get();
Global Scopes
Automatic Scopes
// Global scope that applies to all queries
class SoftDeleteScope {
apply(query, model) {
if (model.softDeletes) {
return query.whereNull(`${model.table}.deleted_at`);
}
return query;
}
}
class TenantScope {
constructor(tenantId) {
this.tenantId = tenantId;
}
apply(query, model) {
if (model.hasColumn('tenant_id')) {
return query.where(`${model.table}.tenant_id`, this.tenantId);
}
return query;
}
}
// Apply global scopes
class User extends Model {
static softDeletes = true;
static boot() {
super.boot();
// Add global scopes
this.addGlobalScope(new SoftDeleteScope());
this.addGlobalScope(new TenantScope(getCurrentTenantId()));
}
// Method to remove global scopes
static withoutGlobalScopes() {
return this.query().withoutGlobalScopes();
}
static withTrashed() {
return this.query().withoutGlobalScope(SoftDeleteScope);
}
}
Scope Composition
Combining Scopes
class Post extends Model {
// Base scopes
static scopePublished(query) {
return query.where('is_published', true);
}
static scopeFeatured(query) {
return query.where('is_featured', true);
}
static scopeRecent(query, days = 7) {
const date = new Date();
date.setDate(date.getDate() - days);
return query.where('created_at', '>', date);
}
// Composite scopes
static scopeFeaturedAndRecent(query, days = 7) {
return query.featured().recent(days);
}
static scopePublishedAndPopular(query, minViews = 1000) {
return query
.published()
.where('views', '>', minViews)
.orderBy('views', 'desc');
}
static scopeHomepageContent(query) {
return query
.published()
.featured()
.recent(30)
.orderBy('featured_at', 'desc')
.limit(5);
}
}
// Usage
const homepagePosts = await Post.query().homepageContent().get();
Scope Inheritance
// Base model with common scopes
class BaseModel extends Model {
static scopeActive(query) {
return query.where('is_active', true);
}
static scopeCreatedBetween(query, startDate, endDate) {
return query.whereBetween('created_at', [startDate, endDate]);
}
static scopeOrderByLatest(query) {
return query.orderBy('created_at', 'desc');
}
}
// Inherit scopes
class User extends BaseModel {
// User-specific scopes
static scopeVerified(query) {
return query.whereNotNull('email_verified_at');
}
static scopeByRole(query, role) {
return query.where('role', role);
}
}
class Post extends BaseModel {
// Post-specific scopes
static scopePublished(query) {
return query.where('is_published', true);
}
static scopeByAuthor(query, authorId) {
return query.where('user_id', authorId);
}
}
// Both models can use inherited scopes
const activeUsers = await User.query().active().get();
const recentPosts = await Post.query().active().orderByLatest().get();
Testing Scopes
Unit Testing Scopes
// test/scopes/PostScopes.test.js
const Post = require('../../models/Post');
describe('Post Scopes', () => {
beforeEach(async () => {
// Clear and seed test data
await Post.query().delete();
await Post.create({
title: 'Published Post',
is_published: true,
views: 150,
created_at: new Date(),
});
await Post.create({
title: 'Draft Post',
is_published: false,
views: 50,
created_at: new Date(),
});
await Post.create({
title: 'Popular Post',
is_published: true,
views: 1500,
created_at: new Date(),
});
});
describe('published scope', () => {
test('returns only published posts', async () => {
const posts = await Post.query().published().get();
expect(posts).toHaveLength(2);
posts.forEach(post => {
expect(post.is_published).toBe(true);
});
});
});
describe('popular scope', () => {
test('returns posts with views above threshold', async () => {
const posts = await Post.query().popular(100).get();
expect(posts).toHaveLength(2);
posts.forEach(post => {
expect(post.views).toBeGreaterThan(100);
});
});
test('uses default threshold when not provided', async () => {
const posts = await Post.query().popular().get();
posts.forEach(post => {
expect(post.views).toBeGreaterThan(100);
});
});
});
describe('scope chaining', () => {
test('can chain multiple scopes', async () => {
const posts = await Post.query()
.published()
.popular(100)
.get();
posts.forEach(post => {
expect(post.is_published).toBe(true);
expect(post.views).toBeGreaterThan(100);
});
});
});
});
Integration Testing
// test/integration/PostFiltering.test.js
describe('Post Filtering Integration', () => {
test('complex filtering scenario', async () => {
// Create test data
const author = await User.create({
name: 'Test Author',
email: 'author@test.com',
});
const category = await Category.create({
name: 'Technology',
slug: 'technology',
});
const post = await Post.create({
title: 'JavaScript Tutorial',
content: 'Learn JavaScript...',
is_published: true,
views: 500,
user_id: author.id,
category_id: category.id,
});
// Test complex scope chain
const results = await Post.query()
.published()
.popular(100)
.byAuthor(author.id)
.inCategory('technology')
.get();
expect(results).toHaveLength(1);
expect(results[0].id).toBe(post.id);
});
});
Performance Optimization
Efficient Scopes
class Post extends Model {
// ✅ Good - uses indexes
static scopePublished(query) {
return query.where('is_published', true); // Assumes index on is_published
}
// ✅ Good - limits results early
static scopeLatest(query, limit = 10) {
return query
.orderBy('created_at', 'desc')
.limit(limit);
}
// ✅ Good - uses specific selects
static scopeForListing(query) {
return query.select([
'id', 'title', 'excerpt', 'created_at', 'user_id'
]);
}
// ❌ Avoid - expensive operations
static scopeWithExpensiveCalculation(query) {
return query.selectRaw(`
*,
(SELECT COUNT(*) FROM comments WHERE post_id = posts.id) as comment_count,
(SELECT AVG(rating) FROM reviews WHERE post_id = posts.id) as avg_rating
`);
}
}
Cached Scopes
class Post extends Model {
static scopePopularCached(query, threshold = 100) {
const cacheKey = `popular_posts_${threshold}`;
// Check cache first
const cached = cache.get(cacheKey);
if (cached) {
return query.whereIn('id', cached);
}
// If not cached, build query and cache result
return query
.where('views', '>', threshold)
.orderBy('views', 'desc')
.tap(async (results) => {
const ids = results.map(post => post.id);
cache.put(cacheKey, ids, 3600); // Cache for 1 hour
});
}
}
Best Practices
1. Keep Scopes Focused
// ✅ Good - single responsibility
static scopePublished(query) {
return query.where('is_published', true);
}
static scopeRecent(query, days = 7) {
const date = new Date();
date.setDate(date.getDate() - days);
return query.where('created_at', '>', date);
}
// ❌ Bad - doing too much
static scopePublishedAndRecentAndPopular(query) {
return query
.where('is_published', true)
.where('created_at', '>', someDate)
.where('views', '>', 100)
.orderBy('views', 'desc');
}
2. Use Descriptive Names
// ✅ Good - clear intent
static scopePublished(query) { /* ... */ }
static scopeByAuthor(query, authorId) { /* ... */ }
static scopeWithMinimumViews(query, views) { /* ... */ }
// ❌ Bad - unclear purpose
static scopeFilter1(query) { /* ... */ }
static scopeSpecial(query) { /* ... */ }
static scopeCustom(query, param) { /* ... */ }
3. Handle Parameters Safely
// ✅ Good - validates parameters
static scopeByRole(query, roles) {
if (!roles) return query;
if (Array.isArray(roles)) {
return query.whereIn('role', roles);
}
return query.where('role', roles);
}
// ❌ Bad - no parameter validation
static scopeByRole(query, roles) {
return query.whereIn('role', roles); // Fails if roles is not array
}
4. Document Complex Scopes
/**
* Scope for filtering posts by engagement metrics
*
* @param {QueryBuilder} query
* @param {Object} options
* @param {number} options.minViews - Minimum view count
* @param {number} options.minLikes - Minimum like count
* @param {number} options.minComments - Minimum comment count
* @param {number} options.days - Days to look back (default: 30)
*/
static scopeHighEngagement(query, options = {}) {
const {
minViews = 1000,
minLikes = 50,
minComments = 10,
days = 30
} = options;
const date = new Date();
date.setDate(date.getDate() - days);
return query
.where('views', '>=', minViews)
.where('likes', '>=', minLikes)
.has('comments', '>=', minComments)
.where('created_at', '>', date);
}
Next Steps
API Reference
Explore the complete API documentation
Model Methods
Learn about all available model methods
