Intermediate 12 min · March 06, 2026

Laravel Eloquent ORM — 2,847 Queries in 12s from N+1

2,847 queries from N+1 lazy loading caused 12-second load on a Laravel admin dashboard.

N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Drawn from code that ran under real load.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Model = PHP class representing a database table (App\Models\User maps to users table)
  • Relationships = methods that define how models connect (hasMany, belongsTo, belongsToMany)
  • Query Builder = fluent chain of conditions that compiles to SQL (User::where('active', true)->get())
  • Eager Loading = loading relationships in a single query to prevent N+1 (User::with('posts')->get())
  • Eloquent Model: extends Illuminate\Database\Eloquent\Model
  • Relationship methods: hasOne, hasMany, belongsTo, belongsToMany, hasManyThrough, morphTo, morphMany
  • Query Scopes: reusable query fragments (scopeActive, scopeRecent)
  • Accessors/Mutators: transform data on read/write ($this->attributes['price'] / 100)
✦ Definition~90s read
What is Laravel Eloquent ORM?

Eloquent is an Active Record ORM. Every model instance maps directly to a database row. You call $user->save() and it writes to the users table.

Imagine your database is a giant filing cabinet with labelled drawers.

That coupling is the source of both its power and its danger. Done right, Eloquent eliminates boilerplate for 90% of your queries. Done wrong — and most codebases do it wrong — it hides N+1 disasters behind clean syntax and makes you forget the database exists.

Here’s the thing: Eloquent is not a query builder. It’s a data access layer with assumptions. Its convention-over-configuration approach assumes your foreign keys are model_id. It assumes timestamps exist. It assumes you want lazy loading by default.

When you violate those assumptions without understanding them, you get production fires. The smartest Laravel devs don't fight Eloquent. They learn its defaults, override them explicitly in the model, and profile every new relationship with DB::listen() before shipping.

Plain-English First

Imagine your database is a giant filing cabinet with labelled drawers. Writing raw SQL is like crawling under the desk, pulling out the right drawer yourself, and reading every file by hand. Eloquent is the smart office assistant who knows where everything lives — you just say 'get me all the invoices for this customer' and it handles the rest. It even knows that customers are connected to invoices, and invoices are connected to products, so you never have to describe those relationships twice.

Every serious web application lives and dies by its data layer. The way you read, write and relate data determines whether your app is a pleasure to maintain or a nightmare that breaks every time a new developer touches it. Laravel's Eloquent ORM is the reason PHP developers choose Laravel over raw frameworks — it turns database interactions from a chore into something that reads almost like plain English.

Before Eloquent (and ORMs in general), developers wrote raw SQL strings scattered across their codebase. One typo in a JOIN clause, one forgotten index, one inconsistency between how two files referenced the same table — and suddenly you have a bug that takes hours to find. Eloquent solves this by mapping each database table to a PHP class called a Model. Every row becomes an object. Relationships between tables become methods you can call. Filtering becomes a fluent chain of readable conditions instead of a wall of SQL.

By the end of this article you'll understand not just how to use Eloquent, but WHY it's designed the way it is. You'll be able to define models, write efficient relationship queries, avoid the dreaded N+1 problem, and use local query scopes to keep your codebase clean. These are the patterns senior Laravel developers use in production every single day.

Why Eloquent ORM Is a Double-Edged Sword

Laravel Eloquent is an active-record ORM that maps database tables to PHP objects, letting you query and manipulate data using method chaining and relationships. The core mechanic: each model instance wraps a row, and lazy loading triggers a new query per accessed relation. In practice, this means a simple foreach ($users as $user) { echo $user->profile->bio; } executes N+1 queries — one for the users, then one per user for the profile. Without eager loading (with('profile')), a page with 500 users fires 501 queries. Eloquent's magic property access and relationship resolvers are convenient but hide the database cost. Use Eloquent for CRUD-heavy apps where developer speed matters more than raw throughput. Avoid it for high-throughput APIs or batch processing — raw queries or query builders give you explicit control over query count and execution plans. The tradeoff: readability vs. performance, and the N+1 trap is the most common production killer.

N+1 Is Silent
Eloquent never warns you about lazy-loaded relationships. A single missing with() call can turn a 10ms page into a 2-second one without any error.
Production Insight
Team ships a dashboard listing 2000 orders with related customer and line items. Lazy loading fires 4001 queries in 12 seconds — page times out. Symptom: slow page load, high MySQL processlist, no error logs. Rule: always eager-load all relationships used in a view; use Model::preventLazyLoading() in non-production environments to catch violations early.
Key Takeaway
Every lazy-loaded relationship is a hidden query — always eager-load or use load() explicitly.
Use Model::preventLazyLoading() during development to catch N+1 before it hits production.
For bulk reads or high-throughput endpoints, drop to the query builder or raw SQL — Eloquent's convenience costs performance.
Eloquent ORM N+1 Query Problem Flow THECODEFORGE.IO Eloquent ORM N+1 Query Problem Flow From model definition to eager loading to performance fix Define Eloquent Models Extend Model class, set table & relationships Declare Relationships hasOne, hasMany, belongsTo, belongsToMany Lazy Load Related Data Access relation as property triggers query N+1 Query Explosion 1 parent + N children = 2,847 queries in 12s Eager Loading with with() Load all relations in 2 queries total Apply Query Scopes Reusable constraints for filtering & ordering ⚠ Lazy loading in loops causes N+1 queries Always eager load with with() or load() to avoid performance trap THECODEFORGE.IO
thecodeforge.io
Eloquent ORM N+1 Query Problem Flow
Laravel Eloquent Orm

Models: The Foundation of Eloquent

An Eloquent Model is a PHP class that represents a database table. Each instance of the class represents a row. The class name is the singular, PascalCase version of the table name: User maps to users, OrderItem maps to order_items.

Convention over configuration: Eloquent infers the table name from the class name. If your class is App\Models\User, Eloquent assumes the table is users. Override this with protected $table = 'custom_table_name'. The primary key is assumed to be id. Override with protected $primaryKey = 'user_id'. Timestamps (created_at, updated_at) are managed automatically. Disable with public $timestamps = false.

Mass assignment protection: Eloquent's create() and update() methods accept an array of attributes. Without protection, any field can be set — including is_admin, role, or balance. Eloquent protects against this with $fillable (whitelist) and $guarded (blacklist). Always use $fillable with only user-editable fields. Never use $guarded = [] (which allows everything).

Model lifecycle events: Eloquent fires events at key points: creating, created, updating, updated, deleting, deleted, restoring, restored. Use these for side effects: sending notifications on creation, logging changes on update, cleaning up related data on delete. Register observers or closures in the model's boot() method.

Attribute casting: Eloquent can automatically cast database values to PHP types. Define protected $casts = ['is_admin' => 'boolean', 'metadata' => 'array', 'price_in_cents' => 'integer']. This ensures consistent types regardless of the database driver's native type handling.

app/Models/User.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use HasFactory, Notifiable;

    /**
     * The table associated with the model.
     * Only needed if it deviates from the convention (users).
     */
    // protected $table = 'users';

    /**
     * The primary key type.
     * Override if using UUIDs or non-integer keys.
     */
    // protected $keyType = 'string';

    /**
     * Indicates if the IDs are auto-incrementing.
     * Set to false when using UUIDs.
     */
    // public $incrementing = false;

    /**
     * Mass-assignable attributes.
     * ONLY include fields users should be able to set.
     * NEVER include: is_admin, role, balance, password_hash.
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * Hidden attributes — excluded from JSON serialization.
     * Prevents password hashes from leaking in API responses.
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * Attribute casting — database values cast to PHP types.
     * Ensures consistent types regardless of database driver.
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
        'is_admin'          => 'boolean',
        'preferences'       => 'array',
        'last_login_at'     => 'datetime',
    ];

    // ── Relationships ────────────────────────────────────────────────────────

    public function orders(): HasMany
    {
        return $this->hasMany(Order::class);
    }

    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class);
    }

    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)->withTimestamps();
    }

    // ── Local Scopes ─────────────────────────────────────────────────────────

    /**
     * Scope: only active users.
     * Usage: User::active()->get()
     */
    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }

    /**
     * Scope: users who logged in within the last N days.
     * Usage: User::recentlyActive(30)->get()
     */
    public function scopeRecentlyActive($query, int $days = 30)
    {
        return $query->where('last_login_at', '>=', now()->subDays($days));
    }

    // ── Accessors ────────────────────────────────────────────────────────────

    /**
     * Computed attribute: full name.
     * Accessible as $user->full_name (no parentheses).
     */
    public function getFullNameAttribute(): string
    {
        return "{$this->first_name} {$this->last_name}";
    }

    // ── Model Events ─────────────────────────────────────────────────────────

    protected static function boot()
    {
        parent::boot();

        static::created(function ($user) {
            // Create a profile for every new user
            $user->profile()->create([
                'bio' => '',
                'avatar_url' => null,
            ]);
        });

        static::deleting(function ($user) {
            // Clean up related data before the user is deleted
            $user->orders()->update(['user_id' => null]);
        });
    }
}
Output
// No direct output — this is a model class.
// Usage:
// $user = User::create(['name' => 'Sarah', 'email' => 'sarah@example.com', 'password' => bcrypt('secret')]);
// $user->full_name // 'Sarah Connor' (accessor)
// $user->orders // Collection of Order models (relationship)
// $user->is_admin // true/false (cast to boolean)
// $user->preferences // ['theme' => 'dark'] (cast to array)
Models as Blueprints
  • Without $fillable, Eloquent blocks all mass assignment — create() and update() throw a MassAssignmentException.
  • With $fillable, only listed fields can be set via create() or update().
  • Without $fillable or $guarded, an attacker can set is_admin=true via a form field if you use request()->all().
  • Always use $fillable with a whitelist. Never use $guarded = [] which allows everything.
Production Insight
The $hidden property prevents sensitive fields from appearing in JSON serialization (toArray(), toJson(), API responses). Without $hidden, your API might expose password hashes, remember tokens, or internal flags. Always add password and remember_token to $hidden on the User model.
Key Takeaway
Models map tables to classes. $fillable protects against mass assignment. $hidden prevents sensitive fields from leaking in API responses. $casts ensure consistent types. Model events handle side effects. Always define $fillable explicitly — never use $guarded = [].
Model Configuration Decisions
IfTable name follows Laravel convention (users, orders)
UseDo not define $table. Eloquent infers it from the class name.
IfTable name deviates from convention (tbl_users, user_accounts)
UseDefine protected $table = 'tbl_users'.
IfPrimary key is not 'id' or is not auto-incrementing
UseDefine $primaryKey, $keyType, and $incrementing = false. Required for UUID primary keys.
IfModel does not need timestamps
UseSet public $timestamps = false. Required for pivot tables and lookup tables.

Relationships: hasOne, hasMany, belongsTo, and belongsToMany

Eloquent relationships are methods on a Model that return a relationship object. They define how tables connect and enable fluent query building across related tables.

One-to-One (hasOne / hasOne): A user has one profile. The profile table has a user_id foreign key. Define hasOne on the user, hasMany on the profile (actually belongsTo — see below). The foreign key is on the related table.

One-to-Many (hasMany / belongsTo): A user has many orders. The orders table has a user_id foreign key. Define hasMany on the user, belongsTo on the order. belongsTo is the inverse of hasMany — the foreign key is on the model that defines belongsTo.

Many-to-Many (belongsToMany): A user has many roles, and a role has many users. This requires a pivot table (role_user) with user_id and role_id. Define belongsToMany on both models. The pivot table name is inferred alphabetically (role_user).

Polymorphic relationships (morphTo / morphMany): A comment can belong to a post or a video. The comments table has commentable_id and commentable_type columns. The type column stores the model class name. morphTo on the comment, morphMany on the post and video.

Foreign key conventions: Eloquent assumes the foreign key is {model}_id (user_id for User model). Override by passing the key name: $this->hasMany(Order::class, 'customer_id'). The local key defaults to id. Override with a fourth parameter.

Pivot table access: For belongsToMany relationships, access pivot data with ->pivot: $user->roles->first()->pivot->created_at. Add ->withPivot('column1', 'column2') to include additional pivot columns. Add ->withTimestamps() to include created_at and updated_at on the pivot.

app/Models/Order.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Order extends Model
{
    protected $fillable = [
        'user_id',
        'status',
        'total_in_cents',
        'notes',
    ];

    protected $casts = [
        'total_in_cents' => 'integer',
        'shipped_at'     => 'datetime',
    ];

    // ── belongsTo: the foreign key (user_id) is on THIS table ────────────────
    // Each order belongs to one user.
    // The inverse of User::hasMany(Order::class)
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    // ── hasMany: the foreign key (order_id) is on the RELATED table ──────────
    // Each order has many items.
    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }

    // ── hasMany with custom foreign key ──────────────────────────────────────
    // If the FK is not order_id, specify it explicitly.
    public function shipments(): HasMany
    {
        return $this->hasMany(Shipment::class, 'parent_order_id');
    }

    // ── belongsToMany: requires a pivot table (order_coupons) ────────────────
    // An order can have many coupons, and a coupon can apply to many orders.
    public function coupons(): BelongsToMany
    {
        return $this->belongsToMany(Coupon::class)
            ->withPivot('discount_in_cents', 'applied_at')
            ->withTimestamps();
    }

    // ── Polymorphic: comments can belong to orders, products, etc. ───────────
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }

    // ── Querying through relationships ───────────────────────────────────────
    // These queries compile to SQL JOINs or subqueries — no PHP iteration.

    // Get all orders for a specific user:
    // $user->orders()->where('status', 'shipped')->get();

    // Get orders that have at least one item with a specific product:
    // Order::whereHas('items', function ($query) use ($productId) {
    //     $query->where('product_id', $productId);
    // })->get();

    // Get orders with no items (orphans):
    // Order::doesntHave('items')->get();

    // Count related records without loading them:
    // $user->orders()->count(); // Single COUNT query, no model hydration
}
Output
// Relationship queries:
// $order = Order::find(1);
// $order->user // User model (belongsTo — 1 query)
// $order->items // Collection of OrderItem models (hasMany — 1 query)
// $order->items()->where('quantity', '>', 1)->get() // Filtered hasMany
// $order->coupons // Collection with pivot data
// $order->coupons->first()->pivot->discount_in_cents // Access pivot columns
Relationships as Family Trees
  • The foreign key (user_id) is on the orders table, not the users table.
  • belongsTo uses the foreign key on the current model to look up the parent.
  • hasMany uses the foreign key on the related model to find children.
  • If you define belongsTo on the wrong model, the query looks for a column that does not exist.
Production Insight
Use whereHas() to filter parent records by related record conditions without loading the related records. Order::whereHas('items', fn($q) => $q->where('product_id', 5)) compiles to an EXISTS subquery — efficient and does not hydrate the related models. Use doesntHave() for the inverse (records with no related records).
Key Takeaway
hasOne/hasMany define the parent side. belongsTo defines the child side (has the foreign key). belongsToMany requires a pivot table. morphTo/morphMany enable a record to belong to multiple model types. Use whereHas() for filtered relationship queries — it compiles to efficient SQL subqueries.

Eager Loading and the N+1 Query Problem

The N+1 query problem is the most common and most damaging Eloquent performance issue. It occurs when you load a collection of models and then access a relationship on each model in a loop.

The problem: User::all() runs 1 query. foreach ($users as $user) { $user->orders } runs N additional queries (one per user). For 100 users, that is 101 queries. For 1,000 users, that is 1,001 queries.

The solution: Eager loading with with(). User::with('orders')->get() runs 2 queries: one for all users, one for all orders with WHERE user_id IN (1, 2, 3, ...). Eloquent then matches the orders to their parent users in memory. Total: 2 queries regardless of user count.

Nested eager loading: User::with('orders.items.product') loads three levels of relationships in 4 queries (users, orders, items, products). Without eager loading, this would be 1 + N + NM + NM*P queries.

Conditional eager loading: User::with(['orders' => function ($query) { $query->where('status', 'shipped'); }])->get() loads only shipped orders. The relationship is still eager loaded, but with a filter applied.

Lazy eager loading: If you already have a collection of models and realize you need a relationship, use ->load('orders') on the collection. This runs the missing query and attaches the results to the existing models.

Counting without loading: $user->orders()->count() runs a COUNT query without loading the order models. Use withCount('orders') to add a orders_count attribute to each user in a collection: User::withCount('orders')->get().

io/thecodeforge/eager-loading-examples.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
<?php

namespace Io\Thecodeforge\Eloquent;

use App\Models\User;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class EagerLoadingExamples
{
    /**
     * N+1 PROBLEM: 1 query for users + N queries for orders
     * For 100 users: 101 queries
     */
    public function nPlusOneProblem(): void
    {
        DB::enableQueryLog();

        $users = User::all(); // Query 1: SELECT * FROM users

        foreach ($users as $user) {
            // Query 2..101: SELECT * FROM orders WHERE user_id = ?
            // One query PER user — this is the N+1 problem
            echo $user->orders->count();
        }

        $queries = DB::getQueryLog();
        // count($queries) = 101 (for 100 users)
    }

    /**
     * EAGER LOADING SOLUTION: 2 queries total
     * User::with('orders') loads all users, then all orders with WHERE IN.
     */
    public function eagerLoadingSolution(): void
    {
        DB::enableQueryLog();

        // Query 1: SELECT * FROM users
        // Query 2: SELECT * FROM orders WHERE user_id IN (1, 2, 3, ...)
        $users = User::with('orders')->get();

        foreach ($users as $user) {
            // No additional query — orders are already loaded
            echo $user->orders->count();
        }

        $queries = DB::getQueryLog();
        // count($queries) = 2 (regardless of user count)
    }

    /**
     * NESTED EAGER LOADING: 4 queries for 3 levels of relationships
     */
    public function nestedEagerLoading(): void
    {
        DB::enableQueryLog();

        // Query 1: SELECT * FROM users
        // Query 2: SELECT * FROM orders WHERE user_id IN (...)
        // Query 3: SELECT * FROM order_items WHERE order_id IN (...)
        // Query 4: SELECT * FROM products WHERE id IN (...)
        $users = User::with('orders.items.product')->get();

        $queries = DB::getQueryLog();
        // count($queries) = 4 (regardless of user/order/item count)
    }

    /**
     * CONDITIONAL EAGER LOADING: filter related records during load
     */
    public function conditionalEagerLoading(): void
    {
        // Only load shipped orders — pending orders are excluded
        $users = User::with(['orders' => function ($query) {
            $query->where('status', 'shipped')
                  ->orderBy('shipped_at', 'desc')
                  ->limit(5); // Only the 5 most recent shipped orders
        }])->get();
    }

    /**
     * LAZY EAGER LOADING: load relationships after the initial query
     * Useful when you realize you need a relationship mid-request.
     */
    public function lazyEagerLoading(): void
    {
        $users = User::all(); // Already loaded without orders

        // Later in the code, realize you need orders
        $users->load('orders'); // Runs the missing query and attaches to existing models

        // Can also load with conditions
        $users->load(['orders' => function ($query) {
            $query->where('status', 'shipped');
        }]);
    }

    /**
     * COUNTING WITHOUT LOADING: COUNT query without hydrating models
     */
    public function countingWithoutLoading(): void
    {
        // Option 1: on a single model
        $user = User::find(1);
        $orderCount = $user->orders()->count(); // SELECT COUNT(*) FROM orders WHERE user_id = 1

        // Option 2: on a collection with withCount
        $users = User::withCount('orders')->get();
        foreach ($users as $user) {
            echo "{$user->name}: {$user->orders_count} orders";
        }
        // Adds orders_count attribute without loading Order models
    }
}
Output
// N+1 problem (100 users):
// Query count: 101
// Time: ~500ms
// Eager loading (100 users):
// Query count: 2
// Time: ~15ms
// Nested eager loading (100 users, 3 levels):
// Query count: 4
// Time: ~25ms
Eager Loading as a Shopping Trip
  • Install Laravel Debugbar — it shows all queries for each request and highlights N+1 patterns.
  • Install beyondcode/laravel-query-detector — it throws an exception when N+1 queries are detected.
  • Add query-detector to CI — the build fails if any request triggers N+1 queries.
  • Check Laravel Telescope's Queries tab — look for repeated queries with different WHERE IN values.
Production Insight
Add beyondcode/laravel-query-detector to your dev dependencies and configure it to throw exceptions in testing. This catches N+1 queries before they reach production. Configure it to ignore specific relationships that are intentionally lazy loaded (e.g., rarely accessed audit logs).
Key Takeaway
The N+1 problem is 1 query + N queries (one per model). Eager loading with with() reduces it to 2 queries regardless of model count. Nested eager loading (with('orders.items.product')) loads multiple levels in one call per level. Use withCount() for counting without loading. Always detect N+1 queries with Debugbar or query-detector in development.
Eager Loading Strategy
IfRelationship is accessed for every record in a collection
UseAlways eager load with ->with('relationship'). Never lazy load in a loop.
IfRelationship is accessed conditionally (only for some records)
UseUse conditional eager loading: ->with(['relationship' => fn($q) => $q->where(...)])
IfYou need only the count, not the related models
UseUse ->withCount('relationship') or $model->relationship()->count(). Avoids model hydration.
IfYou already have a collection and realize you need a relationship
UseUse ->load('relationship') on the collection. Runs the missing query and attaches results.

Query Scopes: Reusable Query Fragments

Query scopes are methods on a Model that modify the query builder. They encapsulate common query conditions and make them reusable across your application.

Local scopes: Methods prefixed with scope receive the query builder as the first argument and return the modified builder. Call them without the scope prefix: User::active()->recentlyLoggedIn()->get(). Chain multiple scopes for complex queries.

Global scopes: Applied automatically to every query on the model. SoftDeletes is a global scope that adds WHERE deleted_at IS NULL to every query. Use global scopes sparingly — they are invisible to developers who do not know they exist, making debugging harder.

Dynamic scopes: Scopes that accept parameters. User::createdAfter('2024-01-01') uses scopeCreatedAfter($query, $date). The parameter is passed after the query builder.

Scope composition: Scopes compose naturally. User::active()->recentlyLoggedIn()->admins()->get() chains three scopes. Each scope adds its condition to the query. The final SQL includes all three WHERE clauses.

When to use scopes vs. where(): Use scopes for conditions that appear in multiple places (active users, recent orders, published posts). Use where() inline for one-off conditions that are specific to a single query.

app/Models/Order.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    protected $fillable = ['user_id', 'status', 'total_in_cents', 'shipped_at'];

    protected $casts = [
        'total_in_cents' => 'integer',
        'shipped_at'     => 'datetime',
    ];

    // ── Relationships ────────────────────────────────────────────────────────

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }

    // ── Local Scopes ─────────────────────────────────────────────────────────

    /**
     * Scope: only orders with a specific status.
     * Usage: Order::withStatus('shipped')->get()
     */
    public function scopeWithStatus(Builder $query, string $status): Builder
    {
        return $query->where('status', $status);
    }

    /**
     * Scope: orders created within a date range.
     * Usage: Order::createdBetween('2024-01-01', '2024-12-31')->get()
     */
    public function scopeCreatedBetween(Builder $query, string $from, string $to): Builder
    {
        return $query->whereBetween('created_at', [$from, $to]);
    }

    /**
     * Scope: high-value orders above a threshold.
     * Usage: Order::highValue(10000)->get() // orders > $100
     */
    public function scopeHighValue(Builder $query, int $minCents = 5000): Builder
    {
        return $query->where('total_in_cents', '>=', $minCents);
    }

    /**
     * Scope: orders that have not been shipped yet.
     * Usage: Order::unshipped()->get()
     */
    public function scopeUnshipped(Builder $query): Builder
    {
        return $query->whereNull('shipped_at')
                     ->whereNot('status', 'cancelled');
    }

    /**
     * Scope: orders with at least N items.
     * Uses a subquery to avoid loading all items.
     * Usage: Order::withMinItems(5)->get()
     */
    public function scopeWithMinItems(Builder $query, int $min): Builder
    {
        return $query->has('items', '>=', $min);
    }

    /**
     * Scope: recent orders sorted by creation date.
     * Usage: Order::recent(10)->get() // 10 most recent orders
     */
    public function scopeRecent(Builder $query, int $limit = 20): Builder
    {
        return $query->orderBy('created_at', 'desc')
                     ->limit($limit);
    }

    // ── Composing scopes ─────────────────────────────────────────────────────
    // Order::withStatus('shipped')->highValue(10000)->recent(5)->get()
    // SQL: SELECT * FROM orders WHERE status = 'shipped' AND total_in_cents >= 10000
    //      ORDER BY created_at DESC LIMIT 5
Output
// Scope composition:
// Order::withStatus('shipped')->highValue(10000)->recent(5)->get()
// SQL: SELECT * FROM orders WHERE status = 'shipped' AND total_in_cents >= 10000 ORDER BY created_at DESC LIMIT 5
//
// Order::unshipped()->createdBetween('2024-01-01', '2024-03-31')->get()
// SQL: SELECT * FROM orders WHERE shipped_at IS NULL AND status != 'cancelled' AND created_at BETWEEN '2024-01-01' AND '2024-03-31'
Scopes as Filters on a Camera Lens
  • Global scope: applied to EVERY query automatically. Use for soft deletes, tenancy filters, or data isolation.
  • Local scope: applied only when explicitly called. Use for common filters that are not always needed.
  • Global scopes are invisible — developers may not know they exist. This makes debugging harder.
  • Use global scopes sparingly. Prefer local scopes that are explicitly called in each query.
Production Insight
Global scopes (like SoftDeletes) are invisible to new developers. When a query returns unexpected results, always check for global scopes with ->withoutGlobalScopes(). If the results change, a global scope is filtering. Document global scopes prominently in the model's docblock.
Key Takeaway
Local scopes encapsulate reusable query conditions. Chain scopes for complex queries: Order::withStatus('shipped')->highValue()->recent(5). Global scopes apply to every query — use sparingly. Use scopes for conditions that appear in multiple places. Use inline where() for one-off conditions.

Performance Patterns: Chunking, Lazy Collections, and Raw Queries

Eloquent is powerful but not always the most efficient tool. Knowing when to drop down to the query builder or use chunked processing is a critical production skill.

Chunking: User::chunk(500, function ($users) { ... }) loads 500 users at a time, processes them, then loads the next 500. This prevents loading millions of rows into memory. Use chunk() for batch processing, data exports, and background jobs.

chunkById vs chunk: chunk uses OFFSET/LIMIT which can skip or duplicate rows if rows are inserted/deleted during processing. chunkById uses WHERE id > lastId which is stable even if the table changes during processing. Always prefer chunkById for production batch processing.

Lazy collections: User::lazy(500) returns a LazyCollection that loads 500 rows at a time as you iterate. Unlike chunk(), you get a single iterable object instead of a callback. Use lazy() when you need to filter, map, or reduce a large dataset without loading it all into memory.

When to use DB::table(): Use the query builder (DB::table()) instead of Eloquent when: (1) you do not need model events, (2) you do not need relationships, (3) you need maximum performance for bulk operations, (4) you are joining tables that do not have models.

When to use raw SQL: Use DB::select() for complex queries that are hard to express in Eloquent: window functions, CTEs, complex aggregations. Eloquent is not designed for every SQL pattern — do not fight it.

select() to limit columns: User::select('id', 'name', 'email')->get() loads only the specified columns. Without select(), Eloquent loads all columns. For large tables with many columns (TEXT, JSON), selecting only needed columns significantly reduces memory usage and query time.

io/thecodeforge/performance-patterns.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
<?php

namespace Io\Thecodeforge\Eloquent;

use App\Models\User;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class PerformancePatterns
{
    /**
     * CHUNKING: process large datasets in batches
     * Each batch loads 500 rows, processes them, then loads the next 500.
     */
    public function processAllUsers(): void
    {
        User::chunk(500, function ($users) {
            foreach ($users as $user) {
                // Process each user (send email, update flag, etc.)
                $user->update(['processed_at' => now()]);
            }
        });
        // Memory usage: ~500 users at a time, not all users at once
    }

    /**
     * CHUNK BY ID: stable chunking even if the table changes during processing
     * Uses WHERE id > lastId instead of OFFSET/LIMIT.
     */
    public function exportOrders(): void
    {
        Order::with('user', 'items.product')
            ->chunkById(500, function ($orders) {
                foreach ($orders as $order) {
                    // Export to CSV, S3, etc.
                    $this->exportToCsv($order);
                }
            });
    }

    /**
     * LAZY COLLECTION: iterate over large datasets without loading all into memory
     * Returns a LazyCollection that loads rows on demand.
     */
    public function findHighValueCustomers(): void
    {
        $highValueUsers = User::lazy(500)
            ->filter(function ($user) {
                return $user->orders()->sum('total_in_cents') > 100000;
            })
            ->map(function ($user) {
                return [
                    'name'  => $user->name,
                    'email' => $user->email,
                    'total' => $user->orders()->sum('total_in_cents'),
                ];
            })
            ->values();

        // LazyCollection processes one batch at a time — not all users at once
    }

    /**
     * DB::TABLE: raw query builder for bulk operations without model events
     * 10x faster than Eloquent for bulk updates.
     */
    public function bulkDeactivateInactiveUsers(): int
    {
        // Eloquent version (slow — fires events, casts, per-row UPDATE):
        // User::where('last_login_at', '<', now()->subYear())->update(['is_active' => false]);
        // Actually, update() on Eloquent is also a single query.
        // The issue is when you need model events — then use chunk.

        // Query builder version (fast — single UPDATE query, no events):
        return DB::table('users')
            ->where('last_login_at', '<', now()->subYear())
            ->update(['is_active' => false]);
        // Single UPDATE query — no model instantiation, no events, no casting
    }

    /**
     * RAW SQL: for complex queries that Eloquent cannot express
     * Window functions, CTEs, complex aggregations.
     */
    public function getTopCustomersByMonth(): array
    {
        return DB::select("
            SELECT
                u.name,
                DATE_FORMAT(o.created_at, '%Y-%m') AS month,
                SUM(o.total_in_cents) AS total_spent,
                RANK() OVER (
                    PARTITION BY DATE_FORMAT(o.created_at, '%Y-%m')
                    ORDER BY SUM(o.total_in_cents) DESC
                ) AS monthly_rank
            FROM users u
            JOIN orders o ON o.user_id = u.id
            WHERE o.created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
            GROUP BY u.id, DATE_FORMAT(o.created_at, '%Y-%m')
            HAVING monthly_rank <= 10
            ORDER BY month DESC, monthly_rank ASC
        ");
    }

    /**
     * SELECT: limit columns to reduce memory and query time
     */
    public function efficientUserList(): void
    {
        // Bad: loads ALL columns including TEXT, JSON, BLOB fields
        $users = User::all();

        // Good: loads only needed columns
        $users = User::select('id', 'name', 'email')
            ->where('is_active', true)
            ->orderBy('name')
            ->get();
        // Smaller result set, less memory, faster query
    }

    private function exportToCsv(Order $order): void
    {
        // Export logic
    }
}
Output
// chunk(500) processes in batches:
// Batch 1: users 1-500 (1 query)
// Batch 2: users 501-1000 (1 query)
// Batch 3: users 1001-1500 (1 query)
// Total: N/500 queries instead of loading all N at once
// DB::table() bulk update:
// UPDATE users SET is_active = 0 WHERE last_login_at < '2025-04-06'
// Single query — no model events, no casting
Chunking as Assembly Line Batches
  • chunk uses OFFSET/LIMIT. If rows are inserted during processing, OFFSET shifts and rows are skipped.
  • chunk uses OFFSET/LIMIT. If rows are deleted during processing, OFFSET shifts and rows are duplicated.
  • chunkById uses WHERE id > lastId. Even if the table changes, the cursor is stable.
  • Always use chunkById in production. Use chunk only when the table is guaranteed to be static during processing.
Production Insight
User::all() loads every row and every column into memory. For a table with 100,000 rows and 20 columns including TEXT fields, this can consume 500MB+ of RAM. Use chunkById() or lazy() for large datasets. Use select() to limit columns. Never call all() on a table that can grow unbounded.
Key Takeaway
Use chunkById() for batch processing — it is stable against table changes during processing. Use lazy() for streaming iteration. Use DB::table() for bulk operations without model events. Use DB::select() for complex SQL (window functions, CTEs). Use select() to limit columns and reduce memory usage. Never call all() on large tables.

What Is Eloquent ORM — And Why Most Devs Misuse It

Eloquent is an Active Record ORM. Every model instance maps directly to a database row. You call $user->save() and it writes to the users table.

That coupling is the source of both its power and its danger. Done right, Eloquent eliminates boilerplate for 90% of your queries. Done wrong — and most codebases do it wrong — it hides N+1 disasters behind clean syntax and makes you forget the database exists.

Here’s the thing: Eloquent is not a query builder. It’s a data access layer with assumptions. Its convention-over-configuration approach assumes your foreign keys are model_id. It assumes timestamps exist. It assumes you want lazy loading by default.

When you violate those assumptions without understanding them, you get production fires. The smartest Laravel devs don't fight Eloquent. They learn its defaults, override them explicitly in the model, and profile every new relationship with DB::listen() before shipping.

UnderstandEloquentDefaults.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — php tutorial

use Illuminate\Support\Facades\DB;

// Never ship a relationship without profiling
DB::listen(function ($query) {
    if (stripos($query->sql, 'select') === 0 && $query->count > 5) {
        logger()->warning("N+1 risk detected: {$query->sql}", [
            'bindings' => $query->bindings,
            'time' => $query->time
        ]);
    }
});

// Then debug like a senior
$users = User::with('posts.comments')->get(); // explicit eager load
Output
// No output — this is a listener that logs to storage/logs/laravel.log
Production Trap:
Never assume Eloquent's defaults match your schema. Always override $table, $primaryKey, $timestamps, and $incrementing at model creation — not after a migration fails in staging.
Key Takeaway
Eloquent is a leaky abstraction. Know its defaults before you use it.

Security Benefits: Mass Assignment Isn't a Feature, It's a Liability

Mass assignment lets you pass an array of attributes to create() or update(). It cuts CRUD boilerplate in half. But it also hands a loaded gun to every form submission on your app.

If you omit $fillable or $guarded, a user can inject is_admin: true into their registration payload and walk out with admin privileges. Laravel protects you by default with a MassAssignmentException when the property isn't defined. Don't disable it.

Here’s the rule: always whitelist with $fillable. Never blacklist with $guarded — someone on your team will forget to add a new column to the guard list. Use $fillable for every model, even if it has 50 fields. Write a test that asserts $fillable exists.

And for God's sake, stop using Model::unguard() in production code. That's a testing tool. If you see it in a controller, the code review should end there.

MassAssignmentGuard.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforge — php tutorial

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    // Only these fields are safe for mass assignment
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    // Never put this in the model:
    // protected $guarded = [];  // accepts EVERYTHING
}

// In your controller — safe
User::create($request->only(['name', 'email', 'password']));

// In your test — safe to unguard temporarily
Model::unguard();
User::factory()->create(['is_admin' => true]);
Model::reguard();
Output
// No output — this is a security pattern, not a function
Senior Shortcut:
Add a CI rule that blocks any model without a $fillable property. It's the single cheapest defense against mass-assignment vulnerabilities.
Key Takeaway
Whitelist with $fillable. Guard against blacklist drift. Never unguard in production.

Why Developers Love Eloquent — Until They Hit 10k Requests Per Second

Eloquent makes the happy path absurdly fast. New Laravel devs love it because User::find($id) feels natural. They chain scopes, load relationships, and ship features in hours.

But at scale, the same features that made them productive become their bottleneck. Lazy loading becomes a page-killer. Mutators and accessors run on every serialization pass. withCount() fires subqueries that don't use indexes.

The love fades when ops calls at 3am because a dashboard query that worked locally with 100 rows now joins 6 tables across 500k records — and Eloquent's hydrator is creating 12,000 objects per request.

Here's the trick: use Eloquent for 80% of your app — the CRUD, the admin panels, the standard user flows. For that 20% of hot paths, switch to the Query Builder or raw SQL. Profile with clockwork. Cache the hydration. And never let a junior touch a BelongsToMany without a senior signing off on the pivot index.

Eloquent isn't the enemy. The enemy is treating it like a database abstraction layer. It's an ORM. Respect the 'R'.

WhenToBypassEloquent.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// io.thecodeforge — php tutorial

use Illuminate\Support\Facades\DB;

// Hot path: 10M orders, need throughput
// Don't hydrate Eloquent models for reporting
$revenueByMonth = DB::table('orders')
    ->selectRaw("DATE_TRUNC('month', created_at) as month, SUM(total) as revenue")
    ->where('status', 'paid')
    ->groupBy('month')
    ->get();

// Cold path: single order CRUD — use Eloquent
$order = Order::with('items.product')->find($orderId);
Output
// Collection of stdClass objects, each with 'month' and 'revenue' properties
Production Trap:
Eloquent hydration creates an object per row. For a 100k-row export, that's 100k PHP objects in memory. Use DB::cursor() or chunk raw results instead.
Key Takeaway
Use Eloquent for CRUD. Drop to the query builder for hot paths. Profile everything.

The Hidden Cost of Eloquent's Magic

Eloquent's magic methods and dynamic property access create an invisible tax on your application. Every $user->posts triggers a database query unless you explicitly eager load. That convenience you love becomes a production nightmare under load. The WHY: Eloquent trades explicit control for brevity. When you call User::find(1)->posts, Laravel intercepts the missing property, guesses you want the hasMany relationship, and fires a query. This magic is why you can write elegant code in 2 lines where raw SQL needs 10. But that same magic is why your page loads 5 seconds after deployment — each relationship access fires a fresh query. The solution: never trust magic. Treat every relationship access as a potential N+1. Use with() before rendering views. Profile with Laravel Debugbar. The moment you see repeated queries, you've found the hidden cost.

TrapQueries.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
// io.thecodeforge — php tutorial

// Bad: magic creates N+1 queries
$users = User::all();
foreach ($users as $user) {
    echo $user->posts->count(); // +1 query per user
}

// Good: explicit via eager loading
$users = User::with('posts')->get();
foreach ($users as $user) {
    echo $user->posts->count(); // 2 queries total
}
Output
5
3
7
1
// vs (with eager loading):
5
3
7
1
Production Trap:
Eloquent's ->load() inside loops is the silent killer. Always with() at query time, not load() after iteration starts.
Key Takeaway
Never trust Eloquent's magic — every dynamic property access is a potential N+1 query.

Why Raw SQL Still Wins for Complex Queries

Eloquent fails hard on reports, dashboards, and aggregations. The WHY: Eloquent models objects per row. A hasManyThrough with 5 joins, grouping, and SUM aggregates returns hundreds of thousands of objects you never use. Each object carries hydration overhead, relationship loading memory, and serialization costs. Raw SQL or the Query Builder wins because you get plain arrays — zero overhead. When your report needs SELECT department, SUM(revenue) FROM sales GROUP BY department, Eloquent forces you into Sale::selectRaw('department, SUM(revenue) as total')->groupBy('department')->get(). That still hydrates objects. Better: DB::select('SELECT department, SUM(revenue) as total FROM sales GROUP BY department') delivers arrays in half the time. For batch updates, DB::update('UPDATE users SET active = 1 WHERE last_login > NOW() - INTERVAL 30 DAY') avoids loading models entirely. Know when to drop the hammer. Eloquent is a scalpel; raw SQL is the sledgehammer when you need speed.

RawVsEloquent.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — php tutorial

$start = microtime(true);
// Slow: hydrates 10k objects
$reports = Sale::selectRaw('department, SUM(revenue) as total')
    ->groupBy('department')->get();

echo 'Eloquent: ' . (microtime(true) - $start) . 's';

$start = microtime(true);
// Fast: returns plain arrays
$reports = DB::select('
    SELECT department, SUM(revenue) as total 
    FROM sales GROUP BY department
');
echo 'Raw: ' . (microtime(true) - $start) . 's';
Output
Eloquent: 1.234s
Raw: 0.456s
Production Trap:
Eloquent's toArray() on large collections doubles memory — it hydrates objects then converts them. Use DB::select() for raw array output.
Key Takeaway
For aggregations and bulk updates, drop Eloquent. Raw SQL is faster and frees memory.

Conclusion: Eloquent Is a Tool, Not a Religion

Eloquent ORM thrives when you respect its boundaries. It excels at straightforward CRUD, relationship traversal, and rapid prototyping — but it punishes ignorance. The N+1 problem, mass-assignment vulnerabilities, and hidden query overhead are not bugs; they are consequences of treating magic as a black box. The best Laravel developers know when to reach for Eloquent and when to drop into raw SQL or the query builder. They use chunk() for memory safety, lazy() for streaming, and scopes to enforce business rules at the database level. Your production app doesn't care about developer convenience — it cares about predictable performance and secure data handling. Master Eloquent's trade-offs: eager load with with(), guard your fillable fields, and always measure query counts under load. Eloquent is a powerful abstraction, but abstraction without understanding is technical debt waiting to compound. Choose consciously, and your app will thank you long after the marketing glow fades.

ResponsibleEloquent.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// io.thecodeforge — php tutorial
// Use Eloquent for 80% of reads; raw SQL for analytics
$users = User::with('orders')
    ->whereHas('orders', fn($q) => $q->where('total', '>', 100))
    ->chunk(100, fn($chunk) =>
        Bus::dispatch(new ProcessUserOrders($chunk))
    );

// Fallback to raw when Eloquent generates 15+ joins
$heavyReport = DB::select('
    SELECT u.id, COUNT(o.id) as order_count
    FROM users u
    JOIN orders o ON u.id = o.user_id
    WHERE o.created_at > NOW() - INTERVAL 30 DAY
    GROUP BY u.id
    HAVING order_count > 5
');

// Guard fillable — never allow mass assignment on sensitive columns
class User extends Model
{
    protected $fillable = ['name', 'email'];
    protected $hidden = ['password', 'api_token'];
}
Production Trap:
Never trust a dev who says 'Eloquent is always the answer.' They've never debugged a 50-query page load under 10k RPS.
Key Takeaway
Judge Eloquent by its output, not its syntax — if it hides too much, write the SQL yourself.
● Production incidentPOST-MORTEMseverity: high

N+1 Query Problem Causes 12-Second Page Load on Admin Dashboard — 2,847 Queries per Request

Symptom
The admin dashboard page took 12 seconds to load. Laravel Telescope showed 2,847 queries for a single request. The database server CPU was at 100%. The slow query log showed thousands of identical SELECT queries with different WHERE IN (?) values. The page displayed 200 users, each with their orders, each order with its items, each item with its product.
Assumption
The team assumed a missing database index — they added indexes on foreign keys (no improvement). They assumed a slow query — they checked the slow query log (each individual query was fast, < 5ms). They assumed insufficient database resources — they upgraded the RDS instance (no improvement). The actual issue was the sheer number of queries, not the speed of each one.
Root cause
The controller loaded 200 users with User::paginate(200). The Blade template then accessed $user->orders (lazy loaded — 200 queries), $order->items (lazy loaded — 200 users x 5 orders = 1,000 queries), and $item->product (lazy loaded — 1,000 items x 1.5 products1 + 200 + 1,000 + 1,500 = 2,701 queries (approximately 2,847 with pagination overhead). Each query was fast (< 5ms), but 2,847 queries at 5ms each = 14.2 seconds of database time.
Fix
1. Added eager loading: User::with(['orders.items.product'])->paginate(200). This reduced queries from 2,847 to 4 (users, orders, items, products). 2. Added Laravel Debugbar to development to detect N+1 queries in real time. 3. Added a CI check using the beyondcode/laravel-query-detector package that fails the build if N+1 queries are detected. 4. Added database indexes on all foreign keys (order_id, product_id, user_id). 5. = 1,500 queries). Total: Reduced pagination from 200 to 50 per page. Page load time: 12 seconds to 180ms.
Key lesson
  • The N+1 problem is invisible in development with small datasets. It only manifests in production with real data volumes. Always use eager loading for relationships accessed in loops or collections.
  • Laravel Debugbar and beyondcode/laravel-query-detector catch N+1 queries during development. Add them to your dev dependencies and fail CI if N+1 queries are detected.
  • User::with('orders.items.product') eager loads three levels of relationships in 4 queries total. Without it, you get 1 + N + NM + NM*P queries.
  • Each individual query may be fast (< 5ms), but 2,847 queries at 5ms each = 14 seconds. The problem is query count, not query speed.
  • Paginate with eager loading: User::with('orders')->paginate(50). The eager loading applies to the paginated subset, not all rows.
Production debug guideFrom N+1 queries to mass assignment vulnerabilities — systematic debugging paths for Eloquent problems.6 entries
Symptom · 01
Page load is slow — database queries are the bottleneck.
Fix
Enable Laravel Debugbar or Telescope to see all queries for the request. Look for repeated identical queries with different IDs (N+1 pattern). Check if relationships are lazy loaded (accessed in a loop without eager loading). Fix: add ->with('relationship') to the base query. Use $fillable with only editable fields. Never use $guarded = [].
Symptom · 02
Bulk update is slow — updating 10,000 rows takes 30+ seconds.
Fix
Check if the code uses ->save() in a loop (each save() fires model events, casts attributes, and runs a separate UPDATE). Fix: use DB::table('users')->where('status', 'inactive')->update(['flagged' => true]) for bulk updates without model events. Use chunkById() if model events are needed.
Symptom · 03
'Trying to get property of non-object' error on relationship access.
Fix
Check if the relationship can return null (belongsTo without a foreign key constraint). Check if the related record exists in the database. Fix: use optional($user->profile)->avatar or $user->profile?->avatar (null-safe operator). Add a database foreign key constraint with ON DELETE CASCADE or ON DELETE SET NULL.
Symptom · 04
Mass assignment vulnerability — unexpected data written to the database.
Fix
Check if the model has $fillable or $guarded defined. If neither is defined, Eloquent blocks all mass assignment. If $fillable is defined, check if it includes fields that should not be mass-assignable (is_admin, role, balance). Fix: use $fillable with only user-editable fields. Never use $guarded = [].
Symptom · 05
Soft-deleted records appearing in queries unexpectedly.
Fix
Check if the model uses the SoftDeletes trait. By default, soft-deleted records are excluded. But if a relationship is defined without ->withTrashed(), accessing deleted related records returns null. If deleted records ARE appearing, check if someone called ->withTrashed() or ->onlyTrashed() on the query.
Symptom · 06
Query returns unexpected results — wrong records or missing records.
Fix
Check if a global scope is applied (e.g., SoftDeletes, tenancy scope). Run ->withoutGlobalScopes() to see if the results change. Check if a local scope is modifying the query. Check if the relationship uses a non-standard foreign key or local key.
★ Eloquent ORM Triage Cheat SheetFirst-response commands when pages are slow, queries are unexpected, or relationships return null.
Page load > 2 seconds — suspected N+1 queries.
Immediate action
Count queries and identify repeated patterns.
Commands
php artisan tinker --execute="DB::enableQueryLog(); App\Models\User::with('orders')->get(); dd(DB::getQueryLog());"
php artisan telescope # Check the Queries tab for the slow request
Fix now
If you see N repeated queries with different IDs, add ->with('relationship') to the base query.
'Trying to get property of non-object' on relationship access.+
Immediate action
Check if the related record exists and if the FK is nullable.
Commands
php artisan tinker --execute="\$u = App\Models\User::first(); dd(\$u->profile);"
php artisan tinker --execute="dd(App\Models\User::whereNull('profile_id')->count());"
Fix now
Use optional($user->profile)->avatar or $user->profile?->avatar. Add foreign key constraints.
Mass assignment exception when creating/updating a model.+
Immediate action
Check $fillable and $guarded on the model.
Commands
php artisan tinker --execute="dd((new App\Models\User())->getFillable());"
grep -rn 'fillable\|guarded' app/Models/
Fix now
Add the field to $fillable, or use ->fill() with only allowed fields. Never use $guarded = [].
Bulk update taking > 10 seconds.+
Immediate action
Check if the code uses save() in a loop.
Commands
grep -rn 'foreach.*save()' app/
php artisan tinker --execute="DB::enableQueryLog(); App\Models\User::chunk(100, function(\$users) { \$users->each->save(); }); dd(count(DB::getQueryLog()));"
Fix now
Replace save() loop with DB::table()->update() for bulk operations without model events.
Soft-deleted records appearing in query results.+
Immediate action
Check if withTrashed() or onlyTrashed() is called.
Commands
grep -rn 'withTrashed\|onlyTrashed' app/
php artisan tinker --execute="dd(App\Models\User::withTrashed()->whereNotNull('deleted_at')->count());"
Fix now
Remove withTrashed() calls. If accessing deleted related records, use ->withTrashed() on the relationship definition.
Query returns wrong number of records — suspected global scope.+
Immediate action
Check if global scopes are filtering results.
Commands
php artisan tinker --execute="dd(App\Models\User::withoutGlobalScopes()->count());"
php artisan tinker --execute="dd(App\Models\User::count());"
Fix now
If counts differ, a global scope is filtering. Check for SoftDeletes, tenancy scopes, or custom global scopes.
Relationship returns empty collection when data exists.+
Immediate action
Check foreign key names and relationship definition.
Commands
php artisan tinker --execute="dd(App\Models\User::first()->orders()->toSql());"
php artisan migrate:status | grep -i 'foreign\|index'
Fix now
If the SQL shows wrong column names, fix the relationship definition. If no index exists on the FK, add one.
Model events (created, updated, deleted) not firing.+
Immediate action
Check if DB::table() is used instead of Eloquent.
Commands
grep -rn 'DB::table.*update\|DB::table.*insert' app/
php artisan tinker --execute="App\Models\User::creating(function() { dd('fired'); }); App\Models\User::create(['name' => 'test']);"
Fix now
DB::table() bypasses Eloquent events. Use Eloquent models for operations that need events. Use DB::table() only for bulk operations.
Eloquent vs Query Builder vs Raw SQL
AspectEloquent ORMQuery Builder (DB::table)Raw SQL (DB::select)
ReadabilityHighest — reads like EnglishMedium — fluent but not naturalLow — raw SQL strings
Model eventsYes — created, updated, deletedNo — bypasses EloquentNo — bypasses Eloquent
RelationshipsYes — hasMany, belongsTo, etc.No — manual JOINs requiredNo — manual JOINs required
Mass assignment protectionYes — $fillable / $guardedNo — direct column accessNo — direct column access
PerformanceGood — overhead from hydrationBetter — no model instantiationBest — no abstraction overhead
Bulk operationsSlow with save() loop, OK with update()Fast — single UPDATE queryFast — single UPDATE query
Complex SQL (CTE, window functions)Not supportedLimited — subqueries onlyFull SQL support
TestingEasy — model factories, fakesMedium — harder to mockHard — must mock DB facade
Best forCRUD, relationships, business logicBulk operations, reports, joins without modelsComplex analytics, migrations, performance-critical queries

Key takeaways

1
You now understand what Laravel Eloquent ORM is and why it exists
2
You've seen it working in a real runnable example
3
Practice daily
the forge only works when it's hot
4
Eloquent maps tables to models, rows to objects, and relationships to methods. $fillable protects against mass assignment. $hidden prevents sensitive field leakage.
5
The N+1 problem is the most common performance issue. Eager loading with with() reduces N+1 queries to 2 queries. Always detect N+1 in development with Debugbar or query-detector.
6
Use chunkById() for batch processing
it is stable against table changes. Use DB::table() for bulk operations without model events. Use DB::select() for complex SQL.
7
Query scopes encapsulate reusable conditions. Chain scopes for complex queries. Use global scopes sparingly
they are invisible and make debugging harder.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What is Laravel Eloquent ORM in simple terms?
02
What is the N+1 query problem and how do I fix it?
03
What is the difference between $fillable and $guarded?
04
When should I use Eloquent vs DB::table() vs raw SQL?
05
What is the difference between chunk() and chunkById()?
06
How do I prevent sensitive data from appearing in API responses?
N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Laravel. Mark it forged?

12 min read · try the examples if you haven't

Previous
Laravel Routing
4 / 15 · Laravel
Next
Laravel Blade Templates