Eloquent isn't slow because MySQL is slow. It's slow because PHP has to instantiate hundreds or thousands of heavy objects. Every time you run a query through an Eloquent model in Laravel 12, the framework isn't just fetching database rows; it's instantiating a full model instance for every single record returned. It sets up internal array properties for original values, attributes, relations, custom casts, and mutators. On PHP 8.3, an empty Eloquent model takes roughly 1.2 kilobytes of RAM. Multiply that by 10,000 rows, and you've consumed over 12 megabytes before you've even processed a single field.
When you use the Query Builder via DB::table(), PHP receives raw stdClass objects. A standard stdClass instance created by PDO takes a fraction of that memory footprint. In a benchmark on a production API running PHP 8.3.12 and Laravel 12.x, pulling 10,000 user records via Eloquent consumed 178MB of memory and took 210ms. Running the exact same query using DB::table('users') consumed 14.5MB and took 32ms. That is a 12x reduction in RAM and a 6.5x speed increase.
Benchmarking Eloquent vs Query Builder in Laravel 12
To see where the time goes, let's look at a concrete benchmark script. We fetched 5,000 order records containing text fields, JSON columns, and timestamps. Here is the test script executed under PHP 8.3 with OPCache enabled:
<?php
use Illuminate\Support\Facades\DB;
use App\Models\Order;
// Test 1: Eloquent Model Hydration
$startMemory = memory_get_usage();
$startTime = microtime(true);
$ordersEloquent = Order::where('status', 'completed')->get();
$eloquentTime = (microtime(true) - $startTime) * 1000;
$eloquentMemory = (memory_get_usage() - $startMemory) / 1024 / 1024;
// Test 2: Query Builder stdClass Hydration
$startMemory = memory_get_usage();
$startTime = microtime(true);
$ordersBuilder = DB::table('orders')->where('status', 'completed')->get();
$builderTime = (microtime(true) - $startTime) * 1000;
$builderMemory = (memory_get_usage() - $startMemory) / 1024 / 1024;
logger()->info(sprintf(
"Eloquent: %.2fms | %.2fMB -- Builder: %.2fms | %.2fMB",
$eloquentTime, $eloquentMemory, $builderTime, $builderMemory
));
The results on our server were predictable but stark:
- Eloquent
Order::get(): 118.4ms, 48.2MB peak memory. - Query Builder
DB::table(): 18.1ms, 5.1MB peak memory.
Why such a huge difference? Eloquent's constructor triggers continuous checks. It parses attribute mutators, casts JSON columns into array definitions, prepares date attributes for Carbon conversion, and checks for booted traits like soft deletes or custom global scopes. If your goal is just to dump data into an export or serve a high-throughput JSON endpoint, those object features are dead weight.
The Architecture Trade-offs
If Query Builder is six times faster and uses a tenth of the memory, why not abandon Eloquent entirely? Because software performance isn't just about CPU execution time; it's about developer velocity, maintainability, and data integrity.
Eloquent provides several vital safeguards that you lose when you drop to raw SQL or Query Builder:
- Global Scopes: Multitenancy filters (like
where('tenant_id', $tenantId)) apply automatically on models. Query Builder ignores them completely. - Soft Deletes: Model queries automatically append
WHERE deleted_at IS NULL.DB::table()will return soft-deleted records unless you manually add the condition every single time. - Attribute Casting: Converting stored JSON into array types or encrypted database columns into plain text happens transparently in Eloquent.
- Model Events: Actions like
saving,updated, or dispatching observer jobs do not execute when modifying records via Query Builder.
Dropping to DB::table() everywhere creates fragmented business logic. I've seen teams drop Eloquent to fix slow response times, only to expose soft-deleted customer data in an API payload because someone forgot a single whereNull('deleted_at') clause. That's a massive security risk for a minor speed boost.
The Best Compromise: toBase() and Select Opt-ins
You don't have to choose between full Eloquent object overhead and losing your query building logic. Laravel 12 gives you excellent middle-ground techniques.
1. The toBase() Method
If you have built an intricate Eloquent query with local scopes, relationships, and dynamic filters, you don't need to rewrite it using DB::table(). Append toBase() before calling baseline collection methods like get(). This executes the query built by Eloquent but bypasses the hydration step, returning a collection of stdClass objects.
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\JsonResponse;
class OrderReportController extends Controller
{
public function index(): JsonResponse
{
// Maintains global scopes and model query logic, but skips model hydration
$orders = Order::query()
->whereActiveCustomer()
->recent()
->select(['id', 'order_number', 'total_amount', 'created_at'])
->toBase()
->get();
return response()->json($orders);
}
}
By using toBase(), the memory consumption drops from 48MB down to 5MB, while keeping your model's local scope methods intact. It is usually the best optimization step before ditching models altogether.
2. Column Pruning with select()
The default SELECT * pattern ruins database performance. When you call Order::all(), Eloquent must assign every database column to the model's internal attributes array. If your orders table has 30 columns including text logs or heavy JSON payloads, hydration costs soar. Explicitly listing columns using select(['id', 'amount', 'status']) reduces memory footprint by up to 60%, even when keeping full Eloquent hydration.
3. Streaming Records with cursor() and lazy()
If you're processing 50,000 records for a background CSV export, loading them all into memory will exhaust your PHP limit. Laravel offers cursor() and lazy() to stream results.
cursor() uses a PDO statement cursor, keeping only a single Eloquent model in memory at any time. Memory usage stays flat near 2MB, regardless of dataset size. When using lazy(), Laravel chunks the query behind the scenes (defaulting to 1,000 records per page) and yields Eloquent instances. It provides a balance between raw memory efficiency and access to model methods.
Practical Rules of Thumb
Here is the decision matrix I use in production applications:
- Standard Web Routes and APIs (1-50 records): Stick with Eloquent. The overhead is negligible (under 5ms), and you keep events, accessors, and relation loading.
- High-Throughput Read Endpoints (e.g., autocompletes, public list feeds): Use Eloquent with
select()andtoBase(). You retain scope modularity without paying the object construction fee. - Data Exports, ETL Scripts, and Batch Processing: Drop to
DB::table()combined withcursor()orchunkById(). Skip model hydration completely. - Bulk Inserts and Updates: Never loop over models calling
$model->save()in a loop. UseDB::table('orders')->insert()or Eloquent'sOrder::insert(), which delegates directly to a single SQL bulk insert query.
Production Gotchas to Avoid
When swapping between Eloquent and Query Builder, keep these common traps in mind:
Lost Mutators and Accessors: If you rely on $order->formatted_total defined as an Attribute accessor on your model, it won't exist on a stdClass returned by Query Builder or toBase(). You'll get an "Undefined property" error or null in your API response.
Raw Carbon Dates: Eloquent automatically casts date fields to Illuminate\Support\Carbon instances. Query Builder returns raw strings directly from MySQL (like "2025-02-18 14:30:00"). Calling $order->created_at->format('Y-m-d') on a Query Builder result will throw a fatal error.
Bypassed Security Policies: If tenant isolation relies on Eloquent Global Scopes, switching to DB::table('orders') removes that boundary. Always test your builder queries against multitenancy leak checks.
Mass Assignment and Raw Updates: When using Query Builder for batch updates, timestamp fields like updated_at are not automatically updated. You must manually pass 'updated_at' => now() in your array payload. Forgetting this leaves outdated timestamps in your database records.













