DEV Community

Cover image for Eloquent vs Query Builder in Laravel: When to Use Each
devTalk
devTalk

Posted on Originally published at dev-talk.com

Eloquent vs Query Builder in Laravel: When to Use Each

Use Eloquent when you are working with individual records that have behavior: validation rules, casts, relationships, events, soft deletes. Use the query builder when you are working with rows as data: reports, joins, aggregates, bulk writes and large exports. That rule covers most cases, but the details are where bugs come from, because the two do not behave the same on updates, deletes and inserts. I ran both against a 50,000-row MariaDB table using the real framework code (Laravel 13.35.0) to measure the cost and to check every claim below.

They are the same engine

An Eloquent builder is a wrapper around the query builder. The same fluent methods exist on both, and the SQL is nearly identical. The only difference in this example is the soft delete condition the model adds on its own:

DB::table('posts')
    ->where('status', 'published')
    ->orderByDesc('id')->limit(5)->toSql();
// select * from `posts` where `status` = ? order by `id` desc limit 5

Post::where('status', 'published')
    ->orderByDesc('id')
    ->limit(5)
    ->toSql();
// select * from `posts` where `status` = ? and `posts`.`deleted_at` is null order by `id` desc limit 5
Enter fullscreen mode Exit fullscreen mode

So the choice is not about SQL features. It is about what happens to the rows before you see them and when you write them back.

What Eloquent adds, and what it costs

The query builder returns plain stdClass objects with the values exactly as the database driver gives them. Eloquent turns every row into a model: it applies casts, attaches the original attributes for dirty tracking, fires a retrieved event and registers the model with its relations. In the test, the same row looked like this from each side:

Column DB::table() Model
meta (JSON) string: '{"tags":["a","b","c"],"score":1}' array (cast)
published_at string: '2026-10-14 13:38:00' Carbon instance (cast)
Row type stdClass Post

That convenience has a price, and it shows when you load many rows. I loaded all 50,000 rows with each approach, in a fresh PHP process each time, three runs per row, taking the median:

Approach (50,000 rows, all columns) Time Extra memory
DB::table('posts')->get() about 160 ms 59 MB
Post::get() about 980 ms 103 MB
Post::get() then reading the casts on every row about 1,900 ms 105 MB
Post::query()->toBase()->get() about 200 ms 60 MB
DB::table() with select('id', 'title') about 65 ms 28 MB
Post::select('id', 'title')->get() about 880 ms 76 MB
DB::table()->chunkById(1000) about 180 ms 2.3 MB
Post::chunkById(1000) about 1,200 ms 4.2 MB
DB::table()->cursor() about 255 ms 8.4 MB
Post::cursor() about 2,900 ms 9.4 MB

Hydrating 50,000 models took about six times longer than reading the same rows as plain objects, and used less than twice the memory. Selecting only the columns you need helps both sides, but does not remove the hydration cost. Nobody renders 50,000 models in one request, so this matters for exports, reports and background jobs, not for a paginated page of 25 rows. The numbers are from one machine with MariaDB on localhost; a network hop would add the same cost to both sides, which would shrink the percentage gap but not the absolute one.

Two practical notes from the table. First, toBase() is the bridge: it keeps your model's query scopes and gives you plain rows. Second, cursor() keeps memory flat but is the slowest option for models. The Laravel documentation also notes that PDO still buffers the raw results, so for very large result sets it recommends lazy(), and chunkById when you update the column you are filtering on.

Three places where they behave differently

This is the part that causes production bugs. I ran each operation inside a transaction and rolled it back, with listeners registered on the model:

Operation Model events Result
$post->update([...]) saving, updating, updated, saved updated_at changed
Post::where(...)->update([...]) none updated_at changed
DB::table('posts')->where(...)->update([...]) none updated_at unchanged
$post->delete() (soft deletes on) deleting, deleted Row kept, deleted_at set
Post::where(...)->delete() none Row kept, deleted_at set
DB::table('posts')->where(...)->delete() none Row permanently deleted
Post::insert([...]) none created_at is NULL
Post::create([...]) saving, saved (the create events fire as well) Timestamps filled

Read it as three rules:

  1. A mass update or delete through Eloquent fires no model events. The Laravel documentation says this directly: the models are never retrieved, so events such as updating and deleting are not dispatched. If you have an observer that clears a cache, writes an audit log or syncs a search index, a Post::where(...)->update() silently skips it. Load the models and save them, or accept that the observer will not run.
  2. The query builder ignores soft deletes. DB::table('posts')->delete() removes the row for good, while the same intent through the model only sets deleted_at. Counts differ too: on my data Post::count() returned 49,000 while DB::table('posts')->count() returned 50,000. A report built on DB::table() will include soft-deleted rows unless you filter them yourself.
  3. Bulkinsert()does not set timestamps, even on a model. Post::insert() goes straight to the query builder, so created_at and updated_at stay NULL unless you supply them. Post::upsert() is different: as documented, it sets the timestamps. In my test it updated updated_at, while DB::table('posts')->upsert() left it untouched.

Mass assignment follows the same split. A model only fills attributes you allow ($fillable in my test; the 13.x documentation shows a Fillable attribute for the same job), and extra keys are silently discarded. In my run, Post::create() with id and an unknown key ignored both, whereas DB::table()->insert() with an unknown column failed with a QueryException. The query builder does protect against SQL injection through parameter binding, but the documentation warns that PDO cannot bind column names, so never let user input pick a column or an order by field.

Which one to use

Task Use Why
Show, create or edit one record in a form or API Eloquent Casts, validation flow, mass-assignment protection, events
Anything with relationships (with(), whereHas()) Eloquent Relationships are the main reason to have models
Rules that must always run (observers, soft deletes, global scopes) Eloquent The query builder skips them
Reports, dashboards, joins across several tables, groupBy aggregates Query builder You want rows and numbers, not objects
Exporting or processing tens of thousands of rows Query builder, or toBase() with chunkById or lazy Roughly 6x faster here, with less memory
Inserting thousands of rows insert() or upsert() Next section
Changing one column on many rows (increment, status flips) One mass update query Next section

Mixing them safely

You rarely need to choose one for a whole application. These patterns use each where it fits.

Read big sets as plain rows but keep the model's scopes (soft deletes included):

Post::query()
    ->where('status', 'published')
    ->toBase()                       // stdClass rows, no model hydration
    ->orderBy('id')
    ->chunkById(1000, function ($rows) {
        foreach ($rows as $row) {
            // $row->title, $row->views ...
        }
    });
Enter fullscreen mode Exit fullscreen mode

Write a report with the query builder and filter soft deletes yourself:

$top = DB::table('posts')
    ->join('users', 'users.id', '=', 'posts.user_id')
    ->whereNull('posts.deleted_at')
    ->groupBy('users.id', 'users.name')
    ->selectRaw('users.id, users.name, count(*) as posts, sum(posts.views) as views')
    ->orderByDesc('views')
    ->limit(20)
    ->get();
Enter fullscreen mode Exit fullscreen mode

Insert in bulk and fill the timestamps yourself:

$now = now();

foreach (array_chunk($rows, 1000) as $chunk) {
    Post::insert(array_map(fn ($r) => $r + ['created_at' => $now, 'updated_at' => $now], $chunk));
}
Enter fullscreen mode Exit fullscreen mode

The speed difference is large. Inserting 5,000 rows took about 2,700 ms with Post::create() in a loop (5,000 queries), and about 120 ms with Post::insert() in chunks of 1,000. Updating the same 5,000 rows one model at a time took about 3,000 to 3,200 ms; one Post::where(...)->increment('views') query took about 15 ms. On a remote database each saved round trip is worth more, so the gap grows. The trade is the one from the table above: the fast version skips events, so only use it where nothing depends on them.

Frequently asked questions

Is Eloquent slow?

Not for normal page work. In my test, loading models with get() cost about 0.02 ms per row, including the query itself. It becomes a problem when you load tens of thousands of rows, or run thousands of single-row queries in a loop, which is more often an N+1 or batching problem than an Eloquent problem.

Can I mix Eloquent and the query builder in one project?

Yes, and most mature Laravel apps do. Keep models for your domain objects and use DB::table() or toBase() for reporting and bulk work. Just remember the three differences above.

Does the query builder protect against SQL injection?

Values are bound as PDO parameters, so you do not need to escape them. Column names and order by fields cannot be bound, so whitelist them if they come from user input.

References: Laravel: Eloquent ORM and Laravel: Query Builder.


Originally published on DEV Talk.

Top comments (0)