Laravel Eloquent ORM Explained — Models, Relationships & Query Patterns
- You now understand what Laravel Eloquent ORM is and why it exists
- You've seen it working in a real runnable example
- Practice daily — the forge only works when it's hot
- 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)
Page load > 2 seconds — suspected N+1 queries.
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'Trying to get property of non-object' on relationship access.
php artisan tinker --execute="\$u = App\Models\User::first(); dd(\$u->profile);"php artisan tinker --execute="dd(App\Models\User::whereNull('profile_id')->count());"Mass assignment exception when creating/updating a model.
php artisan tinker --execute="dd((new App\Models\User())->getFillable());"grep -rn 'fillable\|guarded' app/Models/Bulk update taking > 10 seconds.
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()));"Soft-deleted records appearing in query results.
grep -rn 'withTrashed\|onlyTrashed' app/php artisan tinker --execute="dd(App\Models\User::withTrashed()->whereNotNull('deleted_at')->count());"Query returns wrong number of records — suspected global scope.
php artisan tinker --execute="dd(App\Models\User::withoutGlobalScopes()->count());"php artisan tinker --execute="dd(App\Models\User::count());"Relationship returns empty collection when data exists.
php artisan tinker --execute="dd(App\Models\User::first()->orders()->toSql());"php artisan migrate:status | grep -i 'foreign\|index'Model events (created, updated, deleted) not firing.
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']);"Production Incident
Production Debug GuideFrom N+1 queries to mass assignment vulnerabilities — systematic debugging paths for Eloquent problems.
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.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.
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.
<?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]); }); } }
// 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)
- Without $fillable, Eloquent blocks all mass assignment —
create()andupdate()throw a MassAssignmentException. - With $fillable, only listed fields can be set via
create()orupdate(). - 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.
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.
<?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 }
// $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
- 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.
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().
<?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 } }
// 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
- 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.
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.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.
<?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
// 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'
- 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.
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.
<?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 } }
// 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
- 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.
lazy() for large datasets. Use select() to limit columns. Never call all() on a table that can grow unbounded.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.| Aspect | Eloquent ORM | Query Builder (DB::table) | Raw SQL (DB::select) |
|---|---|---|---|
| Readability | Highest — reads like English | Medium — fluent but not natural | Low — raw SQL strings |
| Model events | Yes — created, updated, deleted | No — bypasses Eloquent | No — bypasses Eloquent |
| Relationships | Yes — hasMany, belongsTo, etc. | No — manual JOINs required | No — manual JOINs required |
| Mass assignment protection | Yes — $fillable / $guarded | No — direct column access | No — direct column access |
| Performance | Good — overhead from hydration | Better — no model instantiation | Best — no abstraction overhead |
| Bulk operations | Slow with save() loop, OK with update() | Fast — single UPDATE query | Fast — single UPDATE query |
| Complex SQL (CTE, window functions) | Not supported | Limited — subqueries only | Full SQL support |
| Testing | Easy — model factories, fakes | Medium — harder to mock | Hard — must mock DB facade |
| Best for | CRUD, relationships, business logic | Bulk operations, reports, joins without models | Complex analytics, migrations, performance-critical queries |
🎯 Key Takeaways
- You now understand what Laravel Eloquent ORM is and why it exists
- You've seen it working in a real runnable example
- Practice daily — the forge only works when it's hot
- Eloquent maps tables to models, rows to objects, and relationships to methods. $fillable protects against mass assignment. $hidden prevents sensitive field leakage.
- 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. - 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.
- Query scopes encapsulate reusable conditions. Chain scopes for complex queries. Use global scopes sparingly — they are invisible and make debugging harder.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QExplain the N+1 query problem in Eloquent. How do you detect it, and how do you fix it? Give a concrete example with a User -> Orders -> Items relationship.
- QWhat is the difference between hasMany and belongsTo? Which side of the relationship has the foreign key? How do you determine which relationship type to use?
- QWhat is mass assignment in Eloquent? How does Laravel protect against it? What is the difference between $fillable and $guarded?
- QWhen would you use DB::table() instead of an Eloquent model? What are the trade-offs?
- QExplain the difference between
chunk()and chunkById(). Why is chunkById() preferred for production batch processing? - QWhat is a query scope in Eloquent? Give an example of a local scope and a global scope. When would you use each?
- QHow does eager loading work in Eloquent? What SQL does User::with('orders.items')->get() generate?
Frequently Asked Questions
What is Laravel Eloquent ORM in simple terms?
Laravel Eloquent ORM is a fundamental concept in PHP. Think of it as a tool — once you understand its purpose, you'll reach for it constantly.
What is the N+1 query problem and how do I fix it?
The N+1 problem occurs when you load a collection of models and then access a relationship on each model in a loop. This triggers 1 query for the collection plus N queries (one per model) for the relationship. Fix it with eager loading: User::with('orders')->get() runs only 2 queries regardless of user count. Use Laravel Debugbar or beyondcode/laravel-query-detector to detect N+1 queries in development.
What is the difference between $fillable and $guarded?
$fillable is a whitelist — only listed fields can be mass-assigned via create() or update(). $guarded is a blackList — listed fields cannot be mass-assigned. Always prefer $fillable with only user-editable fields. Never use $guarded = [] which allows all fields to be mass-assigned, creating a security vulnerability.
When should I use Eloquent vs DB::table() vs raw SQL?
Use Eloquent for CRUD operations, relationships, and business logic — it provides model events, mass assignment protection, and readable code. Use DB::table() for bulk operations (update, delete) where model events are not needed — it is faster because it does not instantiate models. Use DB::select() for complex SQL (window functions, CTEs) that Eloquent cannot express.
What is the difference between chunk() and chunkById()?
chunk() uses OFFSET/LIMIT which can skip or duplicate rows if the table changes during processing. chunkById() uses WHERE id > lastId which is stable even if rows are inserted or deleted during processing. Always use chunkById() in production for batch processing.
How do I prevent sensitive data from appearing in API responses?
Add sensitive fields to the $hidden property on the model. For the User model, add 'password' and 'remember_token' to $hidden. These fields are excluded from toArray() and toJson() output. You can also use makeHidden() at query time: $user->makeHidden('ssn')->toArray().
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.