Tech Verse Logo
Enable dark mode
Query Builder vs Eloquent: Real Cost in Laravel

Query Builder vs Eloquent: Real Cost in Laravel

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

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:

  1. 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.
  2. High-Throughput Read Endpoints (e.g., autocompletes, public list feeds): Use Eloquent with select() and toBase(). You retain scope modularity without paying the object construction fee.
  3. Data Exports, ETL Scripts, and Batch Processing: Drop to DB::table() combined with cursor() or chunkById(). Skip model hydration completely.
  4. Bulk Inserts and Updates: Never loop over models calling $model->save() in a loop. Use DB::table('orders')->insert() or Eloquent's Order::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.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Safe, resumable data backfills for Laravel

    Safe, resumable data backfills for Laravel

    Query Builder vs Eloquent: Real Cost in Laravel

    Query Builder vs Eloquent: Real Cost in Laravel

    Laravel Signed URLs and One-Time Download Links

    Laravel Signed URLs and One-Time Download Links

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Building Honest Health Check Endpoints in Laravel

    Building Honest Health Check Endpoints in Laravel

    Writing Production-Ready Laravel Artisan Commands

    Writing Production-Ready Laravel Artisan Commands

    Testing Mail in Laravel: Mailables and Assertions

    Testing Mail in Laravel: Mailables and Assertions

    Solving Low-Priority Queue Starvation in Laravel

    Solving Low-Priority Queue Starvation in Laravel

    Realistic Laravel Seeders with States and Relations

    Realistic Laravel Seeders with States and Relations