From Eloquent to SQL and Back: Anatomy of a Laravel Query
Compare Laravel Eloquent, Query Builder, and raw SQL: follow query compilation, PDO execution, and model hydration, with benchmarks from 10 to 100,000 rows.
I wanted to compare three ways of writing a query in Laravel: Eloquent with Model::query(), Query Builder with DB::table(), and raw SQL with DB::select().
They offer different ways of working with our data, but they share much of the same execution path. Before reaching the database, the builders produce SQL. Once the results come back, Laravel turns them into the objects we use in our application.
To understand the cost of these abstractions, I followed that journey in Laravel 13, then compared the three approaches with result sets ranging from 10 to 100,000 users. The benchmark repository contains the command used for the comparison.
The source links below point to Laravel’s moving 13.x branch. When comparing this with your application, check the framework version installed in composer.lock.
Three ways to ask for the same data
Let’s start with a simple query: retrieve the first 10 users, ordered by their ID.
With Eloquent:
use App\Models\User;
$users = User::query()
->orderBy('id')
->limit(10)
->get();With Query Builder:
use Illuminate\Support\Facades\DB;
$users = DB::table('users')
->orderBy('id')
->limit(10)
->get();With raw SQL, using MySQL syntax:
use Illuminate\Support\Facades\DB;
$users = DB::select(
'select * from `users` order by `id` asc limit 10'
);Assuming the model uses the same connection and table, without additional scopes or default eager loading, these examples request the same records. Model configuration can add conditions or trigger additional queries. The benchmark explicitly disables global scopes and default eager loads, then checks that the SQL and bindings match before measuring anything. You can see those checks in the benchmark command.
The database receives SQL through all three paths. It has no knowledge of our fluent PHP calls or our User model.
The builders therefore introduce some work before execution. They need to represent the query in PHP and compile it into SQL. The question is how much that work contributes to the time and memory we measure.
From PHP to a SQL string
When we call orderBy() or limit(), Laravel records information about the query. It doesn’t retrieve any users yet.
The query’s clauses live in the underlying Query Builder. For our example, a simplified view of its state looks like this:
Query Builder
├── from = users
├── columns = [*] when get() executes
├── wheres = []
├── orders = [{ column: id, direction: asc }]
├── limit = 10
└── bindings = []Eloquent adds another object around that builder, holding the model and Eloquent-specific configuration. Its constructor receives the underlying Query Builder, and many query operations are forwarded to it. This relationship is visible in Eloquent’s Builder implementation.
At this stage, memory use depends on the query’s structure and its bindings. Changing the limit from 10 to 100,000 doesn’t create 100,000 objects in PHP. Adding thousands of values to a whereIn() clause, however, would make the query representation larger.
When execution begins, the builder’s grammar compiles those pieces into SQL. Laravel’s base query grammar contains the common compilation logic, while database-specific grammars provide the relevant variations. For MySQL, that includes wrapping identifiers in backticks through MySqlGrammar.
For this query, the construction is straightforward:
| Builder state | Compiled SQL fragment |
|---|---|
columns = ['*'] | select * |
from = 'users' | from `users` |
orders = [...] | order by `id` asc |
limit = 10 | limit 10 |
The grammar combines the fragments:
select * from `users` order by `id` asc limit 10If we added where('id', '>', 100), the condition would compile to where `id` > ?, with 100 stored separately as a binding. SQL compilation doesn’t mean inserting every value into the query string.
The three paths now look like this:
Raw SQL → SQL already supplied
Query Builder → query state → grammar → SQL
Eloquent → Eloquent Builder → Query Builder → grammar → SQLRaw SQL skips Laravel’s builder compilation. Laravel still prepares and executes the statement through PDO; whether preparation happens on the server or is emulated depends on the driver and connection settings.
The connection layer
All three paths converge on Laravel’s connection layer. Query Builder’s runSelect() passes the compiled SQL and bindings to Connection::select(). Eloquent reaches that same method through its underlying builder. Raw SQL starts closer to it.
The connection provides access to PDO, PHP’s database interface. It also owns the query grammar used by its builders, so the grammar is already available before execution reaches select(). The relevant execution code is in Laravel’s Connection class.
Inside select(), the central steps are:
$statement = $this->prepared(
$this->getPdoForSelect($useReadPdo)->prepare($query)
);
$this->bindValues($statement, $this->prepareBindings($bindings));
$statement->execute();
return $statement->fetchAll(...$fetchUsing);Laravel prepares the statement, binds the values, executes it, and fetches the results. These operations also happen when we use DB::select(). Writing raw SQL doesn’t bypass Laravel’s connection handling.
For equivalent SQL, bindings, and connection settings, the database has the same work to do. The builder has added PHP work before that point, but it hasn’t introduced a different kind of database query.
There is still another part of the journey to account for: what happens to the returned rows.
Back to PHP
With the default fetch mode, DB::select() returns an array of stdClass objects. Query Builder wraps those row objects in an Illuminate\Support\Collection. Eloquent returns an Eloquent collection containing model instances.
For a nonempty result, we can inspect the difference without dumping every record:
dump(get_debug_type($users));
dump(get_debug_type($users[0]));The expected types are:
DB::select()
array
stdClass
DB::table()->get()
Illuminate\Support\Collection
stdClass
User::query()->get()
Illuminate\Database\Eloquent\Collection
App\Models\UserQuery Builder’s collection wrapper adds little work around the fetched rows. Eloquent has to construct a model for each one. The respective return paths are visible in Query Builder’s get() and Eloquent’s getModels() and hydrate().
Hydration
Hydration is the process of turning a database row into a model instance.
Eloquent loops over the fetched rows and calls newFromBuilder() for each one. That method creates a model marked as already existing, assigns its raw attributes, synchronizes its original attribute state, sets its connection, and fires the retrieved model event. The implementation is in Model.php.
A User therefore carries more state than a plain row object. It has the machinery needed to track changes and participate in the model lifecycle. This doesn’t mean hydration immediately loads every relationship or evaluates every accessor; the initial operation assigns raw attributes.
It also doesn’t mean every model contains a separate copy of its methods, or that each attribute snapshot immediately duplicates every underlying value. PHP’s reference counting and copy-on-write behavior make that accounting more complicated than counting properties. The PHP manual’s reference-counting explanation gives some background on value sharing.
What does grow with the result set is the number of model instances. Retrieving 10 users creates 10 models. Retrieving 100,000 users creates 100,000 models, each passing through that initialization process.
That gives us two different sources of overhead to examine: building the query, which depends on its structure, and creating the result objects, which depends on how many rows come back.
Running the benchmark
The repository contains an Artisan command called benchmark:database.
After installing the project dependencies, configuring the database, and running the migrations, seed an empty benchmark database:
php artisan db:seed --class=UserSeederThe current UserSeeder.php inserts 100,000 users in batches of 5,000. To create fewer users, change $total in that file. Its inner loop always creates a full batch, so also adjust $chunkSize when needed: for example, $total = 1_000 and $chunkSize = 1_000 will create 1,000 users.
The seeder uses predictable, unique email addresses. Running it again against the same populated table can produce duplicate-email errors.
Then run:
php artisan benchmark:database \
--rows=10000 \
--iterations=30 \
--warmup=5 \
--show-sql--rows controls the maximum number of records fetched by each query. --iterations sets the number of measured executions per strategy, while --warmup runs each strategy before collecting samples. --show-sql prints the SQL and any bindings.
Repeat with --rows=10, 100, 1000, 10000, and 100000 to compare different result sizes. The command reports the actual number returned and warns if the database contains fewer records than requested.
There are a few details in the measurement implementation that affect how we read the results.
The raw SQL string is compiled once, outside the timed section. Query Builder and Eloquent rebuild their queries on every measured execution. All strategies use the same model connection, query logging is disabled, and their execution order rotates between iterations.
Timing covers the strategy call through result creation. Memory is measured while the result is still alive, with the peak reset before each sample. Result destruction happens outside the timed section.
This measures fetching and holding the results in an already running application. It doesn’t include application startup, rendering a response, or serializing all the models.
Results
These are the measurements from my runs, grouped into one table.
Median and p95 describe execution time; p95 is the sample’s 95th percentile. With 30 iterations, this command reports the second-slowest sample as p95, so it gives only a rough view of the slower executions. Memory Δ is the median increase in PHP-reported memory while the result remains alive. Peak Δ is the median increase in peak memory during each execution. Neither memory column represents the entire process’s memory consumption.
| Rows | Strategy | Median | p95 | Memory Δ | Peak Δ |
|---|---|---|---|---|---|
| 10 | Raw SQL | 0.037 ms | 0.042 ms | 8.22 KiB | 10.49 KiB |
| 10 | Query Builder | 0.054 ms | 0.061 ms | 8.39 KiB | 11.74 KiB |
| 10 | Eloquent | 0.187 ms | 0.200 ms | 22.14 KiB | 29.40 KiB |
| 100 | Raw SQL | 0.098 ms | 0.119 ms | 75.09 KiB | 77.37 KiB |
| 100 | Query Builder | 0.117 ms | 0.149 ms | 75.27 KiB | 78.62 KiB |
| 100 | Eloquent | 0.829 ms | 0.857 ms | 212.77 KiB | 225.73 KiB |
| 1,000 | Raw SQL | 0.675 ms | 0.699 ms | 746.50 KiB | 748.77 KiB |
| 1,000 | Query Builder | 0.691 ms | 0.707 ms | 746.67 KiB | 750.02 KiB |
| 1,000 | Eloquent | 7.225 ms | 7.274 ms | 2.07 MiB | 2.14 MiB |
| 10,000 | Raw SQL | 6.867 ms | 8.458 ms | 7.35 MiB | 7.35 MiB |
| 10,000 | Query Builder | 6.803 ms | 7.375 ms | 7.35 MiB | 7.35 MiB |
| 10,000 | Eloquent | 73.451 ms | 75.817 ms | 20.78 MiB | 21.42 MiB |
| 100,000 | Raw SQL | 93.046 ms | 94.269 ms | 72.96 MiB | 72.96 MiB |
| 100,000 | Query Builder | 93.053 ms | 94.270 ms | 72.96 MiB | 72.96 MiB |
| 100,000 | Eloquent | 802.986 ms | 882.579 ms | 207.23 MiB | 213.06 MiB |
The environment table isn’t included with these recorded results, so the absolute timings should be read as observations from these runs, without assuming a particular database driver, machine, or PHP configuration. MySQL above is the compilation example. When repeating the benchmark, keep the environment output alongside your results, and also record the database version, hardware, and whether OPcache, JIT, or Xdebug is enabled.
Query Builder and raw SQL stay close
At 10, 100, and 1,000 rows, Query Builder adds approximately 0.016–0.019 milliseconds to the median time.
At 10,000 rows it appears slightly faster than raw SQL, by 0.064 milliseconds. At 100,000 rows it is slower by 0.007 milliseconds. Those small differences don’t establish a performance advantage in either direction; they are consistent with variation between runs.
The retained-memory difference is similarly small: 176 bytes in the smaller runs and 192 bytes in the larger ones. That isn’t the total amount of memory allocated while building the query. Temporary builder state can already be gone when the command measures the returned result.
For this simple query, constructing and compiling the builder adds little measurable cost. The difference remains small as the result grows.
Eloquent’s additional cost grows with the rows
The Eloquent difference follows another pattern:
| Rows | Extra median time over Query Builder | Extra retained memory |
|---|---|---|
| 10 | +0.133 ms | 13.75 KiB |
| 100 | +0.713 ms | 137.50 KiB |
| 1,000 | +6.534 ms | 1.34 MiB |
| 10,000 | +66.647 ms | 13.43 MiB |
| 100,000 | +709.933 ms | 134.28 MiB |
These differences come from the command’s unrounded summaries, so subtracting the displayed medians can occasionally differ by 0.001 milliseconds.
At 10 rows, the absolute cost is small. At 100,000 rows, Eloquent adds about 710 milliseconds and 134 MiB of retained memory over Query Builder. Its total median time is roughly 8.6 times as high, and its retained-memory increase is about 2.8 times as large.
The additional retained memory works out to approximately 1.375 KiB per row throughout these runs. That consistency fits the model creation path we followed earlier. It is a measurement for this model and dataset, not a fixed size for every Eloquent model.
The benchmark doesn’t isolate hydration from all other Eloquent work. The difference includes Eloquent’s query setup and result handling as well as model creation. Still, the way the gap grows with the result count is consistent with hydration being the main additional cost in this example.
What I take from this comparison
For this query, I don’t see a performance reason to replace Query Builder with raw SQL. The measured difference is very small, even when retrieving 100,000 records.
Eloquent involves a more substantial decision because it returns models with state and behavior. With a small result set, the absolute overhead in these runs is a fraction of a millisecond. With a large result set, creating and retaining all those models becomes a significant part of the operation.
For a read that only needs row data, Query Builder already avoids that model creation step. When replacing an Eloquent query with DB::table(), any conditions previously supplied by global scopes, such as soft-delete or tenant filters, need to be preserved explicitly. For code that uses model behavior, Eloquent’s additional work has a purpose.
I would also look at the number of records being loaded before changing the query API. Even raw SQL retained nearly 73 MiB for 100,000 users in this test. Avoiding hydration reduces that cost, but fetching fewer rows or processing them in batches addresses the size of the result itself.
Further reading
For another walk through Laravel’s internals, Inside the Laravel 13 Scheduler follows scheduled tasks from their PHP definitions to execution. The benchmark source is the place to start if you want to repeat this comparison with your own model and database.
Read Next
Inside the Laravel 13 Scheduler: From Cron Entry to Event Execution
Trace how the Laravel 13 scheduler builds, filters, runs, and reports scheduled tasks, including mutexes, background processes, and sub-minute execution.
Finding the Commit That Introduced a Laravel Bug with Pest and Git Bisect
Use a focused Pest test with Git bisect to find the exact commit that introduced a regression in a Laravel application.
Swagger in Laravel Project
How to use Swagger inside you Laravel project to document and develop an API.