Advanced Laravel

Advanced Eloquent: Query Scopes & Builders

18 min Lesson 1 of 40

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.

Why Use Query Scopes?
  • 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.

Basic Local Scope:
// 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');
    }
}
Using Local Scopes:
// 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);
Naming Convention: When calling scopes, you use the method name without the "scope" prefix. Laravel automatically converts scopePublished to published() when you call it.

Dynamic Scopes

Dynamic scopes allow you to create flexible, conditional query logic that adapts based on parameters.

Dynamic Scope Examples:
// 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);
    }
}
Using Dynamic Scopes:
// 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.

Creating a Global Scope:
// 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);
        }
    }
}
Applying Global Scopes:
// 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
Removing Global Scopes:
// 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();
Global Scope Gotcha: Global scopes apply to all queries, including relationship queries. Be careful when using them with eager loading, as they might filter out related models unexpectedly.

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.

Creating a Custom Builder:
// 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]);
    }
}
Using Custom Builder in Model:
// 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();
    }
}
Using Custom Builder Methods:
// 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.

Registering Query Builder Macros:
// 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);
        });
    }
}
Using Query Builder Macros:
// 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();
Best Practice: Register macros in a dedicated service provider (like 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 with is_active = true
  • verified() - Returns users with non-null email_verified_at
  • admins() - Returns users with role = '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 customer
  • totalOver($amount) - Orders where total exceeds a given amount
  • recent($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
Key Takeaways:
  • 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