Developers coming from plain PHP often find Eloquent a little too magical: one line of code can fire many queries without you noticing. The orders page that was instant in development suddenly takes three seconds once the table holds a few thousand rows. The problem is rarely Eloquent itself; it is how it is used.
This guide explains Eloquent ORM in Laravel 11 through a small store example (customers, orders, products): how models work, CRUD, relationships, eager loading, accessors, scopes, soft deletes, and where the query builder or raw SQL is the better tool.
1. What an ORM Does
An ORM maps table rows to PHP objects. Eloquent follows the Active Record pattern: one model instance represents one row and knows how to save itself. Calls such as where() and get() are translated into SQL by the query builder and executed through PDO with bound parameters, so they are safe from SQL injection as long as you never paste raw input into whereRaw().
2. Models and Conventions
php artisan make:model Order -mf
-m adds a migration and -f a factory. By convention, Order maps to the orders table with an id key and automatic timestamps.
class Order extends Model
{
use SoftDeletes;
protected $fillable = ['customer_id', 'invoice_no', 'status', 'total', 'paid_at'];
protected function casts(): array
{
return [
'total' => 'decimal:2',
'paid_at' => 'datetime',
'meta' => 'array',
];
}
}
Laravel 11 defines casts in a casts() method. A datetime cast gives you a Carbon instance, so $order->paid_at->format('M j, Y') just works. $fillable limits mass assignment, stopping a user from slipping status=paid into a form submission.
3. CRUD
$order = Order::findOrFail($id);
$recent = Order::where('status', 'paid')->latest()->limit(20)->get();
$order = Order::create([
'customer_id' => $customer->id,
'invoice_no' => 'INV-2026-0001',
'status' => 'pending',
'total' => 49.90,
]);
$order->update(['status' => 'paid', 'paid_at' => now()]);
Customer::updateOrCreate(['email' => 'sam@example.com'], ['name' => 'Sam']);
$order->delete(); // soft delete
get() returns a Collection, first() returns a model or null, and findOrFail() throws an exception Laravel renders as a 404.
4. Relationships
| Type | Example | Methods |
|---|---|---|
| One to many | A customer has many orders | hasMany / belongsTo |
| One to one | A user has one profile | hasOne / belongsTo |
| Many to many | Orders contain products; products appear in many orders | belongsToMany + pivot |
| Has many through | Customer to order items via orders | hasManyThrough |
// Customer.php
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
// Order.php
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'order_items')
->withPivot(['qty', 'price'])
->withTimestamps();
}
Storing price on the pivot is deliberate: product prices change, but historic orders must keep the price charged at the time.
5. The N+1 Problem
$orders = Order::latest()->take(50)->get();
foreach ($orders as $order) {
echo $order->customer->name; // one query per order
}
That is 51 queries. Eager loading brings it down to two or three:
$orders = Order::with(['customer:id,name', 'products'])
->withCount('products')
->latest()
->take(50)
->get();
Catch mistakes early by enabling lazy-loading protection outside production:
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
More techniques are in optimizing Eloquent query performance.
6. Accessors, Mutators and Scopes
protected function invoiceNo(): Attribute
{
return Attribute::make(set: fn (string $v) => strtoupper(trim($v)));
}
protected function totalFormatted(): Attribute
{
return Attribute::make(get: fn () => '$' . number_format($this->total, 2));
}
public function scopePaid($query)
{
return $query->where('status', 'paid');
}
public function scopeBetween($query, $from, $to)
{
return $query->whereBetween('created_at', [$from, $to]);
}
$revenue = Order::paid()->between(now()->startOfMonth(), now())->sum('total');
7. Working With Large Tables
chunkById(500, ...)processes rows in safe batches even while updating them.lazy()iterates one model at a time with low memory.select([...])fetches only the columns you need.paginate(20)on list pages instead ofget().
8. When Not to Use Eloquent
Eloquent shines for CRUD and per-record logic. For heavy aggregate reports, such as revenue per category per month across millions of rows, the query builder or DB::select() is often faster and clearer because it skips hydrating a model for every row. Mixing both in one project is perfectly normal.
9. Common Errors
- MassAssignmentException: the column is missing from
$fillable. Add it rather than setting$guarded = []on user-facing models. - "Call to a member function on null": an empty
belongsTo. Use$order->customer?->nameorwithDefault(). - Records vanish or reappear: SoftDeletes filters automatically; use
withTrashed()when needed. - Totals off by a cent: money stored as float. Use
DECIMALcolumns with adecimal:2cast, or integer cents.
Healthy Model Checklist
$fillablelists only form-editable columns.- Dates, booleans, JSON and money have proper casts.
- Relationships declare return types.
- List pages use
with()andpaginate(). preventLazyLoadingis on in development.- Frequently filtered columns are indexed in migrations.
Design the tables before the models: designing a simple ERD helps you pick the right relationships, and safe database migrations covers changing them later without losing data.