📁 Volume II: Laravel Performance Tuning Kit

💧 Topic 5: Hydration Hell

Why Eloquent consumes 10x more memory than the Query Builder.

"Eloquent is beautiful. But beauty has a cost.
Every Model you create is a small object with timestamps, casts, events, and relations.
Sometimes, you just need the damn data."
THE REALITY

Eloquent is expensive. Every row from the database is "hydrated" into a full Model object with:

The Query Builder returns simple stdClass objects or arrays. No overhead. Pure data.

🔍 What Is Hydration?

│ │ THE HYDRATION PROCESS │ ═══════════════════════════════════════════════════════════════════ │ │ Database Row: │ ┌─────────────────────────────────────────────────────────────────┐ │ │ | id | name | email | created_at | ... │ │ │ | 1 | John Doe | john@example.com | 2024-01-01 | ... │ │ └─────────────────────────────────────────────────────────────────┘ │ │ │ ▼ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ ELOQUENT HYDRATION │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ │ new User() │ │ │ │ │ → $attributes = [...] (all columns) │ │ │ │ │ → $original = [...] (for dirty checking) │ │ │ │ │ → $casts = [...] (date, json, etc.) │ │ │ │ │ → $dates = [...] (mutators) │ │ │ │ │ → $dispatcher = ... (events) │ │ │ │ │ → $relations = [] (lazy loading ready) │ │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ │ │ │ Memory per row: ~5-10KB │ │ └─────────────────────────────────────────────────────────────────┘ │ │ ═══════════════════════════════════════════════════════════════════ │ │ Database Row: │ ┌─────────────────────────────────────────────────────────────────┐ │ │ | id | name | email | created_at | ... │ │ │ | 1 | John Doe | john@example.com | 2024-01-01 | ... │ │ └─────────────────────────────────────────────────────────────────┘ │ │ │ ▼ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ QUERY BUILDER (stdClass) │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ │ stdClass { │ │ │ │ │ id → 1 │ │ │ │ │ name → "John Doe" │ │ │ │ │ email → "john@example.com" │ │ │ │ │ created_at → "2024-01-01" │ │ │ │ │ } │ │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ │ │ │ Memory per row: ~0.5-1KB │ │ └─────────────────────────────────────────────────────────────────┘ │

📊 Memory Comparison: Eloquent vs Query Builder

100,000 users fetched from database:

👎 ELOQUENT

$users = User::all();
// Returns 100,000 Model objects
~500 MB RAM

Memory: 500-800 MB
Time: 3-5 seconds
Result: 💀 SERVER CRASH (Out of Memory)

👍 QUERY BUILDER

$users = DB::table('users')->get();
// Returns 100,000 stdClass objects
~50 MB RAM

Memory: 50-80 MB
Time: 0.5-1 second
Result: ✅ Server survives

Detailed breakdown per 10,000 rows:

TypeMemory per RowTotal for 10k RowsOverhead
Eloquent Model (full) ~5-10 KB 50-100 MB 10x
stdClass (Query Builder) ~0.5-1 KB 5-10 MB 1x (baseline)
Array (DB::select) ~0.3-0.5 KB 3-5 MB 0.5x (smallest)
THE BOTTOM LINE

Eloquent uses 5-10x more memory than the Query Builder. For read-only operations, reports, exports, or APIs, using Eloquent can crash your server unnecessarily.

📝 Code Comparison: Same Query, Different Results

👎 ELOQUENT (Expensive)

// User.php
class User extends Model {
    protected $casts = [
        'email_verified_at' => 'datetime',
        'preferences' => 'array',
        'is_active' => 'boolean',
    ];
    
    public function posts() {
        return $this->hasMany(Post::class);
    }
}

// Controller
$users = User::where('is_active', true)
    ->select('id', 'name', 'email')
    ->get();

// Each user is a FULL Model object with:
// - Casts (datetime, array, boolean)
// - Event dispatcher
// - Original values array
// - Relationship loader
// - Dirty checking

👍 QUERY BUILDER (Cheap)

// No Model class needed

// Controller
$users = DB::table('users')
    ->where('is_active', true)
    ->select('id', 'name', 'email')
    ->get();

// Each user is a simple stdClass object:
// stdClass {
//   id → 1
//   name → "John"
//   email → "john@example.com"
// }
//
// That's it. No casts. No events. No relations.
// Just pure data.
BUT WAIT — WHAT ABOUT ARRAYS?
// Even lighter than stdClass
$users = DB::table('users')
    ->where('is_active', true)
    ->select('id', 'name', 'email')
    ->get()
    ->toArray();  // Convert to plain array

// Or directly:
$users = DB::select('SELECT id, name, email FROM users WHERE is_active = ?', [true]);
// Returns array of arrays: [['id'=>1, 'name'=>'John', ...], ...]

Arrays are the absolute cheapest data structure in PHP. Use them for exports and reports.

✅ When to Use Eloquent (And When to Avoid)

✅ USE ELOQUENT WHEN:

  • You need to CREATE/UPDATE/DELETE (mutations)
  • You need relationships (and you've eager loaded them)
  • You need accessors/mutators (getFooAttribute, setFooAttribute)
  • You need model events (created, updated, deleting)
  • You need casts (dates, JSON, enums)
  • Working with small datasets (< 1000 rows)
  • Building complex queries with relationships

❌ USE QUERY BUILDER / PDO WHEN:

  • READ-ONLY operations (reports, exports, dashboards)
  • LARGE datasets (> 10,000 rows)
  • API responses that don't need models
  • Admin lists showing thousands of records
  • Data migrations or seeds with large volumes
  • Complex aggregations (SUM, COUNT, GROUP BY)
  • When you DON'T need model features
🔴 THE GOLDEN RULE OF HYDRATION:
If you're only reading data and don't need model features, use the Query Builder or raw PDO.
Your server's memory will thank you.

🌍 Real-World Scenarios

Scenario 1: User Export (50,000 users to CSV)

👎 BAD (Eloquent)

$users = User::all();  // 50,000 models
foreach ($users as $user) {
    fputcsv($file, [$user->id, $user->name, $user->email]);
}
// Memory: ~400 MB
// Time: ~10 seconds
// Risk: OOM on small servers

👍 GOOD (Query Builder + chunk)

DB::table('users')->chunk(1000, function ($users) use ($file) {
    foreach ($users as $user) {
        fputcsv($file, [$user->id, $user->name, $user->email]);
    }
});
// Memory: ~10 MB
// Time: ~2 seconds
// Safe on any server

Scenario 2: API Endpoint Returning 10,000 Products

👎 BAD (Eloquent)

// ProductController.php
return Product::all()->toJson();
// Response size: ~5 MB JSON
// Memory usage: ~100 MB
// Response time: ~500 ms

👍 GOOD (Query Builder)

// ProductController.php
return response()->json(
    DB::table('products')->get()
);
// Response size: ~5 MB JSON
// Memory usage: ~15 MB
// Response time: ~200 ms

Scenario 3: Dashboard Widget (Latest 50 Orders)

✅ ELOQUENT IS FINE HERE

$orders = Order::with('user')
    ->latest()
    ->limit(50)
    ->get();
// 50 models is fine
// Memory: ~2 MB
// Use Eloquent for relationships

✅ QUERY BUILDER ALSO FINE

$orders = DB::table('orders')
    ->join('users', 'orders.user_id', '=', 'users.id')
    ->select('orders.*', 'users.name')
    ->latest('orders.created_at')
    ->limit(50)
    ->get();
// Either works for 50 rows

🥩 The Ultimate Cheap: Raw PDO

WHEN EVEN QUERY BUILDER IS TOO HEAVY

For reports with MILLIONS of rows, even the Query Builder's stdClass creation has overhead. Go straight to PDO.

🟣 RAW PDO (Fastest Possible)

$pdo = DB::connection()->getPdo();
$stmt = $pdo->prepare("
    SELECT id, name, email, created_at 
    FROM users 
    WHERE created_at > ?
");
$stmt->execute(['2024-01-01']);

// Fetch as associative array (cheapest)
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    // Process row
}

// No models. No stdClass. No overhead.
// Just PHP arrays directly from the database driver.

🟢 Memory Comparison (1M rows)

EloquentCRASH (OOM)
Query Builder~500 MB
Raw PDO (streaming)~2 MB (constant)
THE ULTIMATE PATTERN FOR MILLIONS OF ROWS
// ReportController.php - Export 5 million users
public function exportLargeReport()
{
    $pdo = DB::connection()->getPdo();
    $stmt = $pdo->prepare("SELECT id, name, email FROM users");
    $stmt->execute();
    
    $filename = storage_path('exports/users.csv');
    $file = fopen($filename, 'w');
    fputcsv($file, ['ID', 'Name', 'Email']);
    
    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        fputcsv($file, $row);
        // Memory stays constant. No accumulation.
    }
    
    fclose($file);
    return response()->download($filename);
}

This processes 5 million rows with ~5MB of RAM. Eloquent would need 40GB+ and crash instantly.

📝 Topic 5 Summary: Hydration Hell

MethodMemory per Row10k Rows MemoryWhen to Use
Eloquent Model ~5-10 KB 50-100 MB CRUD, relationships, events, casts, small datasets
Query Builder (stdClass) ~0.5-1 KB 5-10 MB Read-only, large datasets, APIs
DB::select() (array) ~0.3-0.5 KB 3-5 MB Reports, exports, raw SQL
Raw PDO (streaming) ~0 bytes (streamed) ~5 MB (constant) Millions of rows, exports, ETL
📌 THE RULE: Read-only = Query Builder. Write operations = Eloquent. Millions of rows = Raw PDO.

Don't pay for features you don't use. If you don't need model events, casts, or relationships, don't use Eloquent.
NEXT TOPIC PREVIEW

Topic 6: Queue Serialization Trap — Why sending full models to queues kills your Redis memory. The ID-only pattern that saves 99% of queue payload size.