Advanced Eloquent: Query Scopes & Builders
Introduction to Advanced Query Techniques
As your Laravel applications grow, you'll need more sophisticated ways to handle database queries. Query scopes and custom builders help you write reusable, expressive, and maintainable database logic.
- Encapsulate complex query logic in reusable methods
- Keep controllers and services clean and focused
- Chain scopes for flexible, readable queries
- Reduce code duplication across your application
Local Scopes
Local scopes are methods you define on your Eloquent models that encapsulate query logic. They always start with the word "scope" followed by the method name in CamelCase.
// app/Models/Post.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
// Scope to get only published posts
public function scopePublished($query)
{
return $query->where('status', 'published');
}
// Scope to get posts from the last N days
public function scopeRecent($query, $days = 7)
{
return $query->where('created_at', '>=', now()->subDays($days));
}
// Scope to filter by category
public function scopeOfCategory($query, $category)
{
return $query->where('category', $category);
}
// Scope with multiple conditions
public function scopeFeatured($query)
{
return $query->where('is_featured', true)
->where('status', 'published')
->orderBy('views', 'desc');
}
}
// Get published posts
$publishedPosts = Post::published()->get();
// Chain multiple scopes
$recentFeaturedPosts = Post::published()
->recent(30)
->featured()
->take(10)
->get();
// Scope with parameters
$techPosts = Post::published()
->ofCategory('technology')
->recent(14)
->get();
// Combine scopes with regular query methods
$popularTechPosts = Post::published()
->ofCategory('technology')
->where('views', '>', 1000)
->orderBy('created_at', 'desc')
->paginate(15);
scopePublished to published() when you call it.
Dynamic Scopes
Dynamic scopes allow you to create flexible, conditional query logic that adapts based on parameters.
// app/Models/Product.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
// Dynamic price range scope
public function scopePriceBetween($query, $min, $max = null)
{
if ($max === null) {
return $query->where('price', '>=', $min);
}
return $query->whereBetween('price', [$min, $max]);
}
// Dynamic sorting scope
public function scopeSortBy($query, $column, $direction = 'asc')
{
$allowedColumns = ['name', 'price', 'created_at', 'popularity'];
if (!in_array($column, $allowedColumns)) {
$column = 'created_at';
}
return $query->orderBy($column, $direction);
}
// Dynamic filter scope
public function scopeFilter($query, array $filters)
{
return $query->when($filters['category'] ?? null, function ($query, $category) {
$query->where('category_id', $category);
})->when($filters['search'] ?? null, function ($query, $search) {
$query->where('name', 'like', "%{$search}%");
})->when($filters['in_stock'] ?? null, function ($query) {
$query->where('stock', '>', 0);
});
}
// Conditional scope
public function scopeAvailable($query, $includeOutOfStock = false)
{
if (!$includeOutOfStock) {
$query->where('stock', '>', 0);
}
return $query->where('is_active', true);
}
}
// Price range filtering
$affordableProducts = Product::priceBetween(10, 50)->get();
$premiumProducts = Product::priceBetween(100)->get();
// Dynamic sorting
$sortedProducts = Product::sortBy('price', 'desc')->get();
// Complex filtering
$filteredProducts = Product::filter([
'category' => 5,
'search' => 'laptop',
'in_stock' => true
])->paginate(20);
// Conditional logic
$availableProducts = Product::available()->get();
$allActiveProducts = Product::available(true)->get();
Global Scopes
Global scopes apply automatically to all queries for a model. They're perfect for implementing soft deletes, multi-tenancy, or default filtering.
// app/Models/Scopes/ActiveScope.php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class ActiveScope implements Scope
{
public function apply(Builder $builder, Model $model)
{
$builder->where('is_active', true);
}
}
// app/Models/Scopes/TenantScope.php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model)
{
if (auth()->check()) {
$builder->where('tenant_id', auth()->user()->tenant_id);
}
}
}
// app/Models/Product.php
namespace App\Models;
use App\Models\Scopes\ActiveScope;
use App\Models\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected static function booted()
{
// Add global scope using class
static::addGlobalScope(new ActiveScope);
// Add global scope using closure
static::addGlobalScope('tenant', function (Builder $builder) {
if (auth()->check()) {
$builder->where('tenant_id', auth()->user()->tenant_id);
}
});
}
}
// Now all queries automatically include these scopes
$products = Product::all(); // Only active products for current tenant
$product = Product::find(1); // Only if active and belongs to tenant
// Remove a specific global scope
$allProducts = Product::withoutGlobalScope(ActiveScope::class)->get();
// Remove multiple global scopes
$allTenantProducts = Product::withoutGlobalScopes([
ActiveScope::class,
'tenant'
])->get();
// Remove all global scopes
$absolutelyAll = Product::withoutGlobalScopes()->get();
Custom Query Builders
Custom query builders allow you to extend Laravel's query builder with your own methods, creating a fluent interface tailored to your domain.
// app/Models/Builders/PostBuilder.php
namespace App\Models\Builders;
use Illuminate\Database\Eloquent\Builder;
class PostBuilder extends Builder
{
public function published()
{
return $this->where('status', 'published');
}
public function draft()
{
return $this->where('status', 'draft');
}
public function byAuthor($authorId)
{
return $this->where('author_id', $authorId);
}
public function popular($threshold = 100)
{
return $this->where('views', '>=', $threshold)
->orderBy('views', 'desc');
}
public function withCategory()
{
return $this->with(['category' => function ($query) {
$query->select('id', 'name', 'slug');
}]);
}
public function searchByTitle($term)
{
return $this->where('title', 'like', "%{$term}%");
}
public function publishedBetween($startDate, $endDate)
{
return $this->whereBetween('published_at', [$startDate, $endDate]);
}
}
// app/Models/Post.php
namespace App\Models;
use App\Models\Builders\PostBuilder;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
// Tell the model to use custom builder
public function newEloquentBuilder($query)
{
return new PostBuilder($query);
}
// Optional: Add PHPDoc for IDE autocomplete
/**
* @return PostBuilder
*/
public static function query()
{
return parent::query();
}
}
// Now you can use custom builder methods
$popularPosts = Post::published()
->popular(500)
->withCategory()
->take(10)
->get();
$authorDrafts = Post::draft()
->byAuthor(5)
->orderBy('updated_at', 'desc')
->get();
$searchResults = Post::published()
->searchByTitle('Laravel')
->publishedBetween(now()->subMonths(3), now())
->paginate(15);
Query Builder Macros
Macros allow you to add custom methods to the query builder globally, making them available on all models.
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
// Macro to order by multiple columns
Builder::macro('orderByMultiple', function (array $columns) {
foreach ($columns as $column => $direction) {
$this->orderBy($column, $direction);
}
return $this;
});
// Macro to add WHERE IN clause more fluently
Builder::macro('whereIdIn', function (array $ids) {
return $this->whereIn('id', $ids);
});
// Macro to get random records
Builder::macro('random', function ($count = 1) {
return $this->inRandomOrder()->limit($count);
});
// Macro for common date ranges
Builder::macro('today', function () {
return $this->whereDate('created_at', today());
});
Builder::macro('thisWeek', function () {
return $this->whereBetween('created_at', [
now()->startOfWeek(),
now()->endOfWeek()
]);
});
Builder::macro('thisMonth', function () {
return $this->whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year);
});
}
}
// Use macros on any model
$products = Product::whereIdIn([1, 5, 10, 15])->get();
$randomPosts = Post::published()->random(3)->get();
$todayOrders = Order::today()->get();
$thisWeekSales = Sale::thisWeek()->sum('amount');
$sortedProducts = Product::orderByMultiple([
'category' => 'asc',
'price' => 'desc',
'name' => 'asc'
])->get();
MacroServiceProvider) to keep your AppServiceProvider clean and organized.
Exercise 1: Create Local Scopes
Create a User model with the following local scopes:
active()- Returns users withis_active = trueverified()- Returns users with non-nullemail_verified_atadmins()- Returns users withrole = 'admin'registeredAfter($date)- Returns users registered after a given date
Then write a query to get all active, verified admins registered in the last 30 days.
Exercise 2: Build a Custom Query Builder
Create a custom query builder for an Order model with these methods:
pending()- Orders with status "pending"completed()- Orders with status "completed"forCustomer($customerId)- Orders for a specific customertotalOver($amount)- Orders where total exceeds a given amountrecent($days = 7)- Orders from the last N days
Implement the builder and use it to find all completed orders for customer #5 with a total over $100 in the last 30 days.
Exercise 3: Create a Global Scope
Create a global scope called PublishedScope that automatically filters models where published_at is not null and is in the past. Apply it to an Article model, then write code to:
- Get all published articles (scope applied)
- Get all articles including unpublished (scope removed)
- Count published articles vs total articles
- Local scopes are perfect for reusable query logic on specific models
- Global scopes apply automatically to all queries for a model
- Custom query builders provide a fluent, domain-specific interface
- Query builder macros extend functionality across all models
- All these techniques help keep your code DRY and maintainable