Laravel: Eloquent vs Query Builder vs Raw SQL (When to Use Each)

Laravel gives you three ways to talk to the database, and you can mix all three in the same project:

  • Eloquent, the ORM: Order::where('status', 'paid')->get()
  • The Query Builder: DB::table('orders')->where('status', 'paid')->get()
  • Raw SQL: DB::select('select * from orders where status = ?', ['paid'])

People argue about which one is “right”. The real answer is that each one is the best tool for a different job. This guide shows the same tasks written all three ways, the SQL each one actually sends to MySQL, and a simple rule for choosing. It also covers the two mistakes that cause most Laravel database problems: the N+1 query and SQL injection through raw queries.

Eloquent, Query Builder and raw SQL in Laravel compared: what each returns and the best use for each

The sample tables

Two tables: customers and orders. A customer has many orders.

idnamecity
1AliManama
2SaraRiffa
3OmarManama
4LinaMuharraq

The orders table has id, customer_id, status (paid, pending or cancelled), amount and created_at, with 8 orders in October 2026.

The Eloquent models, with the relationship defined on both sides:

// app/Models/Customer.php
class Customer extends Model
{
    public function orders(): HasMany
    {
        return $this->hasMany(Order::class);
    }
}

// app/Models/Order.php
class Order extends Model
{
    protected $casts = ['amount' => 'decimal:2'];

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }
}

Task 1: The 10 latest paid orders

Eloquent:

$orders = Order::where('status', 'paid')
    ->latest()          // order by created_at desc
    ->take(10)
    ->get();

Query Builder:

$orders = DB::table('orders')
    ->where('status', 'paid')
    ->orderByDesc('created_at')
    ->limit(10)
    ->get();

Raw SQL:

$orders = DB::select(
    'select * from orders where status = ? order by created_at desc limit 10',
    ['paid']
);

All three send the same SQL to MySQL:

select * from `orders` where `status` = ? order by `created_at` desc limit 10

The difference is what you get back:

  • Eloquent returns a Collection of Order models. Each one knows its relationships ($order->customer), applies casts (amount as a decimal string, dates as Carbon objects), and can be changed and saved with $order->save().
  • The Query Builder returns a Collection of plain stdClass objects. Just data, no relationships, no casts.
  • DB::select returns a plain PHP array of stdClass objects.

Notice the ?. In all three, the value 'paid' is sent separately as a binding, never pasted into the SQL text. That’s what protects you from SQL injection. Remember it for later.

Task 2: A report with JOIN, GROUP BY and HAVING

Customers whose paid orders total more than 500, biggest first.

Eloquent (with withSum, Laravel 8+):

$customers = Customer::withSum(['orders as total' => fn ($q) => $q->where('status', 'paid')], 'amount')
    ->withCount(['orders as orders_count' => fn ($q) => $q->where('status', 'paid')])
    ->having('total', '>', 500)
    ->orderByDesc('total')
    ->get();

This works, but it’s not obvious what SQL it produces (two correlated subqueries per customer), and it gets harder to read with every extra condition.

Query Builder:

$customers = DB::table('orders')
    ->join('customers', 'customers.id', '=', 'orders.customer_id')
    ->where('orders.status', 'paid')
    ->groupBy('customers.id', 'customers.name')
    ->select('customers.name')
    ->selectRaw('SUM(orders.amount) AS total, COUNT(*) AS orders_count')
    ->havingRaw('SUM(orders.amount) > ?', [500])
    ->orderByDesc('total')
    ->get();

Raw SQL:

$customers = DB::select('
    SELECT c.name, SUM(o.amount) AS total, COUNT(*) AS orders_count
    FROM orders AS o
    JOIN customers AS c ON c.id = o.customer_id
    WHERE o.status = ?
    GROUP BY c.id, c.name
    HAVING SUM(o.amount) > ?
    ORDER BY total DESC
', ['paid', 500]);

All three return the same result:

nametotalorders_count
Sara1450.002
Ali1150.002
Omar980.001

For reports, the Query Builder and raw SQL are usually clearer than Eloquent. The Query Builder version also works on any database Laravel supports, while the raw version is plain SQL you can copy into MySQL Workbench or SSMS to test. If GROUP BY and HAVING are new to you, read those first.

Task 3: Things the Query Builder can’t express

Some SQL has no builder method: window functions, recursive CTEs, PIVOT, database-specific hints. That’s where raw SQL earns its place:

$ranked = DB::select('
    SELECT c.name, o.amount,
           RANK() OVER (PARTITION BY o.customer_id ORDER BY o.amount DESC) AS rnk
    FROM orders AS o
    JOIN customers AS c ON c.id = o.customer_id
    WHERE o.status = ?
', ['paid']);

You don’t have to go fully raw, either. selectRaw, whereRaw, orderByRaw and havingRaw let you drop a piece of SQL into an otherwise normal builder query. Stored procedures work the same way: DB::select('CALL get_customer_orders(?)', [$id]) in MySQL, or DB::select('EXEC dbo.GetCustomerOrders ?', [$id]) in SQL Server. For statements that return nothing (DDL, SET options), use DB::statement().

Task 4: Changing data

Eloquent shines when you work with one record and its rules:

$order = Order::findOrFail($id);
$order->status = 'paid';
$order->save();     // fires events, updates updated_at, applies casts and mutators

For many rows, don’t loop:

// Slow: one SELECT, then one UPDATE per order
foreach (Order::where('status', 'pending')->where('created_at', '<', now()->subDays(30))->get() as $order) {
    $order->update(['status' => 'cancelled']);
}

// Fast: one UPDATE statement
Order::where('status', 'pending')
    ->where('created_at', '<', now()->subDays(30))
    ->update(['status' => 'cancelled']);

The second version sends a single update orders set status = ?, updated_at = ? where .... The trade-off: mass updates don’t load the models, so model events (updating, updated) and observers don’t run. If your app relies on them (an audit log, for example), keep that in mind.

Inserting works the same way. Order::create([...]) is right for one row with events and timestamps; DB::table('orders')->insert($arrayOfRows) inserts hundreds of rows in one statement (and doesn’t fill created_at for you). Wrap multi-step changes in DB::transaction(function () { ... }), so they all succeed or all fail. See SQL Transactions and ACID Explained Simply.

The N+1 problem (the most common Laravel performance bug)

This innocent-looking code:

$orders = Order::where('status', 'paid')->get();

foreach ($orders as $order) {
    echo $order->customer->name;
}

runs one query for the orders, then one more query for every order to fetch its customer:

select * from `orders` where `status` = ?
select * from `customers` where `customers`.`id` = ? limit 1
select * from `customers` where `customers`.`id` = ? limit 1
select * from `customers` where `customers`.`id` = ? limit 1
...

With 6 paid orders, that’s 7 queries. With 2,000 orders on a report page, it’s 2,001 queries, and the page takes seconds even though each query is fast. That’s the N+1 problem: 1 query for the list, N queries for the related rows.

The fix is eager loading with with():

$orders = Order::with('customer')->where('status', 'paid')->get();

Now Laravel runs exactly two queries, whatever the number of orders:

select * from `orders` where `status` = ?
select * from `customers` where `customers`.`id` in (1, 2, 3, 4)

To catch N+1 problems during development, add this to AppServiceProvider::boot():

Model::preventLazyLoading(! app()->isProduction());

Any lazy-loaded relationship will then throw an exception on your machine instead of silently running hundreds of queries in production.

The Query Builder and raw SQL don’t have this problem, because they have no relationships to lazy-load. You write the JOIN yourself.

Seeing the SQL Laravel sends

Whichever style you use, check the SQL it actually generates:

// The SQL with ? placeholders
Order::where('status', 'paid')->toSql();

// The SQL with values filled in (Laravel 10.15+), for debugging only
Order::where('status', 'paid')->toRawSql();

// Every query a piece of code runs
DB::enableQueryLog();
// ... your code ...
dd(DB::getQueryLog());

Tools like Laravel Debugbar and Laravel Telescope show the same information for each page, including duplicate queries, which is the easiest way to spot N+1. Once you have the SQL, slow queries are fixed the usual way: check the execution plan and the indexes. Laravel migrations can add them, for example $table->index(['customer_id', 'status']);.

Raw SQL safely: bindings, never string building

The one rule that matters most with raw SQL: never put user input into the SQL text.

// DANGEROUS: SQL injection
$orders = DB::select("select * from orders where status = '$status'");
Order::whereRaw("status = '$status'")->get();

// SAFE: bindings
$orders = DB::select('select * from orders where status = ?', [$status]);
Order::whereRaw('status = ?', [$status])->get();

If $status comes from a form and someone types ' OR '1'='1, the first version returns every order in the table. With bindings, that text is just a strange status value that matches nothing.

Two traps that bindings can’t protect:

  • Column and table names. orderBy($request->sort) puts the column name into the SQL. Check it against a list of allowed columns first: in_array($sort, ['created_at', 'amount'], true).
  • DB::raw() with variables. DB::raw("SUM($column)") is pasted in as-is. Only use DB::raw with fixed text you wrote yourself.

Large result sets: memory, not just speed

Eloquent creates a full PHP object for every row. Loading 200,000 orders with Order::all() can use hundreds of megabytes and hit PHP’s memory limit. Options:

  • Select only the columns you need: Order::select('id', 'amount')->get().
  • Process in chunks: Order::where(...)->chunkById(1000, function ($orders) { ... }); loads 1,000 rows at a time.
  • Stream with lazy() or cursor(), which keep only a small number of models in memory at once.
  • Use the Query Builder for big exports. Plain objects are lighter than models.
  • Let the database do the work. If you’re loading rows only to add them up in PHP, a SUM or GROUP BY query is far faster.

Which one should you use?

SituationBest choice
Normal app screens: show, create, edit, delete one recordEloquent
Business rules on save (events, observers, casts, mutators)Eloquent
Lists with related dataEloquent with with()
Reports with joins, grouping and totalsQuery Builder
Bulk updates and insertsQuery Builder (or Eloquent mass update())
Window functions, CTEs, database-specific SQLRaw SQL (DB::select, selectRaw)
Calling stored proceduresRaw SQL
A query you want to test in a SQL tool firstRaw SQL

A practical rule: start with Eloquent, move to the Query Builder when the query becomes a report, and use raw SQL when the builder can’t express what you need. Performance alone is rarely the reason to switch. Most slow Laravel pages are N+1 problems or missing indexes, not Eloquent overhead.

Common mistakes

  • Lazy-loading relationships in a loop (N+1). Use with(), and turn on preventLazyLoading in development.
  • String-building raw SQL with user input. Always use ? bindings.
  • Model::all() on a big table. Filter, select fewer columns, or chunk.
  • Looping save() over thousands of rows when one mass update() would do.
  • Forgetting that mass updates and insert() skip model events and timestamps.
  • Counting in PHP: Order::all()->count() loads every row; Order::count() runs select count(*).

Practice exercises

Exercise 1. This code runs 101 queries for 100 customers. Fix it.

$customers = Customer::all();
foreach ($customers as $customer) {
    echo $customer->name . ': ' . $customer->orders->count();
}

Exercise 2. Rewrite this so it’s safe: DB::select("select * from customers where city = '" . $request->city . "'");

Exercise 3. Using the Query Builder, get the total paid amount per city.

Answers

Answer 1. Count in the database instead of loading every order:

$customers = Customer::withCount('orders')->get();
foreach ($customers as $customer) {
    echo $customer->name . ': ' . $customer->orders_count;
}

That’s one query with a count(*) subquery. Customer::with('orders') also fixes the N+1 (two queries), but loads every order just to count them.

Answer 2. Use a binding:

DB::select('select * from customers where city = ?', [$request->city]);
// or
DB::table('customers')->where('city', $request->city)->get();

Answer 3.

$totals = DB::table('orders')
    ->join('customers', 'customers.id', '=', 'orders.customer_id')
    ->where('orders.status', 'paid')
    ->groupBy('customers.city')
    ->select('customers.city')
    ->selectRaw('SUM(orders.amount) AS total')
    ->get();

With the sample data: Manama 2,130.00 (Ali 1,150 + Omar 980), Riffa 1,450.00 and Muharraq 90.00.

Summary

  • Eloquent returns models with relationships, casts and events. Best for everyday app code.
  • The Query Builder returns plain data and expresses joins, grouping and bulk changes clearly. Best for reports and bulk work.
  • Raw SQL handles everything else: window functions, CTEs, stored procedures, database-specific features.
  • All three use bindings (?) to keep values out of the SQL text. Keep it that way.
  • Most Laravel database slowness is N+1 queries and missing indexes. Fix those before blaming Eloquent.

Related articles

Scroll to Top