Skip to main content

🔎 Querying with Eloquent

Eloquent's query builder is a fluent, chainable interface that compiles into safe, optimized SQL. This lesson takes you past basic CRUD into where clauses, ordering, pagination, aggregates, conditional queries, and reusable scopes — then shows how to keep it all fast.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Retrieve records with find, first, get, and their orFail variants
  • Build precise filters with where clauses, including grouped and JSON conditions
  • Order, limit, and paginate results for real UIs
  • Compute aggregates and build queries conditionally with when()
  • Encapsulate reusable logic in local and global scopes, and process big data with chunking

Estimated Time: 50–60 minutes  •  Difficulty: Intermediate

Hands-on: Build a conditional, paginated product-filter query for a shop.

In This Lesson

The Query Builder

Every Eloquent query flows through a query builder: you chain methods in PHP, and Eloquent compiles them into parameterized SQL. You describe what you want; the builder handles how to ask the database.

flowchart LR A[Eloquent code] --> B[Query builder] B --> C[Parameterized SQL] C --> D[(Database)] D --> E[Collection / Model]

That abstraction buys you four things at once:

  • Database-agnostic — the same code runs on MySQL, PostgreSQL, SQLite, and SQL Server.
  • Injection-safe — values are bound as parameters, never concatenated into SQL strings.
  • Readable — method chains read close to plain English.
  • Composable — you can build a query in pieces and only execute it when you call get().
💡 Analogy: The builder is a translator. You speak elegant, object-oriented PHP; it speaks fluent, optimized SQL to the database on your behalf — and it never lets a malicious phrase slip through unescaped.

Retrieving Records

Start with the everyday retrieval methods. Note which return a single model versus a collection:

$users = User::all();                       // Collection of every row
$user  = User::find(1);                      // one model, or null
$users = User::find([1, 2, 3]);              // Collection by many IDs
$user  = User::where('active', true)->first(); // first match, or null

// The orFail variants throw ModelNotFoundException (auto 404 in a route)
$user  = User::findOrFail(1);
$user  = User::where('email', $email)->firstOrFail();

// Find or create in one call
$user  = User::firstOrCreate(
    ['email' => 'john@example.com'],        // search by this
    ['name'  => 'John', 'password' => bcrypt('secret')] // create with this
);

Methods returning multiple models give you an Illuminate\Database\Eloquent\Collection — a supercharged array with dozens of helpers:

$users = User::all();

$admins   = $users->where('is_admin', true);  // filter in memory
$names    = $users->pluck('name');            // Collection of names
$byType   = $users->groupBy('type');          // grouped Collection
$avgAge   = $users->avg('age');               // aggregate in memory

⚠️ Collection methods run in PHP, not SQL

User::all()->where(...) pulls every row into memory first, then filters. User::where(...)->get() filters in the database and returns only matches. For anything but tiny tables, filter in the query, not the collection.

Where Clauses

The where family is where most filtering happens. The basics:

$users = User::where('active', true)->get();
$users = User::where('age', '>=', 21)->get();

// Chained wheres are AND; orWhere adds OR
$users = User::where('active', true)
    ->where('age', '>=', 21)
    ->orWhere('role', 'admin')
    ->get();

Specialized wheres

User::whereBetween('age', [18, 65])->get();
User::whereIn('id', [1, 2, 3])->get();
User::whereNotNull('email_verified_at')->get();
User::whereDate('created_at', '2026-01-01')->get();
User::whereColumn('created_at', '>', 'updated_at')->get();

// Query a JSON column (MySQL / PostgreSQL / SQLite)
User::where('preferences->theme', 'dark')->get();

Grouped clauses

Pass a closure to wrap conditions in parentheses — essential for mixing AND and OR correctly:

// WHERE (role = 'admin' AND active = 1)
//    OR (role = 'user'  AND verified = 1)
$users = User::where(function ($query) {
        $query->where('role', 'admin')->where('active', true);
    })
    ->orWhere(function ($query) {
        $query->where('role', 'user')->where('verified', true);
    })
    ->get();

⚠️ The classic OR bug

Writing ->where('active', true)->where('role','admin')->orWhere('role','editor') parses as (active AND admin) OR editor — inactive editors leak in. When you combine ANDs with an OR, always wrap the OR branch in a closure group.

Ordering & Pagination

Ordering & limiting

User::orderBy('name')->get();
User::orderBy('created_at', 'desc')->get();
User::latest()->get();               // shortcut for created_at desc
User::oldest()->get();               // created_at asc
User::inRandomOrder()->first();      // one random row

User::take(10)->get();               // LIMIT 10
User::skip(20)->take(10)->get();     // OFFSET 20 LIMIT 10

Pagination

For real UIs you rarely dump every row — you paginate. Eloquent offers three flavors:

MethodGives youBest for
paginate(15)Numbered pages + total countStandard page lists
simplePaginate(15)Only Prev / Next (no count query)Big tables where the total is expensive
cursorPaginate(15)Cursor-based, very efficientInfinite scroll, huge datasets
// Controller
$users = User::where('active', true)
    ->orderBy('name')
    ->paginate(15)
    ->withQueryString();   // keep ?filter=... across page links
{{-- Blade view --}}
@foreach ($users as $user)
    <div>{{ $user->name }}</div>
@endforeach

{{ $users->links() }}   {{-- renders the pager --}}
💡 Why cursor pagination scales: paginate() uses OFFSET, which forces the database to scan and discard all skipped rows — page 10,000 is slow. cursorPaginate() instead remembers "the last id I saw" and uses a WHERE id > ?, so every page costs the same.

Aggregates

Aggregates ask the database for a summary instead of rows — far cheaper than pulling everything and counting in PHP:

$count   = User::count();
$active  = User::where('active', true)->count();
$revenue = Order::sum('total');
$avg     = Order::avg('total');
$max     = Order::max('total');

// Grouped aggregate: order count per status
$byStatus = Order::selectRaw('status, COUNT(*) as count')
    ->groupBy('status')
    ->get();

Relationship-aware aggregates let you sort or filter by related data without a manual join:

// Most-commented posts first
$posts = Post::withCount('comments')
    ->orderByDesc('comments_count')
    ->get();

// Attach the average review rating as reviews_avg_rating
$products = Product::withAvg('reviews', 'rating')->get();

📖 Key Term

Aggregate function: a SQL function (COUNT, SUM, AVG, MIN, MAX) that collapses many rows into a single summary value — optionally per group when paired with GROUP BY.

Conditional Queries

Filters that depend on user input tempt you into a thicket of if statements. The when() method keeps the chain fluent — the closure runs only when its condition is truthy:

$users = User::query()
    ->when($request->filled('role'), function ($query) use ($request) {
        $query->where('role', $request->input('role'));
    })
    ->when($request->filled('search'), function ($query) use ($request) {
        $search = $request->input('search');
        $query->where(function ($q) use ($search) {
            $q->where('name', 'like', "%{$search}%")
              ->orWhere('email', 'like', "%{$search}%");
        });
    })
    ->paginate(15);

when() also takes a second closure as an "else" branch, and unless() is its inverse:

$users = User::when(
    $sortField,
    fn ($q) => $q->orderBy($sortField, $sortDirection ?? 'asc'),
    fn ($q) => $q->orderBy('name'),   // default when no sort chosen
)->get();

// Only limit visibility for non-admins
$users = User::unless($isAdmin, fn ($q) => $q->where('visible', true))->get();

✅ Why when() beats if

Because when() returns the builder, your whole query stays one uninterrupted chain — easier to read, and you never accidentally forget to reassign $query inside an if block.

Local & Global Scopes

Scopes package a common constraint so you write it once and reuse it everywhere.

Local scopes

Prefix a model method with scope and call it (minus the prefix) as a query method:

use Illuminate\Database\Eloquent\Builder;

class User extends Model
{
    public function scopeActive(Builder $query): Builder
    {
        return $query->where('active', true);
    }

    public function scopeOfType(Builder $query, string $type): Builder
    {
        return $query->where('type', $type);
    }
}

// Read almost like a sentence
$admins = User::active()->ofType('admin')->orderBy('name')->get();

Global scopes

A global scope applies to every query on the model automatically — perfect for soft deletes or multi-tenant isolation. In Laravel 11 the tidiest way is the #[ScopedBy] attribute:

<?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): void
    {
        $builder->where('active', true);
    }
}
use App\Models\Scopes\ActiveScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;

#[ScopedBy(ActiveScope::class)]
class User extends Model
{
    // Every query now includes WHERE active = 1
}

// Opt out when you genuinely need inactive users
$all = User::withoutGlobalScope(ActiveScope::class)->get();

⚠️ Global scopes are easy to forget

Because they apply invisibly, a global scope can make rows "disappear" from queries and confuse teammates debugging missing data. Reserve them for cross-cutting rules everyone expects (soft deletes, tenancy) and document them clearly.

Performance & Chunking

Chunking large datasets

Never load a million rows into memory at once. Process them in batches:

// Process 100 at a time
User::where('active', true)->chunkById(100, function ($users) {
    foreach ($users as $user) {
        // ...work on each user
    }
});

// Or lazily iterate one model at a time, memory-friendly
foreach (User::lazy() as $user) {
    // ...
}

chunkById is safer than plain chunk when you modify rows inside the loop — it paginates by primary key rather than OFFSET, so shifting rows don't get skipped.

Query-tuning checklist

TechniqueWhat it fixes
select('id','name')Stops pulling columns you never use
with('relation')Eliminates the N+1 query problem
chunkById() / lazy()Caps memory on big tables
DB indexes on filtered columnsTurns full scans into fast lookups
Cache::remember()Avoids re-running expensive, stable queries
// See exactly what SQL your code generates
DB::enableQueryLog();
$users = User::where('active', true)->get();
dump(DB::getQueryLog());   // array of every query + bindings
💡 Analogy: Query tuning is engine tuning — small, deliberate adjustments to how you fetch data compound into large gains once traffic grows. Measure first (query log, Laravel Telescope), then optimize the real bottleneck.

Hands-on Exercise

🏋️ Build a Product Filter

Objective: Combine conditional queries, aggregates, and pagination into one realistic controller method.

Requirements:

  1. Only ever return active products.
  2. If a search term is present, match it against name OR description.
  3. If category is present, filter to that category.
  4. If on_sale is truthy, show only products whose sale_price is below price.
  5. Eager load the category, attach the average review rating, and paginate 24 per page keeping the query string.
💡 Hint

Reach for when($request->filled('...'), ...) for each optional filter. Use whereColumn('sale_price', '<', 'price') for the sale check and withAvg('reviews', 'rating') for the rating.

✅ Sample solution
public function index(Request $request)
{
    $products = Product::query()
        ->where('active', true)
        ->when($request->filled('search'), function ($query) use ($request) {
            $term = $request->input('search');
            $query->where(function ($q) use ($term) {
                $q->where('name', 'like', "%{$term}%")
                  ->orWhere('description', 'like', "%{$term}%");
            });
        })
        ->when($request->filled('category'), fn ($q) =>
            $q->where('category_id', $request->input('category'))
        )
        ->when($request->boolean('on_sale'), fn ($q) =>
            $q->whereColumn('sale_price', '<', 'price')
        )
        ->with('category')
        ->withAvg('reviews', 'rating')
        ->orderBy('name')
        ->paginate(24)
        ->withQueryString();

    return view('products.index', compact('products'));
}

🎯 Quick Quiz

Question 1: What is the difference between User::all()->where(...) and User::where(...)->get()?

Question 2: Why prefer when() over a plain if when building conditional queries?

Question 3: Which pagination method scales best for infinite scroll over a huge table?

Summary & Quiz

🎉 Key Takeaways

  • The query builder compiles fluent PHP into safe, database-agnostic SQL — filter in the query, not the collection.
  • Where clauses handle every condition; wrap OR branches in closures to avoid precedence bugs.
  • Order, limit, and paginate — reach for cursorPaginate() on very large datasets.
  • Aggregates (count, sum, withCount, withAvg) summarize in the database; when() builds queries conditionally.
  • Scopes package reusable constraints; chunking and indexes keep big queries fast.

📚 Further Reading

🚀 What's Next?

You can now read and shape data with confidence. Next, in Form Handling in Laravel, you'll capture user input, validate it, and turn it into the models you've just learned to query.

🎉 Query master!

From a single record to a paginated, filtered, optimized result set — you've got the full toolkit.