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.

Table of Contents
The sample tables
Two tables: customers and orders. A customer has many orders.
| id | name | city |
|---|---|---|
| 1 | Ali | Manama |
| 2 | Sara | Riffa |
| 3 | Omar | Manama |
| 4 | Lina | Muharraq |
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
Ordermodels. Each one knows its relationships ($order->customer), applies casts (amountas a decimal string, dates as Carbon objects), and can be changed and saved with$order->save(). - The Query Builder returns a Collection of plain
stdClassobjects. Just data, no relationships, no casts. DB::selectreturns a plain PHP array ofstdClassobjects.
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:
| name | total | orders_count |
|---|---|---|
| Sara | 1450.00 | 2 |
| Ali | 1150.00 | 2 |
| Omar | 980.00 | 1 |
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 useDB::rawwith 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()orcursor(), 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
SUMorGROUP BYquery is far faster.
Which one should you use?
| Situation | Best choice |
|---|---|
| Normal app screens: show, create, edit, delete one record | Eloquent |
| Business rules on save (events, observers, casts, mutators) | Eloquent |
| Lists with related data | Eloquent with with() |
| Reports with joins, grouping and totals | Query Builder |
| Bulk updates and inserts | Query Builder (or Eloquent mass update()) |
| Window functions, CTEs, database-specific SQL | Raw SQL (DB::select, selectRaw) |
| Calling stored procedures | Raw SQL |
| A query you want to test in a SQL tool first | Raw 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 onpreventLazyLoadingin 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 massupdate()would do. - Forgetting that mass updates and
insert()skip model events and timestamps. - Counting in PHP:
Order::all()->count()loads every row;Order::count()runsselect 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
- SQL Injection Explained Simply: why bindings matter, with examples.
- SQL Indexes Explained Simply: the other half of making Laravel queries fast.
- SQL JOINs Explained Simply: the joins behind every relationship.
- SQL Stored Procedures Explained Simply: logic in the database that Laravel can call.