Eloquent ORM
Eloquent ORM patterns for models, relationships, query scoping, and database interactions in Laravel
You are an expert in Eloquent ORM for building Laravel applications. You leverage Eloquent's expressive API for clean data access while remaining vigilant about query performance, mass-assignment safety, and the boundaries between persistence logic and business logic. ## Key Points - Always declare `$fillable` or `$guarded` to prevent mass-assignment vulnerabilities. - Use `$casts` for every column that is not a plain string to ensure type safety. - Prefer relationship methods over raw joins for readability and maintainability. - Use `withCount()` and `withSum()` instead of loading entire relations just for aggregates. - Define inverse relationships on both sides so eager loading works in either direction. - Use database transactions (`DB::transaction()`) when multiple writes must succeed or fail together. - Create custom Eloquent casts for repeated value-object patterns (e.g., Money, Address). - **N+1 queries**: Forgetting `with()` when iterating over relationships. Use Laravel Debugbar or `Model::preventLazyLoading()` in development. - **Mass-assignment on unguarded models**: Never set `$guarded = []` in production without careful review. - **Mutating inside accessors**: Accessors should be pure; side effects belong in observers or service classes. - **Oversized `select *` queries**: Use `->select()` to limit columns on large tables. - **Ignoring soft deletes in raw queries**: `SoftDeletes` only applies to Eloquent; raw `DB::` queries bypass it.
skilldb get php-laravel-skills/Eloquent ORMFull skill: 228 linesEloquent ORM — PHP/Laravel
You are an expert in Eloquent ORM for building Laravel applications. You leverage Eloquent's expressive API for clean data access while remaining vigilant about query performance, mass-assignment safety, and the boundaries between persistence logic and business logic.
Core Philosophy
Eloquent's ActiveRecord pattern trades explicit SQL for expressive model methods, and that trade-off must be managed consciously. The convenience of $post->comments()->create([...]) is powerful, but it hides a database query behind a method call that looks like a simple property access. Every Eloquent method that touches the database -- save(), create(), delete(), relationship accessors -- should be used with awareness of what SQL it generates and when that SQL executes. The developer who thinks in queries while writing Eloquent produces performant code; the developer who treats Eloquent as an in-memory object graph produces N+1 nightmares.
Mass-assignment protection is not optional security theater. The $fillable and $guarded properties exist because HTTP request data is untrusted, and without explicit protection, an attacker can set any column on a model by adding unexpected fields to a request. Setting $guarded = [] is a conscious decision to disable this protection, and it should only be done when the input has already been validated and filtered through a Form Request. The default posture should be to explicitly list fillable fields, which also serves as documentation of which columns are user-modifiable.
Relationships are Eloquent's greatest strength and its most common performance trap. Defining relationships is easy; loading them efficiently requires discipline. The with() method for eager loading should be the default for any query whose results will access relationships. The whenLoaded() guard in API Resources and the preventLazyLoading() development mode are tools for enforcing this discipline. A codebase where lazy loading is the norm will have query counts that grow linearly with data volume, and the performance cliff arrives without warning when the dataset grows past a threshold.
Overview
Eloquent is Laravel's ActiveRecord ORM implementation. Each database table has a corresponding Model class used to interact with that table. Eloquent provides fluent query building, relationship management, attribute casting, events, and observers.
Core Concepts
Model Definition
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['title', 'slug', 'body', 'user_id', 'published_at'];
protected $casts = [
'published_at' => 'datetime',
'metadata' => 'array',
'is_featured' => 'boolean',
];
protected $attributes = [
'status' => 'draft',
];
}
Relationships
// One-to-Many
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
// Belongs-To
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
// Many-to-Many with pivot data
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class)
->withPivot('order')
->withTimestamps();
}
// Has-One-Through
public function authorCountry(): HasOneThrough
{
return $this->hasOneThrough(Country::class, User::class);
}
// Polymorphic
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
Query Scopes
// Local scope
public function scopePublished(Builder $query): Builder
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function scopeByAuthor(Builder $query, User $user): Builder
{
return $query->where('user_id', $user->id);
}
// Usage
$posts = Post::published()->byAuthor($user)->latest()->paginate(15);
Accessors and Mutators (Laravel 11+ Syntax)
use Illuminate\Database\Eloquent\Casts\Attribute;
protected function fullName(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) =>
$attributes['first_name'] . ' ' . $attributes['last_name'],
set: fn (string $value) => [
'first_name' => explode(' ', $value)[0],
'last_name' => explode(' ', $value)[1] ?? '',
],
);
}
Implementation Patterns
Eager Loading to Avoid N+1
// Bad: N+1 queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // query per iteration
}
// Good: eager load
$posts = Post::with(['author', 'tags', 'comments.user'])->get();
// Conditional eager loading
$posts = Post::when($includeComments, fn ($q) =>
$q->with('comments')
)->get();
// Lazy eager loading on an existing collection
$posts->load('author');
Chunking Large Datasets
// Process in chunks to limit memory
Post::where('status', 'draft')
->chunkById(500, function ($posts) {
foreach ($posts as $post) {
$post->update(['status' => 'archived']);
}
});
// Lazy collections for streaming
Post::where('status', 'active')->lazy()->each(function ($post) {
// processes one record at a time
});
Upserts and Bulk Operations
// Upsert: insert or update
Post::upsert(
[
['slug' => 'hello-world', 'title' => 'Hello World', 'views' => 1],
['slug' => 'second-post', 'title' => 'Second Post', 'views' => 1],
],
uniqueBy: ['slug'],
update: ['title', 'views'],
);
// updateOrCreate for single records
$post = Post::updateOrCreate(
['slug' => 'hello-world'],
['title' => 'Hello World', 'body' => 'Content here'],
);
Query Builder for Complex Queries
$results = Post::query()
->select('posts.*')
->selectRaw('COUNT(comments.id) as comments_count')
->leftJoin('comments', 'comments.post_id', '=', 'posts.id')
->where('posts.published_at', '<=', now())
->groupBy('posts.id')
->havingRaw('COUNT(comments.id) > ?', [5])
->orderByDesc('comments_count')
->paginate(20);
Best Practices
- Always declare
$fillableor$guardedto prevent mass-assignment vulnerabilities. - Use
$castsfor every column that is not a plain string to ensure type safety. - Prefer relationship methods over raw joins for readability and maintainability.
- Use
withCount()andwithSum()instead of loading entire relations just for aggregates. - Define inverse relationships on both sides so eager loading works in either direction.
- Use database transactions (
DB::transaction()) when multiple writes must succeed or fail together. - Create custom Eloquent casts for repeated value-object patterns (e.g., Money, Address).
Common Pitfalls
- N+1 queries: Forgetting
with()when iterating over relationships. Use Laravel Debugbar orModel::preventLazyLoading()in development. - Mass-assignment on unguarded models: Never set
$guarded = []in production without careful review. - Mutating inside accessors: Accessors should be pure; side effects belong in observers or service classes.
- Oversized
select *queries: Use->select()to limit columns on large tables. - Ignoring soft deletes in raw queries:
SoftDeletesonly applies to Eloquent; rawDB::queries bypass it. - Calling
->get()on unbounded queries: Always paginate or limit when the result set could be large.
Anti-Patterns
-
The anemic model — a model class with nothing but
$fillableand relationships, with all business logic scattered across controllers and service classes. While models should not become God objects, query scopes, accessors, mutators, and domain methods like$order->markAsPaid()belong on the model when they express domain behavior tied to the entity's data. -
Unguarded bulk operations — using
Model::unguard()or$guarded = []globally to avoid listing fillable fields, then passing unvalidated request data directly tocreate()orupdate(). This allows attackers to set any column, includingis_admin,role, orbalance. Always validate and filter input before it reaches the model. -
Relationship accessor abuse — accessing relationships like
$user->postsin loops without eager loading, generating a query per iteration. EnableModel::preventLazyLoading()in development to catch these issues early, and always usewith()orload()before iterating over relationships. -
Over-reliance on model events — using
creating,updating, anddeletingevents for complex business logic like sending notifications, updating related records, or calling external APIs. Model events are hard to test, hard to debug, and fire in unexpected contexts (seeders, migrations, tests). Move side effects to explicit service methods or dispatched jobs. -
Raw queries that bypass soft deletes — using
DB::table('posts')->where(...)or raw SQL to query a table that usesSoftDeletes, unknowingly including deleted records in results. Eloquent's soft-delete scope only applies to Eloquent queries. If raw queries are necessary, addWHERE deleted_at IS NULLexplicitly.
Install this skill directly: skilldb add php-laravel-skills
Related Skills
API Resources
Laravel API resources and transformers for building consistent, well-structured JSON API responses
Authentication
Laravel authentication using Sanctum for API tokens and SPAs, and Fortify for web-based auth flows
Blade Livewire
Blade templating engine and Livewire for building dynamic server-rendered UI in Laravel applications
Deployment
Deploying Laravel applications with Forge, Vapor, and general server deployment strategies
Queues Jobs
Laravel queues, jobs, and background task processing with Redis, SQS, and Horizon
Routing Middleware
Laravel routing, route groups, resource controllers, and middleware for request filtering and authentication