A raccoon engineer follows three query paths into one database, with returned rows becoming model cubes under green and pink light.

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:

php
use App\Models\User;

$users = User::query()
    ->orderBy('id')
    ->limit(10)
    ->get();

With Query Builder:

php
use Illuminate\Support\Facades\DB;

$users = DB::table('users')
    ->orderBy('id')
    ->limit(10)
    ->get();

With raw SQL, using MySQL syntax:

php
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:

text
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 stateCompiled SQL fragment
columns = ['*']select *
from = 'users'from `users`
orders = [...]order by `id` asc
limit = 10limit 10

The grammar combines the fragments:

sql
select * from `users` order by `id` asc limit 10

If 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:

text
Raw SQL        → SQL already supplied
Query Builder  → query state → grammar → SQL
Eloquent       → Eloquent Builder → Query Builder → grammar → SQL

Raw 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:

php
$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:

php
dump(get_debug_type($users));
dump(get_debug_type($users[0]));

The expected types are:

text
DB::select()
    array
    stdClass

DB::table()->get()
    Illuminate\Support\Collection
    stdClass

User::query()->get()
    Illuminate\Database\Eloquent\Collection
    App\Models\User

Query 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:

bash
php artisan db:seed --class=UserSeeder

The 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:

bash
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.

RowsStrategyMedianp95Memory ΔPeak Δ
10Raw SQL0.037 ms0.042 ms8.22 KiB10.49 KiB
10Query Builder0.054 ms0.061 ms8.39 KiB11.74 KiB
10Eloquent0.187 ms0.200 ms22.14 KiB29.40 KiB
100Raw SQL0.098 ms0.119 ms75.09 KiB77.37 KiB
100Query Builder0.117 ms0.149 ms75.27 KiB78.62 KiB
100Eloquent0.829 ms0.857 ms212.77 KiB225.73 KiB
1,000Raw SQL0.675 ms0.699 ms746.50 KiB748.77 KiB
1,000Query Builder0.691 ms0.707 ms746.67 KiB750.02 KiB
1,000Eloquent7.225 ms7.274 ms2.07 MiB2.14 MiB
10,000Raw SQL6.867 ms8.458 ms7.35 MiB7.35 MiB
10,000Query Builder6.803 ms7.375 ms7.35 MiB7.35 MiB
10,000Eloquent73.451 ms75.817 ms20.78 MiB21.42 MiB
100,000Raw SQL93.046 ms94.269 ms72.96 MiB72.96 MiB
100,000Query Builder93.053 ms94.270 ms72.96 MiB72.96 MiB
100,000Eloquent802.986 ms882.579 ms207.23 MiB213.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:

RowsExtra median time over Query BuilderExtra retained memory
10+0.133 ms13.75 KiB
100+0.713 ms137.50 KiB
1,000+6.534 ms1.34 MiB
10,000+66.647 ms13.43 MiB
100,000+709.933 ms134.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